diff --git a/.editorconfig b/.editorconfig index cf640d53f..70e88aae5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,9 +1,13 @@ -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true \ No newline at end of file +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = off + +[*.md] +trim_trailing_whitespace = false diff --git a/.env b/.env index f52b47c53..5906be3f4 100644 --- a/.env +++ b/.env @@ -1,16 +1,15 @@ -NODE_ENV = production # 程序配置 ## 程序名称 -MAIN_VITE_TITLE = "ChatLab" +MAIN_VITE_TITLE="ChatLab" # 全局 API 配置 -MAIN_VITE_SERVER_API = 127.0.0.1 +MAIN_VITE_SERVER_API=127.0.0.1 # 浏览器环境 -RENDERER_VITE_SERVER_URL = +RENDERER_VITE_SERVER_URL= # 程序信息 -RENDERER_VITE_SITE_TITLE = "聊天记录分析" -RENDERER_VITE_SITE_KEYWORDS = "" -RENDERER_VITE_SITE_DES = "" -RENDERER_VITE_SITE_URL = "" -RENDERER_VITE_SITE_LOGO = "/assets/images/favicon.ico" +RENDERER_VITE_SITE_TITLE="重构你的社交记忆" +RENDERER_VITE_SITE_KEYWORDS="" +RENDERER_VITE_SITE_DES="" +RENDERER_VITE_SITE_URL="" +RENDERER_VITE_SITE_LOGO="/assets/images/favicon.ico" diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index a6f34fea7..000000000 --- a/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -out -.gitignore diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index e13fa17e0..000000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,19 +0,0 @@ -/* eslint-env node */ -require('@rushstack/eslint-patch/modern-module-resolution') - -module.exports = { - extends: [ - 'eslint:recommended', - 'plugin:vue/vue3-recommended', - '@electron-toolkit', - '@electron-toolkit/eslint-config-ts/eslint-recommended', - '@vue/eslint-config-typescript/recommended', - '@vue/eslint-config-prettier', - ], - rules: { - 'vue/require-default-prop': 'off', - 'vue/multi-word-component-names': 'off', - '@typescript-eslint/no-unused-vars': 'off', - '@typescript-eslint/no-explicit-any': 'off', - }, -} diff --git a/.github/workflows/cleanup-r2.yml b/.github/workflows/cleanup-r2.yml new file mode 100644 index 000000000..7d01c804b --- /dev/null +++ b/.github/workflows/cleanup-r2.yml @@ -0,0 +1,97 @@ +name: Cleanup R2 Old Versions + +on: + workflow_dispatch: + inputs: + keep_count: + description: 'Number of recent versions to keep' + required: false + default: '3' + type: string + workflow_call: + inputs: + keep_count: + description: 'Number of recent versions to keep' + required: false + default: '3' + type: string + secrets: + R2_ACCOUNT_ID: + required: true + R2_ACCESS_KEY_ID: + required: true + R2_SECRET_ACCESS_KEY: + required: true + R2_BUCKET_NAME: + required: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Configure AWS CLI for R2 + run: | + aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}" + aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}" + aws configure set default.region auto + + - name: Cleanup old versions from R2 + env: + R2_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }} + KEEP_COUNT: ${{ inputs.keep_count || '3' }} + run: | + echo "=== R2 Cleanup ===" + echo "Bucket: $R2_BUCKET" + echo "Keeping latest $KEEP_COUNT versions" + echo "" + + PREFIXES=$(aws s3api list-objects-v2 \ + --bucket "$R2_BUCKET" \ + --prefix "releases/download/v" \ + --delimiter "/" \ + --endpoint-url "$R2_ENDPOINT" \ + --query "CommonPrefixes[].Prefix" \ + --output text 2>/dev/null || true) + + if [ -z "$PREFIXES" ]; then + echo "No version directories found, nothing to clean up." + exit 0 + fi + + VERSIONS=$(echo "$PREFIXES" | tr '\t' '\n' | sed -n 's|releases/download/\(v[^/]*\)/.*|\1|p' | sort -V) + TOTAL=$(echo "$VERSIONS" | wc -l | tr -d ' ') + + echo "Found $TOTAL versions:" + echo "$VERSIONS" + echo "" + + if [ "$TOTAL" -le "$KEEP_COUNT" ]; then + echo "Total versions ($TOTAL) <= keep count ($KEEP_COUNT), nothing to delete." + exit 0 + fi + + DELETE_COUNT=$((TOTAL - KEEP_COUNT)) + TO_DELETE=$(echo "$VERSIONS" | head -n "$DELETE_COUNT") + KEPT=$(echo "$VERSIONS" | tail -n "$KEEP_COUNT") + + echo "Will delete $DELETE_COUNT old versions:" + echo "$TO_DELETE" + echo "" + echo "Will keep $KEEP_COUNT versions:" + echo "$KEPT" + echo "" + + for VERSION in $TO_DELETE; do + PREFIX="releases/download/${VERSION}/" + echo "Deleting: s3://${R2_BUCKET}/${PREFIX}" + aws s3 rm "s3://${R2_BUCKET}/${PREFIX}" \ + --recursive \ + --endpoint-url "$R2_ENDPOINT" + done + + echo "" + echo "=== Cleanup complete ===" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e63be98e4..59e0a7eca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,22 +13,79 @@ on: tags: - 'v*' +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: + prepare_release: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.meta.outputs.version }} + version_tag: ${{ steps.meta.outputs.version_tag }} + npm_tag: ${{ steps.meta.outputs.npm_tag }} + is_prerelease: ${{ steps.meta.outputs.is_prerelease }} + steps: + - name: Determine release metadata + id: meta + shell: bash + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + VERSION="${VERSION#v}" + else + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + fi + + VERSION_TAG="v${VERSION}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "version_tag=$VERSION_TAG" >> "$GITHUB_OUTPUT" + + if [[ "$VERSION" =~ -(beta|alpha|rc)(\.|$) ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + + if [[ "$VERSION" =~ -beta(\.|$) ]]; then + echo "npm_tag=beta" >> "$GITHUB_OUTPUT" + elif [[ "$VERSION" =~ -alpha(\.|$) ]]; then + echo "npm_tag=alpha" >> "$GITHUB_OUTPUT" + elif [[ "$VERSION" =~ -rc(\.|$) ]]; then + echo "npm_tag=rc" >> "$GITHUB_OUTPUT" + else + echo "npm_tag=latest" >> "$GITHUB_OUTPUT" + fi + + echo "Release version: $VERSION_TAG" + + # macOS 构建需要分架构 + # better-sqlite3 等原生模块会通过 prebuild 下载对应架构的预编译二进制 build-mac: - runs-on: macos-latest + needs: [prepare_release] + strategy: + matrix: + include: + - os: macos-14 # 使用 ARM runner 交叉编译 x64 + arch: x64 + - os: macos-14 # Apple Silicon (arm64) 原生构建 + arch: arm64 + runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '24' + package-manager-cache: false - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Get pnpm store directory shell: bash @@ -36,15 +93,42 @@ jobs: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ env.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + key: ${{ runner.os }}-${{ matrix.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | - ${{ runner.os }}-pnpm-store- + ${{ runner.os }}-${{ matrix.arch }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: | + echo "node-linker=hoisted" >> .npmrc + pnpm install --frozen-lockfile + + - name: Inject version into app package.json + shell: bash + run: | + VERSION="${{ needs.prepare_release.outputs.version }}" + echo "Injecting version: $VERSION" + node -e " + const fs = require('fs'); + for (const p of ['apps/desktop/package.json', 'apps/cli/package.json']) { + const pkg = JSON.parse(fs.readFileSync(p, 'utf-8')); + pkg.version = '${VERSION}'; + fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); + console.log(p + ' → ' + '${VERSION}'); + } + " + + - name: Cache Electron and electron-builder binaries + uses: actions/cache@v5 + with: + path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: ${{ runner.os }}-${{ matrix.arch }}-electron-builder-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.arch }}-electron-builder- # macOS 签名和公证需要的 API Key 文件 - name: Create Apple API Key File @@ -52,7 +136,7 @@ jobs: mkdir -p ~/private_keys echo "${{ secrets.APPLE_API_KEY }}" > ~/private_keys/AuthKey_${{ secrets.APPLE_API_KEY_ID }}.p8 - - name: Build Electron app for macOS + - name: Build Electron app for macOS (${{ matrix.arch }}) env: GH_TOKEN: ${{ secrets.GH_TOKEN }} # 代码签名 @@ -64,44 +148,49 @@ jobs: APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} # 分析服务 APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }} - run: pnpm build:mac + working-directory: apps/desktop + run: pnpm run build && pnpm exec electron-builder --mac --${{ matrix.arch }} --config electron-builder.yml -p never - - name: Upload macOS artifacts - uses: actions/upload-artifact@v4 + - name: Upload macOS artifacts (${{ matrix.arch }}) + uses: actions/upload-artifact@v6 with: - name: ChatLab-mac + name: ChatLab-mac-${{ matrix.arch }} path: | - dist/*.dmg - dist/*.zip - dist/*.yml - dist/*.json - dist/*.blockmap + apps/desktop/dist/*.dmg + apps/desktop/dist/*.zip + apps/desktop/dist/*.yml + apps/desktop/dist/*.json + apps/desktop/dist/*.blockmap if-no-files-found: warn retention-days: 1 # 只保留1天,Release后会上传到GitHub Releases build-win: + needs: [prepare_release] runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '24' + package-manager-cache: false - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Get pnpm store directory shell: bash run: | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + - name: Use Windows system tar (fix zstd cache issue) + shell: cmd + run: echo C:\Windows\System32>>%GITHUB_PATH% + - name: Setup pnpm cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ${{ env.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -109,112 +198,182 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: | + echo "node-linker=hoisted" >> .npmrc + pnpm install --frozen-lockfile + + - name: Inject version into app package.json + shell: pwsh + run: | + $env:RELEASE_VERSION = "${{ needs.prepare_release.outputs.version }}" + Write-Host "Injecting version: $env:RELEASE_VERSION" + node -e " + const fs = require('fs'); + const version = process.env.RELEASE_VERSION; + for (const p of ['apps/desktop/package.json', 'apps/cli/package.json']) { + const pkg = JSON.parse(fs.readFileSync(p, 'utf-8')); + pkg.version = version; + fs.writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); + console.log(p + ' -> ' + version); + } + " + + - name: Cache Electron and electron-builder binaries + uses: actions/cache@v5 + with: + path: | + ~/AppData/Local/electron/Cache + ~/AppData/Local/electron-builder/Cache + key: ${{ runner.os }}-electron-builder-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-electron-builder- - name: Build Electron app for Windows env: GH_TOKEN: ${{ secrets.GH_TOKEN }} # 分析服务 APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }} - run: pnpm build:win + working-directory: apps/desktop + run: pnpm run build && pnpm exec electron-builder --win --config electron-builder.yml -p never - name: Upload Windows artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ChatLab-win path: | - dist/*.exe - dist/*.yml - dist/*.json - dist/*.blockmap + apps/desktop/dist/*.exe + apps/desktop/dist/*.yml + apps/desktop/dist/*.json + apps/desktop/dist/*.blockmap if-no-files-found: warn retention-days: 1 # 只保留1天,Release后会上传到GitHub Releases + build-cli: + needs: [prepare_release] + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '24' + package-manager-cache: false + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v5 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: | + echo "node-linker=hoisted" >> .npmrc + pnpm install --frozen-lockfile + + - name: Inject CLI version + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('apps/cli/package.json', 'utf-8')); + pkg.version = '${{ needs.prepare_release.outputs.version }}'; + fs.writeFileSync('apps/cli/package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + - name: Build CLI (with Web UI) + env: + APTABASE_APP_KEY: ${{ secrets.APTABASE_APP_KEY }} + run: pnpm --filter chatlab-cli run build:full + + - name: Upload CLI build output + uses: actions/upload-artifact@v6 + with: + name: ChatLab-cli-build + path: | + apps/cli/dist + apps/cli/dist-web + if-no-files-found: error + retention-days: 1 + release: - needs: [build-mac, build-win] + needs: [prepare_release, build-mac, build-win, build-cli] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 + + - name: Download macOS artifacts (x64) + uses: actions/download-artifact@v8 with: - fetch-depth: 0 # 获取完整历史用于生成 changelog + name: ChatLab-mac-x64 + path: release-dist - - name: Download macOS artifacts - uses: actions/download-artifact@v4 + - name: Download macOS artifacts (arm64) + uses: actions/download-artifact@v8 with: - name: ChatLab-mac - path: dist + name: ChatLab-mac-arm64 + path: release-dist - name: Download Windows artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: ChatLab-win - path: dist + path: release-dist - name: List files - run: ls -la dist/ + run: ls -la release-dist/ - name: Generate Release Notes id: release_notes run: | - # 获取当前版本号 - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - CURRENT_VERSION="v${{ inputs.version }}" - else - CURRENT_VERSION="${{ github.ref_name }}" - fi - VERSION_NUMBER="${CURRENT_VERSION#v}" + CURRENT_VERSION="${{ needs.prepare_release.outputs.version_tag }}" + VERSION_NUMBER="${{ needs.prepare_release.outputs.version }}" echo "Current version: $CURRENT_VERSION" - # 获取上一个 tag - PREVIOUS_TAG=$(git tag --sort=-creatordate | grep -v "^${CURRENT_VERSION}$" | head -n 1) + # 按当前版本号从 changelog JSON 中提取 summary,避免补发旧版本时误用第一条日志。 + EN_SUMMARY=$(jq -r --arg version "$VERSION_NUMBER" 'map(select(.version == $version))[0].summary // "See changelog for details"' changelogs/en.json 2>/dev/null || echo "See changelog for details") + CN_SUMMARY=$(jq -r --arg version "$VERSION_NUMBER" 'map(select(.version == $version))[0].summary // "详见更新日志"' changelogs/cn.json 2>/dev/null || echo "详见更新日志") - if [ -z "$PREVIOUS_TAG" ]; then - echo "No previous tag found, using first commit" - COMMIT_RANGE="HEAD" - else - echo "Previous tag: $PREVIOUS_TAG" - COMMIT_RANGE="${PREVIOUS_TAG}..HEAD" - fi + echo "EN summary: $EN_SUMMARY" + echo "CN summary: $CN_SUMMARY" # 生成 release notes { + echo "## What's New" + echo "" + echo "$EN_SUMMARY" + echo "" + echo "## 更新内容" + echo "" + echo "$CN_SUMMARY" + echo "" + echo "---" + echo "" + echo "Full Changelog: [English](https://github.com/ChatLab/ChatLab/blob/main/changelogs/en.md) | [中文](https://github.com/ChatLab/ChatLab/blob/main/changelogs/cn.md) | [日本語](https://github.com/ChatLab/ChatLab/blob/main/changelogs/ja.md)" echo "" - - # 提取 feat commits - FEATS=$(git log $COMMIT_RANGE --pretty=format:"%s" --grep="^feat" 2>/dev/null | sed 's/^feat[:(]//' | sed 's/^[^)]*): //' | sed 's/^/- ✨ /') - if [ -n "$FEATS" ]; then - echo "### 新功能" - echo "$FEATS" - echo "" - fi - - # 提取 fix commits - FIXES=$(git log $COMMIT_RANGE --pretty=format:"%s" --grep="^fix" 2>/dev/null | sed 's/^fix[:(]//' | sed 's/^[^)]*): //' | sed 's/^/- 🐛 /') - if [ -n "$FIXES" ]; then - echo "### 修复" - echo "$FIXES" - echo "" - fi - - # 如果没有 feat 和 fix,显示提示 - if [ -z "$FEATS" ] && [ -z "$FIXES" ]; then - echo "- 常规更新和优化" - echo "" - fi - echo "---" echo "" echo "## Download" echo "" echo "| Platform | File |" echo "|-----------------|-------------|" - echo "| Mac (Apple Silicon) | [ChatLab-${VERSION_NUMBER}-arm64.dmg](https://github.com/hellodigua/ChatLab/releases/download/v${VERSION_NUMBER}/ChatLab-${VERSION_NUMBER}-arm64.dmg) |" - echo "| Mac (Intel) | [ChatLab-${VERSION_NUMBER}-x64.dmg](https://github.com/hellodigua/ChatLab/releases/download/v${VERSION_NUMBER}/ChatLab-${VERSION_NUMBER}-x64.dmg) |" - echo "| Windows | [ChatLab-${VERSION_NUMBER}-setup.exe](https://github.com/hellodigua/ChatLab/releases/download/v${VERSION_NUMBER}/ChatLab-${VERSION_NUMBER}-setup.exe) |" + echo "| Mac (Apple Silicon) | [ChatLab-${VERSION_NUMBER}-arm64.dmg](https://github.com/ChatLab/ChatLab/releases/download/v${VERSION_NUMBER}/ChatLab-${VERSION_NUMBER}-arm64.dmg) |" + echo "| Mac (Intel) | [ChatLab-${VERSION_NUMBER}-x64.dmg](https://github.com/ChatLab/ChatLab/releases/download/v${VERSION_NUMBER}/ChatLab-${VERSION_NUMBER}-x64.dmg) |" + echo "| Windows | [ChatLab-${VERSION_NUMBER}-setup.exe](https://github.com/ChatLab/ChatLab/releases/download/v${VERSION_NUMBER}/ChatLab-${VERSION_NUMBER}-setup.exe) |" } > release_notes.md echo "Generated release notes:" @@ -223,14 +382,125 @@ jobs: - name: Create Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.ref_name }} - name: ChatLab ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} + tag_name: ${{ needs.prepare_release.outputs.version_tag }} + name: ChatLab ${{ needs.prepare_release.outputs.version }} body_path: release_notes.md files: | - dist/*.exe - dist/*.dmg - dist/*.zip - dist/*.yml - dist/*.blockmap + release-dist/*.exe + release-dist/*.dmg + release-dist/*.zip + release-dist/*.yml + release-dist/*.blockmap env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # 同步到 Cloudflare R2(国内镜像) + # 注意:beta/alpha/rc 等预发布版本不上传到 R2 + - name: Print R2 upload metadata + run: | + echo "Release tag: ${{ needs.prepare_release.outputs.version_tag }}" + echo "Prerelease: ${{ needs.prepare_release.outputs.is_prerelease }}" + + # 上传所有文件到版本目录 + - name: Upload to Cloudflare R2 (version directory) + if: needs.prepare_release.outputs.is_prerelease == 'false' + uses: ryand56/r2-upload-action@latest + with: + r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + r2-bucket: ${{ secrets.R2_BUCKET_NAME }} + source-dir: release-dist + destination-dir: releases/download/${{ needs.prepare_release.outputs.version_tag }} + + # 准备 yml 文件用于根目录上传 + # 需要修改 yml 中的 path/url 为包含版本号的完整路径 + - name: Prepare yml files for root upload + if: needs.prepare_release.outputs.is_prerelease == 'false' + run: | + mkdir -p dist-yml + VERSION_TAG="${{ needs.prepare_release.outputs.version_tag }}" + + # 处理所有 yml 文件 + for yml_file in release-dist/*.yml; do + filename=$(basename "$yml_file") + echo "Processing $filename..." + + # 使用 sed 修改 path 和 url 字段,添加版本目录前缀 + # path: ChatLab-x.x.x-arm64.dmg → path: vx.x.x/ChatLab-x.x.x-arm64.dmg + # url: ChatLab-x.x.x-arm64.dmg → url: vx.x.x/ChatLab-x.x.x-arm64.dmg + sed -E "s|^(path: )(.+)|\1${VERSION_TAG}/\2|g; s|^( - url: )(.+)|\1${VERSION_TAG}/\2|g" "$yml_file" > "dist-yml/$filename" + + echo "Original:" + cat "$yml_file" + echo "" + echo "Modified:" + cat "dist-yml/$filename" + echo "---" + done + + # 上传 yml 文件到根目录(覆盖旧版本,electron-updater 从这里检测更新) + - name: Upload yml files to Cloudflare R2 (root directory) + if: needs.prepare_release.outputs.is_prerelease == 'false' + uses: ryand56/r2-upload-action@latest + with: + r2-account-id: ${{ secrets.R2_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }} + r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }} + r2-bucket: ${{ secrets.R2_BUCKET_NAME }} + source-dir: dist-yml + destination-dir: releases/download + + publish-cli: + needs: [prepare_release, build-cli] + runs-on: ubuntu-latest + + # 必须显式声明 id-token 写入权限,用于 OIDC 与 npm 握手交换临时 Token + permissions: + contents: read + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + package-manager-cache: false + + - name: Inject CLI version + run: | + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('apps/cli/package.json', 'utf-8')); + pkg.version = '${{ needs.prepare_release.outputs.version }}'; + fs.writeFileSync('apps/cli/package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + - name: Download CLI build output + uses: actions/download-artifact@v8 + with: + name: ChatLab-cli-build + path: apps/cli + + - name: Copy README for npm publish + run: cp README.md apps/cli/README.md + + - name: Publish to npm + working-directory: apps/cli + run: npm publish --ignore-scripts --provenance --tag ${{ needs.prepare_release.outputs.npm_tag }} + + cleanup-r2: + needs: [prepare_release, release] + if: needs.prepare_release.outputs.is_prerelease == 'false' + uses: ./.github/workflows/cleanup-r2.yml + with: + keep_count: '3' + secrets: + R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} + R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} diff --git a/.gitignore b/.gitignore index 812e7da96..76a0865a2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,17 +15,22 @@ yarn-error.log* *.log error-log-*.txt -# Editor +# IDE +.vscode/* !.vscode/settings.json !.vscode/snippets.code-snippets # Project dist/ +dist-web/ +docs/.vitepress/cache/ +docs/.vitepress/dist/ +apps/cli/native/ .history/ out -.obskey release-config.json src/auto-imports.d.ts +src/components.d.ts *.tsbuildinfo # 使用npm作为包管理器 @@ -33,6 +38,7 @@ yarn.lock # AI .cursor -.vscode +.antigravitycli .docs +.playwright-mcp/ diff --git a/.npmrc b/.npmrc index 7873f592e..2c910cffd 100644 --- a/.npmrc +++ b/.npmrc @@ -1,42 +1,5 @@ -# 参考 https://docs.npmjs.com/files/npmrc +# Keep dependency resolution consistent for future installs. +resolution-mode=highest -# 设置使用淘宝镜像地址 -registry=https://registry.npmmirror.com - -# 设置一些二进制文件镜像地址 -disturl=https://npmmirror.com/dist -chromedriver-cdnurl=https://npmmirror.com/mirrors/chromedriver -couchbase-binary-host-mirror=https://npmmirror.com/mirrors/couchbase/v{version} -debug-binary-host-mirror=https://npmmirror.com/mirrors/node-inspector -electron-mirror=https://npmmirror.com/mirrors/electron/ -ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ -electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/ -flow-bin-binary-host-mirror=https://npmmirror.com/mirrors/flow/v -fse-binary-host-mirror=https://npmmirror.com/mirrors/fsevents -fuse-bindings-binary-host-mirror=https://npmmirror.com/mirrors/fuse-bindings/v{version} -git4win-mirror=https://npmmirror.com/mirrors/git-for-windows -gl-binary-host-mirror=https://npmmirror.com/mirrors/gl/v{version} -grpc-node-binary-host-mirror=https://npmmirror.com/mirrors -hackrf-binary-host-mirror=https://npmmirror.com/mirrors/hackrf/v{version} -leveldown-binary-host-mirror=https://npmmirror.com/mirrors/leveldown/v{version} -leveldown-hyper-binary-host-mirror=https://npmmirror.com/mirrors/leveldown-hyper/v{version} -mknod-binary-host-mirror=https://npmmirror.com/mirrors/mknod/v{version} -node-sqlite3-binary-host-mirror=https://npmmirror.com/mirrors -node-tk5-binary-host-mirror=https://npmmirror.com/mirrors/node-tk5/v{version} -nodegit-binary-host-mirror=https://npmmirror.com/mirrors/nodegit/v{version}/ -operadriver-cdnurl=https://npmmirror.com/mirrors/operadriver -phantomjs-cdnurl=https://npmmirror.com/mirrors/phantomjs -profiler-binary-host-mirror=https://npmmirror.com/mirrors/node-inspector/ -puppeteer-download-host=https://npmmirror.com/mirrors -python-mirror=https://npmmirror.com/mirrors/python -rabin-binary-host-mirror=https://npmmirror.com/mirrors/rabin/v{version} -sass-binary-site=https://npmmirror.com/mirrors/node-sass -sodium-prebuilt-binary-host-mirror=https://npmmirror.com/mirrors/sodium-prebuilt/v{version} -sqlite3-binary-site=https://npmmirror.com/mirrors/sqlite3 -utf-8-validate-binary-host-mirror=https://npmmirror.com/mirrors/utf-8-validate/v{version} -utp-native-binary-host-mirror=https://npmmirror.com/mirrors/utp-native/v{version} -zmq-prebuilt-binary-host-mirror=https://npmmirror.com/mirrors/zmq-prebuilt/v{version} -phantomjs_cdnurl=https://npmmirror.com/mirrors/phantomjs/ +# 历史项目依赖兼容:保留 hoist 行为,避免安装结构变化带来回归 shamefully-hoist=true -# 允许 better-sqlite3 构建脚本 -better-sqlite3_binary_host_mirror=https://npmmirror.com/mirrors/better-sqlite3 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.prettierignore b/.prettierignore index 9c6b791d5..345ab59ed 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,7 @@ out dist pnpm-lock.yaml -LICENSE.md +LICENSE tsconfig.json tsconfig.*.json +docs/**/*.md diff --git a/.prettierrc.yaml b/.prettierrc.yaml index 45384efd4..65b9f9d4f 100644 --- a/.prettierrc.yaml +++ b/.prettierrc.yaml @@ -7,3 +7,4 @@ useTabs: false endOfLine: 'lf' htmlWhitespaceSensitivity: 'ignore' arrowParens: 'always' +proseWrap: 'never' diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..aacba4bb3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "i18n-ally.localesPaths": ["src/i18n/locales"], + "i18n-ally.keystyle": "nested", + "i18n-ally.namespace": true, + "i18n-ally.pathMatcher": "{locale}/{namespaces}.json", + "i18n-ally.sourceLanguage": "zh-CN" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..2ab2bf9ad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +## 开发流程 + +- 文档:开始开发前,请先查看公开开发指南 `docs/cn/contributing/development.md`。如果工作区存在 `.docs/`,再查看 `./.docs/README.md` 并阅读与当前需求相关的文档;`.docs/` 是可选的个人或团队私有开发上下文,可用于维护任务、决策、AI 协作记忆和临时规划,公开文档与公开 PR 不应依赖 `.docs/` 才能理解。 +- 目标:用最小改动快速交付正确、可维护、可回归的业务结果 +- 每次完成任务后,对产生修改的文件进行类型检查、lint检查和format格式化,指定修改的文件路径去执行,确保代码质量 +- 在执行检查时,如果有其他与本次修改无关的报错,也需要一并修复 + +## 审查与判断 + +- 处理外部 review、bug 报告或架构建议时,必须先核对代码事实,再决定接受或反驳;不要因为 reviewer 表达确定就直接改代码 +- 判断问题是否成立时,至少阅读相关函数、调用方、被调用方、相邻测试和现有文档;不能只看 diff 或单行评论 +- 如果涉及依赖、框架、OpenAI/LLM SDK、数据库、打包发布等外部行为,应优先查官方文档、源码或类型定义,不凭记忆判断 +- 结论必须包含证据:涉及的文件/函数、当前行为、风险、建议修复方式,以及已运行或应运行的验证 + +## 项目地图 + +- `src/`:共享前端应用代码,包含页面、组件、状态、服务封装和 i18n +- `apps/desktop/`:Electron 主进程、preload、桌面端构建与平台能力适配 +- `apps/cli/`:CLI、HTTP API、CLI Web 运行时、导入命令和本地服务入口 +- `packages/core/`:平台无关的核心模型、查询、导入去重、图表和 AI 静态定义 +- `packages/node-runtime/`:Node.js 运行时能力,包括 SQLite 适配、数据库迁移、AI 管理、导出、缓存和数据目录 +- `packages/tools/`:AI 工具定义、工具 registry 和数据访问 provider +- `packages/parser/`:聊天导出格式解析器和格式识别 +- `packages/http-routes/`:Electron 和 CLI Web 复用的 HTTP route +- `docs/`:公开文档站源码;`.docs/`:私有开发上下文和任务记录,不作为公开 PR 理解前提 +- 更细的架构说明继续以 `docs/cn/contributing/development.md` 和 `.docs/README.md` 为准,不在根 `AGENTS.md` 里重复维护 + +## 测试 + +- 测试文件位置:与单个业务模块强相关的单元测试应就近放在被测文件同目录,命名为 `*.test.ts` / `*.test.js`;跨模块、集成、E2E、测试工具或不明显归属某个业务文件的测试放在根目录 `tests/`;某个 app/package 专属测试放在对应的 `apps/*` 或 `packages/*` 内,遵循该子项目现有约定 +- 测试目标:测试应优先覆盖用户数据安全、数据库迁移、导入解析、权限/认证、AI 工具权限、配置/API key 迁移、跨端共享逻辑和已发生过的回归;不要为了提高数量给低风险 getter、常量、样式或纯展示细节机械补测 +- 必须加测试:修复会影响用户数据、业务逻辑、异步任务、缓存状态、跨端共享 service、公开 API 契约、导入解析、去重逻辑、权限认证、AI 工具 allowlist、配置/API key 迁移或数据库 schema/迁移的行为 bug 时,必须先加能失败的回归测试;修改上述高风险模块时也必须加或更新测试 +- 可以不加测试:只改 UI 文案、i18n key/翻译、样式、类型声明、日志、注释、无行为变化的小重构,或修复低风险展示细节时,可以不新增测试,但仍需运行相关类型检查、lint 和 format;不要为了低价值页面文案或源码字符串扫描新增脆弱测试 +- 测试分层:纯函数、解析/格式化、权限判断、参数规范化、错误分支优先写单元测试;SQL 行为、数据库迁移、文件迁移、导入写库、Fastify route、跨包 service 优先写集成测试;Electron、真实浏览器、真实 LLM、真实网络仅用于少量关键 E2E/Smoke,并默认通过环境变量显式启用 +- 避免重复测试:新增测试前先搜索同类断言;如果下层单测已覆盖算法,上层只测调用链是否接通,不重复枚举算法细节;如果测试只锁定实现写法、失败后不指向用户可见风险,应合并、改写或删除;优先写能覆盖全量场景的通用断言,而非针对单个条目的专项断言——若通用断言已完整覆盖,单个断言是冗余的 +- 测试替身选择:涉及 SQL、数据库迁移、Fastify route 或跨包 service 时,优先使用轻量内存 SQLite / 临时文件 fixture 验证真实行为;只有在测试纯适配边界时才使用 mock/fake。避免用大量 `sql.includes(...)` 一类字符串匹配来模拟数据库行为 +- 适配层测试边界:CLI/Electron/Web route、IPC adapter、tool adapter 只验证参数传递、权限过滤、错误映射和返回契约;core 已覆盖的算法矩阵不要在入口层重复展开 + +## 命令与验证 + +- 类型检查:Node/CLI/Electron 主进程相关改动运行 `pnpm run type-check:node`;前端/Vue 相关改动运行 `pnpm run type-check:web`;跨端或发布前改动运行 `pnpm run type-check:all` +- Lint:优先对修改文件运行 `pnpm exec eslint `;需要全量修复时再运行 `pnpm lint` +- Format:优先对修改文件运行 `pnpm exec prettier --write `;大范围格式化才运行 `pnpm format` +- 单元/集成测试:日常默认运行 `pnpm test` 或 `pnpm run test:unit`;优先运行相关测试文件时用 `pnpm test -- path/to/file.test.ts` +- 文档:修改公开文档或 VitePress 配置后运行 `pnpm docs:build`;只改 `.docs/` 私有任务文档时不需要构建公开文档站 +- E2E/Smoke:`pnpm run test:e2e:launcher`、`pnpm run test:e2e:smoke` 和真实 LLM/真实 Electron/真实网络测试只在相关功能需要时运行,不加入默认 `pnpm test` +- 最后检查:提交前运行 `git diff --check`,确认没有空白错误 + +## 代码规范 + +- 多语言:代码中的日志、注释、AI 工具描述、错误消息等非 UI 文本默认使用英文。当有运行时 locale 可用时(如工具返回结果、AI 看到的文本),应通过 `isChineseLocale(locale)` 等机制支持中英双语。数据清洗中与聊天平台格式匹配的标签(如 `[分享]`、`[图片]`)保持原始语言不变。UI 文案的国际化遵循 `.docs/rules/i18n.md` + +## 日志 + +- 统一入口:Node 侧(Electron 主进程 / CLI / CLI Web)通过 `@openchatlab/node-runtime` 的 `appLogger`(`appLogger.info/warn/error/debug(scope, message, data?)`)落盘到 `logs/app.log`;前端(Vue)通过 `src/services/log-report.ts` 经 `POST /_web/logs/report` 上报。不要为通用日志新写 `fs.appendFileSync` 或新的 logger 文件;AI 用 `AiLogger`、导入性能用 `perf-logger` 保持现状。 +- **新增或修改功能时必须补必要日志**:关键路径要留下足够的可排查痕迹——成功节点用 `info`(如启动、迁移、数据库/配置变更、导入开始/完成、外部调用、auth 结果),失败分支用 `error` 并把 `Error` 直接作为 `data` 传入(自动提取 message/stack)。判断标准:用户带着这个功能报问题时,仅凭 `logs/app.log` 能否还原到出错的那一步;不能,就说明日志不够。 +- 适度原则:不要在高频热路径或循环里打 `info`,详细诊断用 `debug`(默认 `INFO` 级不落盘,`CHATLAB_LOG_LEVEL=debug` 开启);日志、注释等非 UI 文本用英文,且**不得写入聊天明文、API Key、token 等隐私数据**。 +- 级别与轮转:`app.log` 达 10MB 自动原子 rename 为 `app.old.log`,无需手动清理。 + +## 架构边界 + +- 多端复用:维护 Electron 和 CLI Web 的共享业务逻辑时,优先在 `packages/node-runtime/src/services/` 下实现,禁止在路由/IPC handler 中绕过 core 直接写 SQL。详见 `.docs/README.md` 的"多端逻辑复用"章节。 + +## 兼容与迁移 + +- 运行时应读写当前 canonical 数据结构;旧 schema、旧字段名、旧配置形状应优先在数据库迁移、配置迁移或解析加载阶段 normalize,不在业务热路径长期保留多套分支 +- 保留兼容必须能说明对应的已发布版本、用户数据或公开 API 契约;不要为了假设中的旧状态添加永久 alias、fallback 或双写逻辑 +- 修改数据库 schema、AI 数据、配置文件、数据目录或导入格式时,必须考虑从上一个稳定版本和更早已发布版本升级的路径,并补充能证明数据不丢失的测试或验证 +- 会让旧版 CLI/Desktop/MCP 无法安全读写同一 `userDataDir` 的变更,必须通过 `.chatlab-meta.json` 提升数据目录最低运行时版本,并接入 CLI/Desktop/MCP 启动检查;详细规则见 `docs/cn/contributing/development.md` 的“数据目录兼容门禁” +- 如果异常状态可以通过中断、报错和后续人工/AI 处理解决,不要预先加入复杂防御逻辑;优先保持迁移路径清晰、可验证 + +## 安全与发布 + +- 不得提交真实 API Key、token、用户聊天数据库、个人数据目录、日志、截图或包含隐私的导出文件 +- 修改依赖、lockfile、构建产物、发布脚本、版本号、changelog、npm publish、release 流程时,必须先获得明确确认 +- 涉及 API Key、auth profile、配置迁移、数据目录迁移和数据库迁移时,必须优先保护已有用户数据,并提供回滚或失败中断策略 + +## 提交规范 + +- Commit 规范:使用 Conventional Commits。scope 规则——通用改动 scope 随意(如 `ai`、`import`、`sidebar` 等模块名);仅当改动是**平台特有**时才使用平台 scope(`electron`、`cli`、`web`)。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 6e97533fb..064f58f70 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,148 @@ -# ChatLab +
+ + + ChatLab + -简体中文 | [English](./README_en.md) +Your chat history, finally yours. -ChatLab 是一个免费、开源、本地化的,专注于分析聊天记录的应用。通过 AI Agent 和灵活的 SQL 引擎,你可以自由地拆解、查询甚至重构你的社交数据。 +English | [简体中文](./README.zh-CN.md) -我们拒绝将你的隐私上传云端,而是把强大的分析能力直接塞进你的电脑。 +[Official Website](https://chatlab.fun/) · [Docs](https://docs.chatlab.fun/) · [Quick Start](https://docs.chatlab.fun/usage/quick-start) · [Roadmap](https://chatlab.fun/roadmap/tasks) · [Releases](https://github.com/ChatLab/ChatLab/releases) -目前已支持:微信、QQ、WhatsApp、Discord 的聊天记录分析,即将支持:iMessage、LINE。 +
-项目目前还处于早期迭代阶段,因此还有很多缺陷和未完成功能。若您遇到了任何问题,欢迎随时反馈。 +ChatLab is an open-source desktop app for understanding your social conversations. It combines a flexible SQL engine with AI agents so you can explore patterns, ask better questions, and extract insights from chat data, all on your own machine. -## 核心特性 +Currently supported: **WhatsApp, LINE, QQ, Discord, Instagram, Telegram, iMessage, and Google Chat**. Coming next: **Messenger and KakaoTalk**. -- 🚀 **极致性能**:使用流式计算与多线程并行架构,就算是百万条级别的聊天记录,依然拥有丝滑交互和响应。 -- 🔒 **保护隐私**:聊天记录和配置都存在你的本地数据库,所有分析都在本地进行(AI 功能例外)。 -- 🤖 **智能 AI Agent**:集成 10+ Function Calling 工具,支持动态调度,深度挖掘聊天记录中的更多有趣。 -- 📊 **多维数据可视化**:提供活跃度趋势、时间规律分布、成员排行等多个维度的直观分析图表。 -- 🧩 **格式标准化**:通过强大的数据抽象层,抹平不同聊天软件的格式差异,任何聊天记录都能分析。 +> New install? Start here: [Getting started](https://docs.chatlab.fun/usage/quick-start) -## 使用指南 +## Core Features -- [导出聊天记录指南](https://chatlab.fun/cn/usage/how-to-export.html) -- [标准化格式规范](https://chatlab.fun/cn/usage/chatlab-format.html) -- [故障排查指南](https://chatlab.fun/cn/usage/troubleshooting.html) +- 🚀 **Built for large histories**: Stream parsing and multi-worker processing keep imports and analysis responsive, even at million-message scale. +- 🔒 **Private by default**: Your chat data and settings stay local. No mandatory cloud upload of raw conversations. +- 🤖 **AI that can actually operate on data**: Agent + Function Calling workflows (24+ tools) can search, summarize, and analyze chat records with context. +- 📊 **Insight-rich visual views**: See trends, time patterns, interaction frequency, rankings, and more in one place. +- 🧩 **Cross-platform normalization**: Different export formats are mapped into a unified model so you can analyze them consistently. -## 预览界面 +## Installation -预览更多请前往官网 [chatlab.fun](https://chatlab.fun/cn/) +### Desktop App -![预览界面](/public/images/intro_zh.png) +Download the installer for your OS from the [official website](https://chatlab.fun/?type=download) or [GitHub Releases](https://github.com/ChatLab/ChatLab/releases), then double-click to install. -## 系统架构 +### CLI -### Electron 主进程 +Requires Node.js ≥ 20. -- `electron/main/index.ts` 负责应用生命周期、窗口管理、自定义协议注册 -- `electron/main/ipc/` 按功能拆分 IPC 模块(窗口、聊天、合并、AI、缓存),确保数据交换安全可控 -- `electron/main/ai/` 集成多家 LLM,内置 Agent 管道、提示词拼装、Function Calling 工具注册 +```bash +npm i chatlab-cli -g +``` + +Start ChatLab: + +```bash +chatlab start # Start API + Web UI, auto-open in browser +chatlab start --no-open # Start API + Web UI, skip auto-open +chatlab start --headless # API only, no Web UI (for scripts / AI Agents) +``` + +Common options: `--port ` (default 3110), `--host
`, `--token `. + +To run as a persistent background service (auto-start on login + auto-restart on crash): + +```bash +chatlab start --daemon # Install as system service (macOS / Linux) +chatlab status # Check service status +chatlab stop # Stop and uninstall service +``` + +For a full walkthrough, see the [Quick Start guide](https://docs.chatlab.fun/usage/quick-start). + +## Usage Guides + +- [Download Guide](https://chatlab.fun/?type=download) +- [Chat Record Export Guide](https://docs.chatlab.fun/usage/how-to-export) +- [Standardized Format Specification](https://docs.chatlab.fun/standard/chatlab-format) +- [Troubleshooting Guide](https://docs.chatlab.fun/usage/troubleshooting) -### Worker 与数据管线 +## Preview -- `electron/main/worker/` 中的 `workerManager` 统筹 Worker 线程,`dbWorker` 负责路由消息 -- `worker/query/*` 承担活跃度、AI 搜索、高级分析、SQL 实验室等查询;`worker/import/streamImport.ts` 提供流式导入 -- `parser/` 目录采用嗅探 + 解析三层架构,能在恒定内存下处理 GB 级日志文件 +For more previews, please visit the official website: [chatlab.fun](https://chatlab.fun/) -### 渲染进程 +![Preview Interface](/public/images/intro_en.png) -- Vue 3 + Nuxt UI + Tailwind CSS 负责可视化页面。`src/pages` 存放各业务页面,`src/components/analysis`、`src/components/charts` 等目录提供复用组件 -- `src/stores` 通过 Pinia 管理会话、布局、AI 提示词等状态;`src/composables/useAIChat.ts` 封装 AI 对话流程 -- 预加载脚本 `electron/preload/index.ts` 暴露 `window.chatApi/mergeApi/aiApi/llmApi`,确保渲染进程与主进程通信安全隔离 +## Architecture Overview -## 本地运行 +ChatLab is a pnpm monorepo built on Electron + Vue 3 + Nuxt UI + Tailwind CSS. Core business logic lives in shared packages (`@openchatlab/core`, `@openchatlab/node-runtime`, `@openchatlab/tools`), consumed by both the desktop app and the CLI service — so they stay in sync. -### 启动步骤 +Data flows in five stages: **format detection → stream parsing → local persistence → SQL + AI query → visualization**. -Node.js 环境依赖 v20+ +For a deep dive, see the [architecture documentation](https://docs.chatlab.fun/intro). + +### Architecture Principles + +- **Local-first by default**: Raw chat data, indexes, and settings remain on-device unless you explicitly choose otherwise. +- **Streaming over buffering**: Stream-first parsing and incremental processing keep large imports stable and memory-efficient. +- **Composable intelligence**: AI features are assembled through Agent + Tool Calling, not hard-coded into one model path. +- **Schema-first evolution**: Import, query, analysis, and visualization share a consistent data model that scales with new features. + +--- + +## Local Development + +For complete contributor instructions, see the [Development Guide](https://docs.chatlab.fun/contributing/development). + +### Requirements + +- Node.js >= 24 < 25 +- pnpm >= 9 < 10 + +### Setup ```bash -# 安装依赖 +# Install dependencies pnpm install -# 启动开发服务器 -pnpm run dev +# Start dev mode — prompts you to choose which app to launch +pnpm dev ``` -若 Electron 在启动时异常,可尝试使用 `electron-fix`: +Or launch a specific target directly: + +```bash +pnpm dev:desktop # Electron desktop app +pnpm dev:web # Web frontend + local server +pnpm dev:serve # CLI server only +pnpm docs:dev # Docs site +``` + +If Electron encounters exceptions during startup, you can try using `electron-fix`: ```bash npm install electron-fix -g electron-fix start ``` -## 贡献指南 +## Privacy Policy & User Agreement + +Before using this software, please read the [Privacy Policy & User Agreement](./src/assets/docs/agreement_en.md). + +## Community -提交 Pull Request 前请遵循以下原则: +Please follow these principles before submitting a Pull Request: -- 明显的 Bug 修复可直接提交 -- 对于新功能,请先提交 Issue 进行讨论,**未经讨论直接提交的 PR 会被关闭** -- 一个 PR 尽量只做一件事,若改动较大,请考虑拆分为多个独立的 PR +- Obvious bug fixes can be submitted directly. +- For new features, please submit an Issue for discussion first; **PRs submitted without prior discussion will be closed**. +- Keep one PR focused on one task; if changes are extensive, consider splitting them into multiple independent PRs. +- For local setup, repository structure, checks, and AI collaboration notes, see the [Development Guide](https://docs.chatlab.fun/contributing/development). -## 隐私政策与用户协议 +Thanks to all contributors: -使用本软件前,请阅读 [隐私政策与用户协议](./src/assets/docs/agreement_zh.md) + + + ## License diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 000000000..711b081b5 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,149 @@ +
+ + + ChatLab + + +聊天记忆驱动的 AI Agent + +[English](README.md) | 简体中文 + +[官网](https://chatlab.fun/cn/) · [文档](https://docs.chatlab.fun/cn/) · [快速开始](https://docs.chatlab.fun/cn/usage/quick-start) · [路线图](https://chatlab.fun/cn/roadmap/tasks) · [Releases](https://github.com/ChatLab/ChatLab/releases) + +
+ +ChatLab 是一个专注于聊天记录分析的本地化应用。通过 AI Agent和灵活的 SQL 引擎,你可以自由地分析你的聊天记录数据。 + +目前已支持:**WhatsApp、LINE、QQ、Discord、Instagram、Telegram、iMessage、Google Chat**。即将支持:Messenger、KakaoTalk。 + +> 首次安装?从这里开始:[快速开始](https://docs.chatlab.fun/cn/usage/quick-start) + +## 核心特性 + +- 🚀 **极致性能**:使用流式计算与多线程并行架构,就算是百万条级别的聊天记录,依然拥有丝滑交互和响应。 +- 🔒 **保护隐私**:聊天记录和配置都存在你的本地数据库,所有分析都在本地进行(AI 功能例外)。 +- 🤖 **智能 AI Agent**:集成 24+ Function Calling 工具,支持动态调度,深度挖掘聊天记录中的更多有趣。 +- 📊 **多维数据可视化**:提供活跃度趋势、时间规律分布、成员排行等多个维度的直观分析图表。 +- 🧩 **格式标准化**:通过强大的数据抽象层,抹平不同聊天软件的格式差异,即使是再小众的聊天软件,也能分析。 + +## 安装 + +### 桌面端 + +前往[官网](https://chatlab.fun/cn/?type=download)或 [GitHub Releases](https://github.com/ChatLab/ChatLab/releases) 下载对应操作系统的安装包,双击安装即可。 + +### CLI + +需要 Node.js ≥ 20。 + +```bash +npm i chatlab-cli -g +``` + +启动 ChatLab: + +```bash +chatlab start # 启动 API + Web UI,并在浏览器中打开 +chatlab start --no-open # 启动 API + Web UI,但不自动打开浏览器 +chatlab start --headless # 仅启动 API,不挂载 Web UI(供脚本 / AI Agent 调用) +``` + +常用选项:`--port <端口>`(默认 3110)、`--host <地址>`、`--token <令牌>`。 + +如果希望服务常驻后台(开机自启 + 崩溃自动重启): + +```bash +chatlab start --daemon # 注册为系统服务(macOS / Linux) +chatlab status # 查看常驻状态 +chatlab stop # 停止并取消常驻 +``` + +完整使用说明请见[快速开始指南](https://docs.chatlab.fun/cn/usage/quick-start)。 + +## 使用指南 + +- [下载 ChatLab 指南](https://chatlab.fun/cn/?type=download) +- [导出聊天记录指南](https://docs.chatlab.fun/cn/usage/how-to-export) +- [标准化格式规范](https://docs.chatlab.fun/cn/standard/chatlab-format) +- [故障排查指南](https://docs.chatlab.fun/cn/usage/troubleshooting) + +## 预览界面 + +预览更多请前往官网 [chatlab.fun](https://chatlab.fun/cn/) + +![预览界面](public/images/intro_zh.png) + +## 架构概览 + +ChatLab 是一个基于 pnpm monorepo 的工程,桌面端使用 Electron + Vue 3 + Nuxt UI + Tailwind CSS,核心业务逻辑沉淀在共享包(`@openchatlab/core`、`@openchatlab/node-runtime`、`@openchatlab/tools`),桌面端与 CLI 服务端复用同一份逻辑,保持功能同步。 + +数据流分五个阶段:**格式嗅探 → 流式解析 → 本地落盘 → SQL + AI 查询 → 可视化呈现**。 + +详细架构请参考[项目文档](https://docs.chatlab.fun/cn/intro)。 + +### 架构原则 + +- **Local-first by default**:原始聊天记录、索引与配置默认留在本地,优先保护隐私边界。 +- **Streaming over buffering**:以流式解析和增量处理为核心,面向大体量导出文件保持稳定吞吐。 +- **Composable intelligence**:AI 能力通过 Agent + Tool Calling 组合,避免将业务逻辑硬编码到单一模型。 +- **Schema-first evolution**:围绕统一数据结构构建导入、查询、分析与可视化,降低演进成本。 + +--- + +## 本地开发 + +完整协作者说明见[开发指南](https://docs.chatlab.fun/cn/contributing/development)。 + +### 环境要求 + +- Node.js >= 24 < 25 +- pnpm >= 9 < 10 + +### 启动步骤 + +```bash +# 安装依赖 +pnpm install + +# 启动开发模式 — 会提示选择要启动的目标 +pnpm dev +``` + +也可以直接启动指定目标: + +```bash +pnpm dev:desktop # Electron 桌面端 +pnpm dev:web # 前端 + 本地服务 +pnpm dev:serve # 仅 CLI 服务端 +pnpm docs:dev # 文档站 +``` + +若 Electron 在启动时异常,可尝试使用 `electron-fix`: + +```bash +npm install electron-fix -g +electron-fix start +``` + +## 隐私政策与用户协议 + +使用本软件前,请阅读 [隐私政策与用户协议](src/assets/docs/agreement_zh.md) + +## 社区 + +提交 Pull Request 前请遵循以下原则: + +- 明显的 Bug 修复可直接提交 +- 对于新功能,请先提交 Issue 进行讨论,**未经讨论直接提交的 PR 会被关闭** +- 一个 PR 尽量只做一件事,若改动较大,请考虑拆分为多个独立的 PR +- 本地运行、目录职责、测试检查和 AI 协作说明见[开发指南](https://docs.chatlab.fun/cn/contributing/development) + +感谢所有为 ChatLab 做出贡献的人! + + + + + +## License + +AGPL-3.0 License diff --git a/README_en.md b/README_en.md deleted file mode 100644 index f09ba5a71..000000000 --- a/README_en.md +++ /dev/null @@ -1,95 +0,0 @@ -# ChatLab - -English | [简体中文](./README.md) - -ChatLab is a free, open-source, and local-first application dedicated to analyzing chat records. Through an AI Agent and a flexible SQL engine, you can freely dissect, query, and even reconstruct your social data. - -We refuse to upload your privacy to the cloud; instead, we bring powerful analytics directly to your computer. - -Currently supported: Chat record analysis for **WeChat, QQ, WhatsApp and Discord**. Upcoming support: **iMessage, and LINE**. - -The project is still in early iteration, so there are many bugs and unfinished features. If you encounter any issues, feel free to provide feedback. - -## Core Features - -- 🚀 **Ultimate Performance**: Utilizing stream computing and multi-threaded parallel architecture, it maintains fluid interaction and response even with millions of chat records. -- 🔒 **Privacy Protection**: Chat records and configurations are stored in your local database, and all analysis is performed locally (with the exception of AI features). -- 🤖 **Intelligent AI Agent**: Integrated with 10+ Function Calling tools and supporting dynamic scheduling to deeply excavate interesting insights from chat records. -- 📊 **Multi-dimensional Data Visualization**: Provides intuitive analysis charts for activity trends, time distribution patterns, member rankings, and more. -- 🧩 **Format Standardization**: Through a powerful data abstraction layer, it bridges the format differences between various chat applications, allowing any chat records to be analyzed. - -## Usage Guides - -- [Chat Record Export Guide](https://chatlab.fun/usage/how-to-export.html) -- [Standardized Format Specification](https://chatlab.fun/usage/chatlab-format.html) -- [Troubleshooting Guide](https://chatlab.fun/usage/troubleshooting.html) - -## Preview Interface - -For more previews, please visit the official website: [chatlab.fun](https://chatlab.fun/) - -![Preview Interface](/public/images/intro_en.png) - -## System Architecture - -### Electron Main Process - -- `electron/main/index.ts` handles the application lifecycle, window management, and custom protocol registration. -- `electron/main/ipc/` splits IPC modules by function (Window, Chat, Merge, AI, Cache) to ensure secure and controllable data exchange. -- `electron/main/ai/` integrates multiple LLMs, featuring built-in Agent pipelines, prompt assembly, and Function Calling tool registration. - -### Worker and Data Pipeline - -- The `workerManager` in `electron/main/worker/` coordinates Worker threads, while `dbWorker` handles message routing. -- `worker/query/*` handles activity, AI search, advanced analysis, and SQL Lab queries. -- `worker/import/streamImport.ts` provides stream importing. -- The `parser/` directory adopts a three-layer "sniff + parse" architecture capable of processing GB-level log files with constant memory usage. - -### Rendering Process - -- Vue 3 + Nuxt UI + Tailwind CSS manages the visualization pages. -- `src/pages` contains business pages, while `src/components/analysis` and `src/components/charts` provide reusable components. -- `src/stores` manages states like sessions, layout, and AI prompts via Pinia. -- `src/composables/useAIChat.ts` encapsulates the AI conversation workflow. -- The preload script `electron/preload/index.ts` exposes `window.chatApi/mergeApi/aiApi/llmApi`, ensuring secure isolation between the renderer and main processes. - ---- - -## Local Development - -### Setup Steps - -Node.js environment requirement: v20+ - -```bash -# Install dependencies -pnpm install - -# Start development server -pnpm run dev - -``` - -If Electron encounters exceptions during startup, you can try using `electron-fix`: - -```bash -npm install electron-fix -g -electron-fix start - -``` - -## Contribution Guide - -Please follow these principles before submitting a Pull Request: - -- Obvious bug fixes can be submitted directly. -- For new features, please submit an Issue for discussion first; **PRs submitted without prior discussion will be closed**. -- Keep one PR focused on one task; if changes are extensive, consider splitting them into multiple independent PRs. - -## Privacy Policy & User Agreement - -Before using this software, please read the [Privacy Policy & User Agreement](./src/assets/docs/agreement_en.md). - -## License - -AGPL-3.0 License diff --git a/apps/cli/bin/chatlab.mjs b/apps/cli/bin/chatlab.mjs new file mode 100755 index 000000000..4f9d5f56e --- /dev/null +++ b/apps/cli/bin/chatlab.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import('../dist/cli.mjs').then((m) => m.run()) diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 000000000..3c01d8c2a --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,80 @@ +{ + "name": "chatlab-cli", + "version": "0.29.0", + "description": "ChatLab CLI & Service - Chat history analysis tool", + "type": "module", + "bin": { + "chatlab": "bin/chatlab.mjs", + "clb": "bin/chatlab.mjs" + }, + "main": "./dist/index.mjs", + "files": [ + "bin/", + "dist/", + "dist-web/", + "LICENSE" + ], + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public" + }, + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/ChatLab/ChatLab.git", + "directory": "apps/cli" + }, + "homepage": "https://github.com/ChatLab/ChatLab", + "keywords": [ + "chatlab", + "chat", + "analysis", + "cli" + ], + "scripts": { + "cli": "node scripts/ensure-native.mjs && tsx src/cli.ts", + "build": "tsup", + "build:full": "pnpm -w run build:web && pnpm run build && pnpm run prepare:web", + "prepare:web": "rm -rf dist-web && cp -r ../../dist-web dist-web", + "ensure-native": "node scripts/ensure-native.mjs", + "rebuild-native": "bash scripts/rebuild-native.sh", + "rebuild-sqlite": "npm rebuild better-sqlite3", + "prepack": "node scripts/sync-release-docs.mjs", + "postpack": "node scripts/clean-release-docs.mjs", + "prepublishOnly": "pnpm run build" + }, + "dependencies": { + "@fastify/multipart": "^10.0.0", + "@fastify/static": "^9.1.3", + "@earendil-works/pi-agent-core": "0.74.2", + "@earendil-works/pi-ai": "0.74.2", + "@modelcontextprotocol/sdk": "^1.12.1", + "better-sqlite3": "^12.4.6", + "commander": "^13.1.0", + "fastify": "^5.8.4", + "gray-matter": "^4.0.3", + "js-tiktoken": "^1.0.21", + "smol-toml": "^1.3.1", + "stream-json": "^1.9.1", + "zod": "^3.24.4" + }, + "optionalDependencies": { + "@node-rs/jieba": "^2.0.1" + }, + "devDependencies": { + "@openchatlab/config": "workspace:*", + "@openchatlab/core": "workspace:*", + "@openchatlab/http-routes": "workspace:*", + "@openchatlab/node-runtime": "workspace:*", + "@openchatlab/parser": "workspace:*", + "@openchatlab/shared-types": "workspace:*", + "@openchatlab/sync": "workspace:*", + "@openchatlab/tools": "workspace:*", + "@types/better-sqlite3": "^7.6.13", + "chatlab-mcp": "workspace:*", + "tsup": "^8.5.0", + "tsx": "^4.21.0" + } +} diff --git a/apps/cli/scripts/clean-release-docs.mjs b/apps/cli/scripts/clean-release-docs.mjs new file mode 100644 index 000000000..9dcc6a676 --- /dev/null +++ b/apps/cli/scripts/clean-release-docs.mjs @@ -0,0 +1,13 @@ +import { rm } from 'node:fs/promises' +import { resolve } from 'node:path' + +const packageRoot = resolve(import.meta.dirname, '..') +const generatedFiles = ['README.md', 'LICENSE'] + +await Promise.all( + generatedFiles.map((file) => + rm(resolve(packageRoot, file), { force: true }).catch((error) => { + console.warn(`[release-docs] Failed to remove ${file}: ${error.message}`) + }) + ) +) diff --git a/apps/cli/scripts/ensure-native.mjs b/apps/cli/scripts/ensure-native.mjs new file mode 100644 index 000000000..54876c658 --- /dev/null +++ b/apps/cli/scripts/ensure-native.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +import { existsSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' + +const currentFile = fileURLToPath(import.meta.url) +const scriptDir = dirname(currentFile) +const serverDir = dirname(scriptDir) +const nativePath = resolve(serverDir, 'native/better_sqlite3.node') +const rebuildScript = resolve(scriptDir, 'rebuild-native.sh') + +export function getNativeStatus(bindingPath, nodeExecutable = process.execPath) { + if (!existsSync(bindingPath)) { + return { ok: false, reason: 'missing', message: `Native binding not found: ${bindingPath}` } + } + + const result = spawnSync( + nodeExecutable, + ['-e', 'require(process.argv[1]); process.stdout.write(process.versions.modules)', bindingPath], + { encoding: 'utf8' } + ) + + if (result.status === 0) { + return { ok: true, reason: 'valid', abi: result.stdout.trim() } + } + + return { + ok: false, + reason: 'invalid', + message: (result.stderr || result.stdout || result.error?.message || 'Native binding failed to load').trim(), + } +} + +function runRebuild() { + const result = spawnSync('bash', [rebuildScript], { + cwd: serverDir, + stdio: 'inherit', + }) + + if (result.status !== 0) { + process.exit(result.status || 1) + } +} + +function main() { + const checkOnly = process.argv.includes('--check') + const status = getNativeStatus(nativePath) + + if (status.ok) { + console.error(`[server native] better-sqlite3 ready (Node ABI ${status.abi})`) + return + } + + if (checkOnly) { + console.error(`[server native] ${status.message}`) + process.exit(1) + } + + console.error(`[server native] ${status.message}`) + console.error('[server native] Rebuilding better-sqlite3 for the current system Node.js...') + runRebuild() + + const rebuilt = getNativeStatus(nativePath) + if (!rebuilt.ok) { + console.error(`[server native] Rebuild completed, but native binding is still unusable: ${rebuilt.message}`) + process.exit(1) + } + + console.error(`[server native] better-sqlite3 ready (Node ABI ${rebuilt.abi})`) +} + +if (process.argv[1] && currentFile === resolve(process.argv[1])) { + main() +} diff --git a/apps/cli/scripts/ensure-native.test.mjs b/apps/cli/scripts/ensure-native.test.mjs new file mode 100644 index 000000000..aeab27019 --- /dev/null +++ b/apps/cli/scripts/ensure-native.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { getNativeStatus } from './ensure-native.mjs' + +test('reports missing native binding', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'chatlab-native-missing-')) + try { + const status = getNativeStatus(path.join(dir, 'better_sqlite3.node')) + assert.equal(status.ok, false) + assert.equal(status.reason, 'missing') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('reports invalid native binding load failure', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'chatlab-native-invalid-')) + try { + const nativePath = path.join(dir, 'better_sqlite3.node') + writeFileSync(nativePath, 'not a native module') + + const status = getNativeStatus(nativePath) + assert.equal(status.ok, false) + assert.equal(status.reason, 'invalid') + assert.match(status.message, /file too short|not a mach-o file|invalid/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('reports valid native binding when it can be loaded by current Node', () => { + const nativePath = path.resolve('apps/cli/native/better_sqlite3.node') + const status = getNativeStatus(nativePath) + assert.equal(status.ok, true) + assert.equal(status.reason, 'valid') +}) + +test('prints status to stderr so CLI stdout stays machine-readable', () => { + const result = spawnSync(process.execPath, ['apps/cli/scripts/ensure-native.mjs', '--check'], { + encoding: 'utf8', + }) + + assert.equal(result.status, 0) + assert.equal(result.stdout, '') + assert.match(result.stderr, /better-sqlite3 ready/) +}) diff --git a/apps/cli/scripts/rebuild-native.sh b/apps/cli/scripts/rebuild-native.sh new file mode 100755 index 000000000..871bb9504 --- /dev/null +++ b/apps/cli/scripts/rebuild-native.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# +# 在隔离目录中编译 better-sqlite3 的系统 Node.js 原生模块, +# 避免与 electron-rebuild 产生冲突。 +# +# 产物:apps/cli/native/better_sqlite3.node +# +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SERVER_DIR="$(dirname "$SCRIPT_DIR")" +TARGET_DIR="$SERVER_DIR/native" + +# 获取当前 workspace 中 better-sqlite3 的版本 +BS3_VERSION=$(node -e "const p = require('better-sqlite3/package.json'); console.log(p.version)") +echo "Building better-sqlite3@${BS3_VERSION} for system Node.js ($(node -v))..." + +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +cd "$TEMP_DIR" +npm init -y > /dev/null 2>&1 +npm install "better-sqlite3@${BS3_VERSION}" --ignore-scripts > /dev/null 2>&1 + +cd "node_modules/better-sqlite3" +npx --yes prebuild-install -r napi || npm run build-release 2>/dev/null || npx --yes node-gyp rebuild --release + +BUILT="$TEMP_DIR/node_modules/better-sqlite3/build/Release/better_sqlite3.node" +if [ ! -f "$BUILT" ]; then + echo "Error: native binary not found at $BUILT" + exit 1 +fi + +mkdir -p "$TARGET_DIR" +cp "$BUILT" "$TARGET_DIR/better_sqlite3.node" + +echo "Done. Native binary saved to $TARGET_DIR/better_sqlite3.node" +echo " ABI: $(node -e 'console.log(process.versions.modules)')" diff --git a/apps/cli/scripts/sync-release-docs.mjs b/apps/cli/scripts/sync-release-docs.mjs new file mode 100644 index 000000000..4a18d66b1 --- /dev/null +++ b/apps/cli/scripts/sync-release-docs.mjs @@ -0,0 +1,14 @@ +import { copyFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const packageRoot = resolve(import.meta.dirname, '..') +const repositoryRoot = resolve(packageRoot, '../..') + +const releaseDocs = [ + ['README.md', 'README.md'], + ['LICENSE', 'LICENSE'], +] + +for (const [source, target] of releaseDocs) { + await copyFile(resolve(repositoryRoot, source), resolve(packageRoot, target)) +} diff --git a/apps/cli/src/ai/agent-stream-runner.test.ts b/apps/cli/src/ai/agent-stream-runner.test.ts new file mode 100644 index 000000000..f9d974c76 --- /dev/null +++ b/apps/cli/src/ai/agent-stream-runner.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { getChartCapabilityAllowedBuiltinTools } from '@openchatlab/node-runtime' +import { getAllowedToolSet, getAvailableToolDefs } from './agent-stream-runner' + +describe('CLI chart capability tool filtering', () => { + it('does not expose uncategorized raw SQL in chart-only turns', () => { + const allowedToolSet = new Set(getChartCapabilityAllowedBuiltinTools()) + + const toolNames = getAvailableToolDefs(true, allowedToolSet).map((tool) => tool.name) + + assert.deepEqual(toolNames.sort(), ['get_schema', 'render_chart']) + assert.ok(!toolNames.includes('execute_sql')) + }) + + it('keeps only chart core tools plus explicitly allowed analysis tools', () => { + const allowedToolSet = new Set(getChartCapabilityAllowedBuiltinTools(['keyword_frequency', 'execute_sql'])) + + const toolNames = getAvailableToolDefs(true, allowedToolSet).map((tool) => tool.name) + + assert.deepEqual(toolNames.sort(), ['get_schema', 'keyword_frequency', 'render_chart']) + assert.ok(!toolNames.includes('execute_sql')) + }) + + it('can expose render_chart for auto skill turns with restrictive assistant tools', () => { + const allowedToolSet = new Set(getChartCapabilityAllowedBuiltinTools(['keyword_frequency'])) + + const toolNames = getAvailableToolDefs(false, allowedToolSet).map((tool) => tool.name) + + assert.ok(toolNames.includes('keyword_frequency')) + assert.ok(toolNames.includes('render_chart')) + }) + + it('does not expose raw SQL when the assistant did not allow it', () => { + const allowedToolSet = new Set(['keyword_frequency']) + + const toolNames = getAvailableToolDefs(false, allowedToolSet).map((tool) => tool.name) + + assert.ok(toolNames.includes('keyword_frequency')) + assert.ok(!toolNames.includes('execute_sql')) + }) + + it('accepts legacy session tool names in assistant allowlists', () => { + const allowedToolSet = getAllowedToolSet(false, ['get_session_summaries']) + + assert.ok(allowedToolSet instanceof Set) + + const toolNames = getAvailableToolDefs(false, allowedToolSet).map((tool) => tool.name) + + assert.ok(toolNames.includes('get_segment_summaries')) + assert.ok(!toolNames.includes('get_session_summaries')) + }) + + it('preserves an empty assistant allowlist instead of treating it as unrestricted', () => { + const allowedToolSet = getAllowedToolSet(false, []) + + assert.ok(allowedToolSet instanceof Set) + assert.equal(allowedToolSet.size, 0) + + const toolNames = getAvailableToolDefs(false, allowedToolSet).map((tool) => tool.name) + + assert.ok(toolNames.includes('get_schema')) + assert.ok(!toolNames.includes('keyword_frequency')) + assert.ok(!toolNames.includes('execute_sql')) + }) +}) diff --git a/apps/cli/src/ai/agent-stream-runner.ts b/apps/cli/src/ai/agent-stream-runner.ts new file mode 100644 index 000000000..45417909c --- /dev/null +++ b/apps/cli/src/ai/agent-stream-runner.ts @@ -0,0 +1,227 @@ +/** + * CLI Agent stream runner — provides runAgentStream implementation + * for the shared HTTP route context. + */ + +import type { DatabaseManager, AIChatManager, AgentStreamChunk, SemanticIndexRuntime } from '@openchatlab/node-runtime' +import { + CHART_CAPABILITY_CORE_TOOLS, + SkillManager, + buildSkillMenuWithBuiltinChart, + createActivateSkillTool, + createDataSnapshotFromOverview, + getAllowedBuiltinToolsForChartAutoSkill, + getChartCapabilitySkill, + getSkillConfigWithBuiltinChart, + resolveChartRuntimeForRequest, +} from '@openchatlab/node-runtime' +import { getChatOverview, normalizeBuiltinToolNames } from '@openchatlab/core' +import type { ChartAutoMode } from '@openchatlab/shared-types' +import type { DataSnapshot } from '@openchatlab/node-runtime' +import type { AgentStreamRequest } from '@openchatlab/http-routes' +import { AGENT_TOOL_REGISTRY, SEMANTIC_SEARCH_TOOL_NAME } from '@openchatlab/tools' +import { buildSemanticSearchGuidance } from '@openchatlab/node-runtime' +import { adaptToolsForAgent } from './tool-adapter' +import { getDefaultAssistantConfig, buildPiModel } from './llm-config' +import { loadAssistantConfig } from './assistant-loader' +import { runServerAgent } from './agent' + +function getAiDir(dbManager: DatabaseManager): string { + const pathProvider = (dbManager as any)['pathProvider'] + if (!pathProvider) { + throw Object.assign(new Error('PathProvider not available'), { statusCode: 500 }) + } + return pathProvider.getAiDataDir() +} + +const RAW_SQL_TOOL_NAMES = new Set(['execute_sql']) + +export function getAvailableToolDefs(isChartCapability: boolean, allowedToolSet: Set | null) { + if (isChartCapability) { + const chartCoreTools = new Set(CHART_CAPABILITY_CORE_TOOLS) + return AGENT_TOOL_REGISTRY.filter( + (tool) => + chartCoreTools.has(tool.name) || + (tool.category === 'analysis' && allowedToolSet?.has(tool.name) && !RAW_SQL_TOOL_NAMES.has(tool.name)) + ) + } + + return allowedToolSet + ? AGENT_TOOL_REGISTRY.filter((tool) => tool.category !== 'analysis' || allowedToolSet.has(tool.name)) + : AGENT_TOOL_REGISTRY +} + +// 区分“未配置白名单”和“配置为空白名单”:前者无限制,后者禁用 analysis 工具。 +export function getAllowedToolSet( + isChartCapability: boolean, + allowedBuiltinTools?: readonly string[] +): Set | null { + if (isChartCapability) { + return new Set(normalizeBuiltinToolNames(allowedBuiltinTools ?? [])) + } + return allowedBuiltinTools === undefined ? null : new Set(normalizeBuiltinToolNames(allowedBuiltinTools)) +} + +export function createCliRunAgentStream( + dbManager: DatabaseManager, + aiChatManager: AIChatManager, + semanticIndexService?: SemanticIndexRuntime +): (params: AgentStreamRequest, onEvent: (chunk: AgentStreamChunk) => void, abortSignal: AbortSignal) => Promise { + return async (params, onEvent, abortSignal) => { + const { + userMessage, + aiChatId, + historyLeafMessageId, + sessionId, + chatType, + locale, + assistantId, + skillId, + enableAutoSkill, + chartAutoMode, + compressionConfig, + ownerInfo, + mentionedMembers, + thinkingLevel, + } = params + + const aiDataDir = getAiDir(dbManager) + + let assistantSystemPrompt: string | undefined + let assistantAllowedTools: string[] | undefined + if (assistantId) { + const assistantConfig = loadAssistantConfig(aiDataDir, assistantId) + if (assistantConfig?.systemPrompt) { + assistantSystemPrompt = assistantConfig.systemPrompt + } + assistantAllowedTools = assistantConfig?.allowedBuiltinTools + } + + const llmConfig = getDefaultAssistantConfig(aiDataDir) + const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50 + const contextWindow = llmConfig ? (buildPiModel(llmConfig).contextWindow ?? 128000) : 128000 + const maxToolResultTokens = Math.floor(contextWindow * (maxToolResultPercent / 100)) + + const db = (dbManager as any).open?.(sessionId) + const resolvedChartAutoMode: ChartAutoMode = chartAutoMode ?? 'suggest' + const chartRuntime = resolveChartRuntimeForRequest({ + skillId, + userMessage, + locale, + assistantAllowedTools, + enableAutoDetection: enableAutoSkill === true, + chartAutoMode: resolvedChartAutoMode, + }) + const isChartCapability = chartRuntime.isChartCapability + const autoSkillAllowedTools = + !skillId && enableAutoSkill && resolvedChartAutoMode !== 'explicit' + ? getAllowedBuiltinToolsForChartAutoSkill(assistantAllowedTools) + : assistantAllowedTools + const allowedToolSet = getAllowedToolSet( + isChartCapability, + isChartCapability ? chartRuntime.allowedBuiltinTools : autoSkillAllowedTools + ) + const availableToolDefs = getAvailableToolDefs(isChartCapability, allowedToolSet) + + // 语义检索按需暴露:仅当前会话可检索时保留工具,否则从工具集中过滤掉以减少 schema token 与无效调用。 + const canSemanticSearch = !!db && !!semanticIndexService && (await semanticIndexService.canSearch(sessionId)) + const filteredToolDefs = canSemanticSearch + ? availableToolDefs + : availableToolDefs.filter((tool) => tool.name !== SEMANTIC_SEARCH_TOOL_NAME) + + const agentTools = db + ? adaptToolsForAgent( + filteredToolDefs, + () => ({ + db, + sessionId, + locale, + semanticIndexService, + preprocessConfig: params.preprocessConfig, + ownerPlatformId: ownerInfo?.platformId, + timeFilter: params.timeFilter, + maxMessagesLimit: params.maxMessagesLimit, + }), + { maxToolResultTokens } + ) + : [] + + // 工具可用时向 system prompt 加简短引导,提示何时调用语义检索。 + if (canSemanticSearch) { + assistantSystemPrompt = [assistantSystemPrompt, buildSemanticSearchGuidance(locale)].filter(Boolean).join('\n\n') + } + + const skillMgr = new SkillManager(aiDataDir) + skillMgr.init() + const toolNames = agentTools.map((t: { name: string }) => t.name) + + let resolvedSkillDef: { name: string; prompt: string } | undefined + let resolvedSkillMenu: string | undefined + if (isChartCapability) { + const def = chartRuntime.skillDef ?? getChartCapabilitySkill(locale ?? 'zh-CN') + resolvedSkillDef = { name: def.name, prompt: def.prompt } + } else if (skillId) { + const def = skillMgr.getSkillConfig(skillId) + if (def) resolvedSkillDef = { name: def.name, prompt: def.prompt } + } else if (enableAutoSkill) { + const baseMenu = skillMgr.getSkillMenu(chatType ?? 'group', toolNames) + const menu = + resolvedChartAutoMode === 'aggressive' ? buildSkillMenuWithBuiltinChart(baseMenu, locale, toolNames) : baseMenu + if (menu) resolvedSkillMenu = menu + } + + if (resolvedSkillMenu) { + const activateSkillTool = createActivateSkillTool({ + chatType: chatType ?? 'group', + allowedTools: toolNames, + coreToolNames: new Set(CHART_CAPABILITY_CORE_TOOLS), + locale, + getSkillConfig: (id) => + resolvedChartAutoMode === 'aggressive' + ? getSkillConfigWithBuiltinChart(id, locale, (skillConfigId) => skillMgr.getSkillConfig(skillConfigId)) + : skillMgr.getSkillConfig(id), + }) + agentTools.push(activateSkillTool as any) + } + + const resolvedCompression = compressionConfig?.enabled + ? { + enabled: true as const, + tokenThresholdPercent: compressionConfig.tokenThresholdPercent ?? 75, + bufferSizePercent: compressionConfig.bufferSizePercent ?? 20, + maxToolResultPercent: compressionConfig.maxToolResultPercent, + } + : undefined + + let dataSnapshot: DataSnapshot | undefined + if (db) { + try { + dataSnapshot = createDataSnapshotFromOverview(getChatOverview(db, 10)) + } catch { + // non-fatal + } + } + + await runServerAgent({ + userMessage, + aiChatId, + historyLeafMessageId, + chatType, + locale, + assistantSystemPrompt, + skillMenu: resolvedSkillMenu, + skillDef: resolvedSkillDef, + compressionConfig: resolvedCompression, + tools: agentTools, + aiDataDir, + aiChatManager, + onEvent, + abortSignal, + ownerInfo, + mentionedMembers, + dataSnapshot, + thinkingLevel, + chartAutoMode: resolvedChartAutoMode, + }) + } +} diff --git a/apps/cli/src/ai/agent.ts b/apps/cli/src/ai/agent.ts new file mode 100644 index 000000000..de421c06b --- /dev/null +++ b/apps/cli/src/ai/agent.ts @@ -0,0 +1,285 @@ +/** + * 服务端 Agent + * + * 使用 @openchatlab/node-runtime 的 runAgentCore 编排对话流程, + * 通过 AgentEventHandler 输出与 Electron 端一致的流式事件。 + */ + +import { + DEFAULT_MAX_TOOL_ROUNDS, + buildPlanGuidance, + createAnalysisPlanner, + createLlmRouteDecider, + createPlanContentBlock, + decideRequestRoute, + runAgentCore, + checkAndCompress, + buildSystemPrompt, + createAiTranslate, + createCompressionLlmAdapter, + AgentEventHandler, + formatAIError, + shouldUseChartCapabilityForMessage, + getChartPlannerCapabilityForMessage, + initTokenizer, + type AgentStreamChunk, + type PiMessage, + type SimpleHistoryMessage, + type AIChatManager, + type CompressionConfig, + type AgentTool, + type DataSnapshot, + type OwnerInfo, + type MentionedMember, +} from '@openchatlab/node-runtime' +import type { ChartAutoMode } from '@openchatlab/shared-types' + +import { getDefaultAssistantConfig, buildPiModel } from './llm-config' +import { getServerAiLogger } from './logger' + +export type { AgentStreamChunk } + +export interface RunAgentOptions { + userMessage: string + aiChatId: string + historyLeafMessageId?: string | null + chatType?: 'group' | 'private' + locale?: string + assistantSystemPrompt?: string + skillMenu?: string | null + skillDef?: { name: string; prompt: string } + compressionConfig?: CompressionConfig + tools?: AgentTool[] + aiDataDir: string + aiChatManager: AIChatManager + onEvent: (event: AgentStreamChunk) => void + abortSignal?: AbortSignal + ownerInfo?: OwnerInfo + mentionedMembers?: MentionedMember[] + dataSnapshot?: DataSnapshot + thinkingLevel?: string + chartAutoMode?: ChartAutoMode +} + +export async function runServerAgent(options: RunAgentOptions): Promise { + const { + userMessage, + aiChatId, + historyLeafMessageId, + chatType = 'group', + locale = 'zh-CN', + assistantSystemPrompt, + skillMenu, + skillDef, + compressionConfig, + tools = [], + aiDataDir, + aiChatManager, + onEvent, + abortSignal, + ownerInfo, + mentionedMembers, + dataSnapshot, + thinkingLevel, + chartAutoMode = 'suggest', + } = options + + const aiLogger = getServerAiLogger() + + // 确保 tokenizer rank 表已加载(compression + agent 路径均依赖) + await initTokenizer() + + const llmConfig = getDefaultAssistantConfig(aiDataDir) + if (!llmConfig) { + onEvent({ type: 'error', error: { name: 'ConfigError', message: 'LLM service not configured' } }) + onEvent({ type: 'done', isFinished: true }) + return + } + + const piModel = buildPiModel(llmConfig) + const t = createAiTranslate(locale) + + let skillCtx: { skillDef?: { name: string; prompt: string }; skillMenu?: string } | undefined + if (skillDef) { + skillCtx = { skillDef } + } else if (skillMenu) { + skillCtx = { skillMenu } + } + + const systemPrompt = buildSystemPrompt({ + t, + chatType, + assistantSystemPrompt, + ownerInfo, + locale, + skillCtx, + mentionedMembers, + dataSnapshot, + }) + + const handler = new AgentEventHandler({ + onChunk: onEvent, + context: {}, + systemPrompt, + }) + + if (compressionConfig?.enabled && historyLeafMessageId === undefined) { + const llmAdapter = createCompressionLlmAdapter({ + piModel, + apiKey: llmConfig.apiKey, + onCompressing: () => handler.emitStatus('compressing', []), + }) + const compressionResult = await checkAndCompress( + aiChatId, + compressionConfig, + systemPrompt, + llmAdapter, + aiChatManager, + aiLogger ?? undefined + ) + if (compressionResult.compressed) { + onEvent({ + type: 'compression_done', + compressionResult: { + summaryContent: compressionResult.summaryContent ?? '', + tokensBefore: compressionResult.tokensBefore ?? 0, + tokensAfter: compressionResult.tokensAfter ?? 0, + timestamp: Date.now(), + }, + }) + } + } else if (compressionConfig?.enabled && historyLeafMessageId !== undefined) { + aiLogger?.info?.('Compression', 'Skipping compression for edited branch request', { + aiChatId, + historyLeafMessageId, + }) + } + + if (abortSignal?.aborted) { + handler.emitStatus('aborted', [], { force: true }) + onEvent({ type: 'done', isFinished: true, usage: handler.cloneUsage() }) + return + } + + let history: SimpleHistoryMessage[] = [] + try { + history = aiChatManager.getHistoryForAgent(aiChatId, undefined, historyLeafMessageId) + } catch { + // empty history on failure + } + + handler.emitStatus('preparing', [], { pendingUserMessage: userMessage, force: true }) + + const steerMessage = t('ai.agent.answerWithoutTools') + let cachedMessages: PiMessage[] = [] + const effectiveTools = + chartAutoMode === 'explicit' && !shouldUseChartCapabilityForMessage(userMessage) + ? tools.filter((tool) => tool.name !== 'render_chart') + : tools + + try { + const routeInput = { + userMessage, + chatType, + locale, + dataSnapshot, + availableTools: effectiveTools.map((tool) => tool.name), + availableCapabilities: [ + getChartPlannerCapabilityForMessage({ + userMessage, + locale, + availableTools: effectiveTools.map((tool) => tool.name), + chartAutoMode, + }), + ].filter((capability) => capability !== null), + skillSummary: skillDef?.name ?? (skillMenu ? 'auto_skill_menu' : undefined), + } + const routeStartedAt = Date.now() + const routeDecision = await decideRequestRoute(routeInput, { + llmRouter: createLlmRouteDecider({ + piModel, + apiKey: llmConfig.apiKey, + abortSignal, + }), + }) + aiLogger?.info('Router', 'Shadow route decision', { + ...routeDecision, + elapsedMs: Date.now() - routeStartedAt, + availableToolCount: tools.length, + shadowOnly: true, + }) + onEvent({ type: 'route', routeDecision }) + + let effectiveSystemPrompt = systemPrompt + if (routeDecision.route === 'planned_execution') { + const planStartedAt = Date.now() + const planner = createAnalysisPlanner({ + piModel, + apiKey: llmConfig.apiKey, + onPlanDelta: (delta) => onEvent({ type: 'plan_delta', planDelta: delta }), + onThinkingDelta: (delta) => onEvent({ type: 'think', content: delta, thinkTag: 'thinking' }), + onThinkingEnd: (durationMs) => + onEvent({ type: 'think', content: '', thinkTag: 'thinking', thinkDurationMs: durationMs }), + onValidationDelta: (delta) => onEvent({ type: 'think', content: delta, thinkTag: 'plan_validation' }), + onValidationEnd: (durationMs) => + onEvent({ type: 'think', content: '', thinkTag: 'plan_validation', thinkDurationMs: durationMs }), + }) + const plan = await planner(routeInput, abortSignal) + if (plan) { + const planBlock = createPlanContentBlock(plan) + onEvent({ type: 'plan', plan: planBlock }) + effectiveSystemPrompt = `${systemPrompt}\n\n${buildPlanGuidance(plan)}` + aiLogger?.info('Planner', 'Plan generated', { + title: plan.title, + steps: plan.steps.length, + successCriteria: plan.successCriteria.length, + elapsedMs: Date.now() - planStartedAt, + }) + } else { + aiLogger?.warn('Planner', 'Plan generation skipped or failed', { + elapsedMs: Date.now() - planStartedAt, + route: routeDecision.route, + }) + onEvent({ type: 'plan_skipped' }) + } + } + + const result = await runAgentCore({ + piModel, + apiKey: llmConfig.apiKey, + systemPrompt: effectiveSystemPrompt, + tools: effectiveTools, + history, + userMessage, + maxToolRounds: DEFAULT_MAX_TOOL_ROUNDS, + abortSignal, + steerMessage, + thinkingLevel: thinkingLevel as import('@openchatlab/core').ThinkingLevel | undefined, + onConvertToLlm: (filteredMessages) => { + cachedMessages = filteredMessages as PiMessage[] + }, + onEvent: (coreEvent) => handler.handleCoreEvent(coreEvent, cachedMessages), + onDebugContext: (messages) => { + try { + aiChatManager.setPendingDebugContext(aiChatId, JSON.stringify(messages, null, 2)) + } catch { + // silent + } + }, + }) + + if (result.error) { + const friendlyMessage = formatAIError(result.error) + onEvent({ type: 'error', error: { name: 'AgentError', message: friendlyMessage } }) + } + + handler.emitStatus('completed', cachedMessages, { force: true }) + onEvent({ type: 'done', isFinished: true, usage: result.usage }) + } catch (error) { + const friendlyMessage = formatAIError(error) + aiLogger?.error('ServerAgent', 'Agent execution error', { error: String(error) }) + handler.emitStatus('error', cachedMessages, { force: true }) + onEvent({ type: 'error', error: { name: 'AgentError', message: friendlyMessage } }) + onEvent({ type: 'done', isFinished: true, usage: handler.cloneUsage() }) + } +} diff --git a/apps/cli/src/ai/assistant-loader.ts b/apps/cli/src/ai/assistant-loader.ts new file mode 100644 index 000000000..76815f743 --- /dev/null +++ b/apps/cli/src/ai/assistant-loader.ts @@ -0,0 +1,22 @@ +/** + * 助手配置加载器 + * + * 从 ~/.chatlab/ai/assistants/*.md 加载助手系统提示词。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import { parseAssistantFile } from '@openchatlab/node-runtime' +import type { AssistantConfig } from '@openchatlab/node-runtime' + +export function loadAssistantConfig(aiDataDir: string, assistantId: string): AssistantConfig | null { + const filePath = path.join(aiDataDir, 'assistants', `${assistantId}.md`) + if (!fs.existsSync(filePath)) return null + + try { + const content = fs.readFileSync(filePath, 'utf-8') + return parseAssistantFile(content, filePath) + } catch { + return null + } +} diff --git a/apps/cli/src/ai/chat-command.test.ts b/apps/cli/src/ai/chat-command.test.ts new file mode 100644 index 000000000..965b8ad21 --- /dev/null +++ b/apps/cli/src/ai/chat-command.test.ts @@ -0,0 +1,528 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { Readable } from 'node:stream' +import { Writable } from 'node:stream' +import type { AIChatManager, ContentBlock, DatabaseManager, TokenUsageData } from '@openchatlab/node-runtime' +import { resolveAIChatTarget, runChatCommand, runChatTurn } from './chat-command' + +function createDbManager(sessionIds: string[]): DatabaseManager { + return { + listSessionIds: () => sessionIds, + open: (sessionId: string) => (sessionIds.includes(sessionId) ? {} : null), + } as unknown as DatabaseManager +} + +function createAIChatManager( + existing: Array<{ id: string; sessionId: string; assistantId?: string }> = [] +): AIChatManager { + const chats = new Map( + existing.map((chat) => [chat.id, { ...chat, title: null, assistantId: chat.assistantId ?? 'general_cn' }]) + ) + const messages: Array<{ + aiChatId: string + role: string + content: string + contentBlocks?: ContentBlock[] + tokenUsage?: TokenUsageData + }> = [] + + return { + getAIChat: (aiChatId: string) => chats.get(aiChatId) ?? null, + createAIChat: (sessionId: string, title: string | undefined, assistantId: string) => { + const id = `ai_chat_${chats.size + 1}` + const chat = { id, sessionId, title: title ?? null, assistantId } + chats.set(id, chat) + return chat + }, + addMessage: ( + aiChatId: string, + role: string, + content: string, + _dataKeywords?: string[], + _dataMessageCount?: number, + contentBlocks?: ContentBlock[], + tokenUsage?: TokenUsageData + ) => { + messages.push({ + aiChatId, + role, + content, + ...(contentBlocks ? { contentBlocks } : {}), + ...(tokenUsage ? { tokenUsage } : {}), + }) + return { id: `msg_${messages.length}`, aiChatId, role, content, timestamp: 1 } + }, + __messages: messages, + } as unknown as AIChatManager +} + +class MemoryWritable extends Writable { + chunks: string[] = [] + onChunk?: (text: string) => void + + _write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + const text = String(chunk) + this.chunks.push(text) + this.onChunk?.(text) + callback() + } + + text(): string { + return this.chunks.join('') + } +} + +class PromptDrivenReadable extends Readable { + private nextLines: string[] + + constructor(lines: string[]) { + super() + this.nextLines = lines + } + + _read(): void { + // Input is pushed when the CLI writes a prompt. + } + + pushNext(): void { + const line = this.nextLines.shift() + if (line === undefined) { + this.push(null) + return + } + this.push(`${line}\n`) + } +} + +describe('resolveAIChatTarget', () => { + it('creates a new AI chat for an explicit session id', () => { + const target = resolveAIChatTarget( + { sessionId: 'session-1', question: 'hello' }, + { dbManager: createDbManager(['session-1']), aiChatManager: createAIChatManager() } + ) + + assert.equal(target.sessionId, 'session-1') + assert.equal(target.aiChatId, 'ai_chat_1') + assert.equal(target.created, true) + }) + + it('recovers session id from a globally unique aiChatId', () => { + const target = resolveAIChatTarget( + { aiChatId: 'ai-chat-1' }, + { + dbManager: createDbManager(['session-1']), + aiChatManager: createAIChatManager([{ id: 'ai-chat-1', sessionId: 'session-1' }]), + } + ) + + assert.deepEqual(target, { + sessionId: 'session-1', + aiChatId: 'ai-chat-1', + assistantId: 'general_cn', + created: false, + }) + }) + + it('rejects mismatched explicit session id and aiChatId', () => { + assert.throws( + () => + resolveAIChatTarget( + { sessionId: 'session-2', aiChatId: 'ai-chat-1' }, + { + dbManager: createDbManager(['session-1', 'session-2']), + aiChatManager: createAIChatManager([{ id: 'ai-chat-1', sessionId: 'session-1' }]), + } + ), + /belongs to session session-1/ + ) + }) +}) + +describe('runChatTurn', () => { + it('collects streamed answer and persists user and assistant messages', async () => { + const stdout = new MemoryWritable() + const aiChatManager = createAIChatManager() + let streamedAssistantId: string | undefined + const result = await runChatTurn( + { sessionId: 'session-1', question: 'hello', json: true }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + createRunAgentStream: () => async (params, onEvent) => { + streamedAssistantId = params.assistantId + onEvent({ type: 'content', content: 'hi' }) + onEvent({ + type: 'done', + isFinished: true, + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }) + }, + } + ) + + assert.equal(result.sessionId, 'session-1') + assert.equal(result.aiChatId, 'ai_chat_1') + assert.equal(result.answer, 'hi') + assert.equal(result.usage.tokenUsage?.totalTokens, 2) + assert.equal(streamedAssistantId, 'general_cn') + assert.equal(stdout.text(), '') + assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, [ + { aiChatId: 'ai_chat_1', role: 'user', content: 'hello' }, + { + aiChatId: 'ai_chat_1', + role: 'assistant', + content: 'hi', + tokenUsage: { promptTokens: 1, completionTokens: 1, totalTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + ]) + }) + + it('can include all agent events and persist plan content blocks', async () => { + const stdout = new MemoryWritable() + const aiChatManager = createAIChatManager() + + const result = await runChatTurn( + { sessionId: 'session-1', question: '分析过去一年话题趋势', json: true, includeEvents: true }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + createRunAgentStream: () => async (_params, onEvent) => { + onEvent({ + type: 'route', + routeDecision: { + route: 'planned_execution', + confidence: 0.91, + reason: 'Complex long-range trend analysis.', + source: 'rule', + }, + }) + onEvent({ type: 'plan_delta', planDelta: '年度话题趋势\n' }) + onEvent({ type: 'plan_delta', planDelta: '1. 按季度检索\n' }) + onEvent({ + type: 'plan', + plan: { + type: 'plan', + version: 1, + status: 'created', + plan: { + version: 1, + title: '年度话题趋势', + route: 'planned_execution', + intent: 'trend', + steps: [{ goal: '按季度检索', suggestedTools: ['search_messages'], evidenceNeeded: '季度证据' }], + successCriteria: ['覆盖至少三个季度'], + }, + }, + }) + onEvent({ type: 'content', content: '年度趋势如下。' }) + onEvent({ + type: 'done', + isFinished: true, + usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }) + }, + } + ) + + assert.equal(result.answer, '年度趋势如下。') + assert.equal(result.events?.length, 6) + assert.equal(result.events?.[0]?.type, 'route') + assert.equal(result.events?.[0]?.routeDecision?.route, 'planned_execution') + assert.equal(result.events?.[1]?.type, 'plan_delta') + assert.equal(result.events?.[2]?.type, 'plan_delta') + assert.equal(result.events?.[3]?.type, 'plan') + const firstBlock = result.contentBlocks?.[0] + assert.equal(firstBlock?.type, 'plan') + if (firstBlock?.type === 'plan') { + assert.equal(firstBlock.status, 'done') + assert.equal(firstBlock.displayText, '年度话题趋势\n1. 按季度检索') + } + assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, [ + { aiChatId: 'ai_chat_1', role: 'user', content: '分析过去一年话题趋势' }, + { + aiChatId: 'ai_chat_1', + role: 'assistant', + content: '年度趋势如下。', + contentBlocks: result.contentBlocks, + tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + ]) + }) + + it('drops streamed plan drafts when planner validation is skipped', async () => { + const stdout = new MemoryWritable() + const aiChatManager = createAIChatManager() + + const result = await runChatTurn( + { sessionId: 'session-1', question: '分析过去一年话题趋势', json: true, includeEvents: true }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + createRunAgentStream: () => async (_params, onEvent) => { + onEvent({ type: 'plan_delta', planDelta: '无法校验的计划草稿\n' }) + onEvent({ type: 'plan_skipped' }) + onEvent({ type: 'content', content: '直接给出分析。' }) + onEvent({ type: 'done', isFinished: true }) + }, + } + ) + + assert.equal(result.events?.[0]?.type, 'plan_delta') + assert.equal(result.events?.[1]?.type, 'plan_skipped') + assert.deepEqual( + result.contentBlocks?.map((block) => block.type), + ['text'] + ) + }) + + it('persists streamed thinking and answer text blocks for full CLI replay', async () => { + const stdout = new MemoryWritable() + const aiChatManager = createAIChatManager() + + const result = await runChatTurn( + { sessionId: 'session-1', question: '分析过去一年话题趋势', json: true, includeEvents: true }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + createRunAgentStream: () => async (_params, onEvent) => { + onEvent({ + type: 'plan', + plan: { + type: 'plan', + version: 1, + status: 'created', + plan: { + version: 1, + title: '年度话题趋势', + route: 'planned_execution', + intent: 'trend', + steps: [{ goal: '按季度检索', suggestedTools: ['search_messages'], evidenceNeeded: '季度证据' }], + successCriteria: ['覆盖至少三个季度'], + }, + }, + }) + onEvent({ type: 'think', thinkTag: 'thinking', content: '先理解问题,' }) + onEvent({ type: 'think', thinkTag: 'thinking', content: '再整理证据。' }) + onEvent({ type: 'think', thinkTag: 'thinking', content: '', thinkDurationMs: 1200 }) + onEvent({ type: 'content', content: '年度趋势如下。' }) + onEvent({ + type: 'done', + isFinished: true, + usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }) + }, + } + ) + + assert.equal(result.answer, '年度趋势如下。') + assert.equal(result.events?.filter((event) => event.type === 'think').length, 3) + assert.deepEqual( + result.contentBlocks?.map((block) => block.type), + ['plan', 'think', 'text'] + ) + assert.equal(result.contentBlocks?.[0]?.type, 'plan') + assert.equal(result.contentBlocks?.[0]?.status, 'done') + assert.deepEqual(result.contentBlocks?.[1], { + type: 'think', + tag: 'thinking', + text: '先理解问题,再整理证据。', + durationMs: 1200, + }) + assert.deepEqual(result.contentBlocks?.[2], { + type: 'text', + text: '年度趋势如下。', + }) + assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, [ + { aiChatId: 'ai_chat_1', role: 'user', content: '分析过去一年话题趋势' }, + { + aiChatId: 'ai_chat_1', + role: 'assistant', + content: '年度趋势如下。', + contentBlocks: result.contentBlocks, + tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + ]) + }) + + it('persists render_chart results as chart content blocks for CLI replay', async () => { + const stdout = new MemoryWritable() + const aiChatManager = createAIChatManager() + const chart = { + version: 1, + spec: { + version: 1, + type: 'bar', + title: '季度消息量趋势', + encoding: { x: 'quarter', y: 'count' }, + }, + dataset: { + columns: [ + { name: 'quarter', type: 'category' }, + { name: 'count', type: 'integer' }, + ], + rows: [ + { quarter: '2025-Q4', count: 1607 }, + { quarter: '2026-Q1', count: 4284 }, + ], + }, + data: { + labels: ['2025-Q4', '2026-Q1'], + values: [1607, 4284], + }, + rowCount: 2, + } as const + + const result = await runChatTurn( + { sessionId: 'session-1', question: '分析季度消息量变化趋势', json: true, includeEvents: true }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + createRunAgentStream: () => async (_params, onEvent) => { + onEvent({ + type: 'plan', + plan: { + type: 'plan', + version: 1, + status: 'created', + plan: { + version: 1, + title: '季度消息量趋势', + route: 'planned_execution', + intent: 'trend', + steps: [ + { goal: '生成趋势图', suggestedTools: ['get_schema', 'render_chart'], evidenceNeeded: '季度数据' }, + ], + successCriteria: ['展示趋势'], + }, + }, + }) + onEvent({ type: 'tool_result', toolName: 'render_chart', toolResult: { details: { chart } } }) + onEvent({ type: 'content', content: '2026-Q1 是峰值。' }) + onEvent({ + type: 'done', + isFinished: true, + usage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }) + }, + } + ) + + assert.deepEqual( + result.contentBlocks?.map((block) => block.type), + ['plan', 'chart', 'text'] + ) + assert.equal(result.contentBlocks?.[1]?.type, 'chart') + assert.deepEqual(result.contentBlocks?.[1]?.chart.dataset.rows, []) + assert.deepEqual(result.contentBlocks?.[1]?.chart.data, chart.data) + assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages.at(-1), { + aiChatId: 'ai_chat_1', + role: 'assistant', + content: '2026-Q1 是峰值。', + contentBlocks: result.contentBlocks, + tokenUsage: { promptTokens: 3, completionTokens: 5, totalTokens: 8, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }) + }) + + it('fails the turn and does not persist messages when the agent stream reports an error', async () => { + const stdout = new MemoryWritable() + const aiChatManager = createAIChatManager() + + await assert.rejects( + () => + runChatTurn( + { sessionId: 'session-1', question: 'hello', json: true }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + createRunAgentStream: () => async (_params, onEvent) => { + onEvent({ type: 'error', error: { name: 'ConfigError', message: 'LLM service not configured' } }) + onEvent({ type: 'done', isFinished: true }) + }, + } + ), + /LLM service not configured/ + ) + + assert.equal(stdout.text(), '') + assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, []) + }) + + it('passes the resolved AI chat assistant id into the agent stream', async () => { + const stdout = new MemoryWritable() + const aiChatManager = createAIChatManager([ + { id: 'ai-chat-1', sessionId: 'session-1', assistantId: 'custom_assistant' }, + ]) + let streamedAssistantId: string | undefined + let streamedEnableAutoSkill: boolean | undefined + + await runChatTurn( + { aiChatId: 'ai-chat-1', question: 'hello', json: true }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + createRunAgentStream: () => async (params, onEvent) => { + streamedAssistantId = params.assistantId + streamedEnableAutoSkill = params.enableAutoSkill + onEvent({ type: 'content', content: 'hi' }) + onEvent({ type: 'done', isFinished: true }) + }, + } + ) + + assert.equal(streamedAssistantId, 'custom_assistant') + assert.equal(streamedEnableAutoSkill, true) + }) +}) + +describe('runChatCommand', () => { + it('keeps interactive mode alive after a single failed turn', async () => { + const stdout = new MemoryWritable() + const stderr = new MemoryWritable() + const stdin = new PromptDrivenReadable(['fail', 'recover', 'exit']) + const aiChatManager = createAIChatManager() + stdout.onChunk = (text) => { + if (text.includes('chatlab> ')) stdin.pushNext() + } + + const command = runChatCommand( + { sessionId: 'session-1' }, + { + dbManager: createDbManager(['session-1']), + pathProvider: {} as never, + aiChatManager, + stdout, + stderr, + stdin, + createRunAgentStream: () => async (params, onEvent) => { + if (params.userMessage === 'fail') { + throw new Error('temporary failure') + } + onEvent({ type: 'content', content: 'recovered' }) + onEvent({ type: 'done', isFinished: true }) + }, + } + ) + await command + + assert.match(stderr.text(), /temporary failure/) + assert.match(stdout.text(), /recovered/) + assert.deepEqual((aiChatManager as unknown as { __messages: unknown[] }).__messages, [ + { aiChatId: 'ai_chat_2', role: 'user', content: 'recover' }, + { aiChatId: 'ai_chat_2', role: 'assistant', content: 'recovered' }, + ]) + }) +}) diff --git a/apps/cli/src/ai/chat-command.ts b/apps/cli/src/ai/chat-command.ts new file mode 100644 index 000000000..0575b6bbd --- /dev/null +++ b/apps/cli/src/ai/chat-command.ts @@ -0,0 +1,374 @@ +import { createInterface } from 'node:readline/promises' +import type { Readable, Writable } from 'node:stream' +import type { + DatabaseManager, + AIChatManager, + AgentStreamChunk, + ContentBlock, + PlanContentBlock, + PlanDraftContentBlock, + TokenUsageData, +} from '@openchatlab/node-runtime' +import type { ChartPayload, PathProvider } from '@openchatlab/core' +import { createCliRunAgentStream } from './agent-stream-runner' + +export interface ChatCommandOptions { + sessionId?: string + aiChatId?: string + question?: string + json?: boolean + stream?: boolean + locale?: string + includeEvents?: boolean +} + +export interface ChatCommandDeps { + dbManager: DatabaseManager + pathProvider: PathProvider + aiChatManager: AIChatManager + stdout?: Pick + stderr?: Pick + stdin?: Readable + createRunAgentStream?: typeof createCliRunAgentStream +} + +export interface ResolvedAIChatTarget { + sessionId: string + aiChatId: string + assistantId: string + created: boolean +} + +export interface ChatTurnResult { + sessionId: string + aiChatId: string + question: string + answer: string + events?: AgentStreamChunk[] + contentBlocks?: ContentBlock[] + usage: { + durationMs: number + tokenUsage: TokenUsageData | null + } +} + +function write(stream: Pick, text: string): void { + stream.write(text) +} + +function buildTitle(question?: string): string { + const trimmed = question?.trim() + if (!trimmed) return 'CLI Chat' + return trimmed.length > 50 ? `${trimmed.slice(0, 50)}...` : trimmed +} + +function createAgentStreamError(error: unknown): Error { + if (error instanceof Error) return error + if (typeof error === 'string' && error) return new Error(error) + if (typeof error === 'object' && error !== null) { + const record = error as { name?: unknown; message?: unknown; stack?: unknown } + const streamError = new Error( + typeof record.message === 'string' && record.message ? record.message : 'Agent stream failed' + ) + if (typeof record.name === 'string' && record.name) streamError.name = record.name + if (typeof record.stack === 'string' && record.stack) streamError.stack = record.stack + return streamError + } + return new Error('Agent stream failed') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isChartPayload(value: unknown): value is ChartPayload { + return isRecord(value) && value.version === 1 && isRecord(value.spec) && isRecord(value.dataset) +} + +function extractChartPayloads(toolResult: unknown): ChartPayload[] { + if (!isRecord(toolResult)) return [] + const details = isRecord(toolResult.details) ? toolResult.details : toolResult + const charts: ChartPayload[] = [] + if (isChartPayload(details.chart)) charts.push(details.chart) + if (Array.isArray(details.charts)) { + for (const chart of details.charts) { + if (isChartPayload(chart)) charts.push(chart) + } + } + return charts +} + +function toPersistedChartPayload(chart: ChartPayload): ChartPayload { + return { + ...chart, + dataset: { + ...chart.dataset, + rows: [], + }, + } +} + +function resolveSingleSessionId(dbManager: DatabaseManager): string { + const sessionIds = dbManager.listSessionIds() + if (sessionIds.length === 1) return sessionIds[0]! + if (sessionIds.length === 0) { + throw new Error('No chat sessions found. Import data first or run `chatlab sessions` to verify available sessions.') + } + throw new Error('Missing --session-id. Run `chatlab sessions` to choose a session ID.') +} + +function assertSessionExists(dbManager: DatabaseManager, sessionId: string): void { + const db = dbManager.open(sessionId) + if (!db) { + throw new Error(`Session ${sessionId} not found`) + } +} + +export function resolveAIChatTarget( + options: Pick, + deps: Pick +): ResolvedAIChatTarget { + if (options.aiChatId) { + const aiChat = deps.aiChatManager.getAIChat(options.aiChatId) + if (!aiChat) { + throw new Error(`AI chat ${options.aiChatId} not found`) + } + if (options.sessionId && options.sessionId !== aiChat.sessionId) { + throw new Error(`AI chat ${options.aiChatId} belongs to session ${aiChat.sessionId}, not ${options.sessionId}`) + } + assertSessionExists(deps.dbManager, aiChat.sessionId) + return { sessionId: aiChat.sessionId, aiChatId: aiChat.id, assistantId: aiChat.assistantId, created: false } + } + + const sessionId = options.sessionId ?? resolveSingleSessionId(deps.dbManager) + assertSessionExists(deps.dbManager, sessionId) + const aiChat = deps.aiChatManager.createAIChat(sessionId, buildTitle(options.question), 'general_cn') + return { sessionId, aiChatId: aiChat.id, assistantId: aiChat.assistantId, created: true } +} + +export async function runChatTurn( + options: Required> & + Pick, + deps: ChatCommandDeps +): Promise { + const stdout = deps.stdout ?? process.stdout + const target = resolveAIChatTarget(options, deps) + const startedAt = Date.now() + let answer = '' + let tokenUsage: TokenUsageData | null = null + let streamError: Error | null = null + const events: AgentStreamChunk[] = [] + const contentBlocks: ContentBlock[] = [] + let hasReplayContentBlocks = false + + const runAgentStream = (deps.createRunAgentStream ?? createCliRunAgentStream)(deps.dbManager, deps.aiChatManager) + + // 中文注释:CLI 历史回放依赖 contentBlocks 的时序;只要出现计划/思考块, + // 就同步保留后续正文 text block,避免 UI 使用 blocks 渲染时丢失最终回答。 + const appendTextBlock = (text: string) => { + if (!text) return + const lastBlock = contentBlocks[contentBlocks.length - 1] + if (lastBlock?.type === 'text') { + lastBlock.text += text + } else { + contentBlocks.push({ type: 'text', text }) + } + } + + const appendThinkBlock = (text: string, tag = 'thinking', durationMs?: number) => { + if (!text && durationMs === undefined) return + const lastBlock = contentBlocks[contentBlocks.length - 1] + let targetBlock: ContentBlock | undefined + + if (lastBlock?.type === 'think' && lastBlock.tag === tag) { + lastBlock.text += text + targetBlock = lastBlock + } else if (text.trim().length > 0) { + targetBlock = { type: 'think', tag, text } + contentBlocks.push(targetBlock) + } else if (durationMs !== undefined) { + for (let index = contentBlocks.length - 1; index >= 0; index--) { + const block = contentBlocks[index] + if (block.type === 'think' && block.tag === tag) { + targetBlock = block + break + } + } + } + + if (durationMs !== undefined && targetBlock?.type === 'think') { + targetBlock.durationMs = durationMs + } + if (targetBlock?.type === 'think') { + hasReplayContentBlocks = true + } + } + + const appendChartBlocks = (charts: ChartPayload[]) => { + if (charts.length === 0) return + // 中文注释:图表 block 需要持久化给 CLI 生成的 AI 对话回放; + // 原始 SQL 行数据可能较大,保存时只保留渲染数据和字段元信息。 + contentBlocks.push(...charts.map((chart) => ({ type: 'chart' as const, chart: toPersistedChartPayload(chart) }))) + hasReplayContentBlocks = true + } + + const appendPlanDraftBlock = (delta: string) => { + if (!delta) return + const lastBlock = contentBlocks[contentBlocks.length - 1] + if (lastBlock?.type === 'plan_draft') { + lastBlock.text += delta + } else { + contentBlocks.push({ type: 'plan_draft', version: 1, status: 'streaming', text: delta } as PlanDraftContentBlock) + } + hasReplayContentBlocks = true + } + + const removePlanDraftBlocks = () => { + for (let index = contentBlocks.length - 1; index >= 0; index--) { + if (contentBlocks[index]?.type === 'plan_draft') { + contentBlocks.splice(index, 1) + } + } + } + + const appendFinalPlanBlock = (plan: PlanContentBlock) => { + const planBlock = JSON.parse(JSON.stringify(plan)) as PlanContentBlock + for (let index = contentBlocks.length - 1; index >= 0; index--) { + const block = contentBlocks[index] + if (block?.type === 'plan_draft') { + const displayText = block.text.trim() + if (displayText) planBlock.displayText = displayText + contentBlocks[index] = planBlock + hasReplayContentBlocks = true + return + } + } + contentBlocks.push(planBlock) + hasReplayContentBlocks = true + } + + await runAgentStream( + { + userMessage: options.question, + sessionId: target.sessionId, + aiChatId: target.aiChatId, + assistantId: target.assistantId, + chatType: 'group', + locale: options.locale ?? 'zh-CN', + enableAutoSkill: true, + chartAutoMode: 'suggest', + }, + (chunk: AgentStreamChunk) => { + if (options.includeEvents) { + events.push(JSON.parse(JSON.stringify(chunk)) as AgentStreamChunk) + } + if (chunk.type === 'error') { + removePlanDraftBlocks() + streamError = createAgentStreamError(chunk.error) + return + } + if (chunk.type === 'plan_delta' && chunk.planDelta) { + appendPlanDraftBlock(chunk.planDelta) + return + } + if (chunk.type === 'plan' && chunk.plan) { + appendFinalPlanBlock(chunk.plan) + return + } + if (chunk.type === 'plan_skipped') { + removePlanDraftBlocks() + return + } + if (chunk.type === 'think') { + appendThinkBlock(chunk.content ?? '', chunk.thinkTag, chunk.thinkDurationMs) + return + } + if (chunk.type === 'tool_result' && chunk.toolName === 'render_chart') { + appendChartBlocks(extractChartPayloads(chunk.toolResult)) + return + } + if (chunk.type === 'content' && chunk.content) { + answer += chunk.content + appendTextBlock(chunk.content) + if (!options.json && options.stream !== false) write(stdout, chunk.content) + } + if (chunk.type === 'done' && chunk.usage) { + tokenUsage = chunk.usage + for (let index = contentBlocks.length - 1; index >= 0; index--) { + const block = contentBlocks[index] + if (block.type === 'plan') { + block.status = 'done' + break + } + } + } + }, + new AbortController().signal + ) + + if (streamError) throw streamError + + if (!options.json && options.stream !== false && answer && !answer.endsWith('\n')) { + write(stdout, '\n') + } + + deps.aiChatManager.addMessage(target.aiChatId, 'user', options.question) + deps.aiChatManager.addMessage( + target.aiChatId, + 'assistant', + answer, + undefined, + undefined, + hasReplayContentBlocks ? contentBlocks : undefined, + tokenUsage ?? undefined + ) + + return { + sessionId: target.sessionId, + aiChatId: target.aiChatId, + question: options.question, + answer, + ...(options.includeEvents ? { events } : {}), + ...(hasReplayContentBlocks ? { contentBlocks } : {}), + usage: { + durationMs: Date.now() - startedAt, + tokenUsage, + }, + } +} + +export async function runChatCommand(options: ChatCommandOptions, deps: ChatCommandDeps): Promise { + const stdout = deps.stdout ?? process.stdout + const stderr = deps.stderr ?? process.stderr + + if (options.question?.trim()) { + const result = await runChatTurn({ ...options, question: options.question.trim() }, deps) + if (options.json) { + write(stdout, `${JSON.stringify(result, null, 2)}\n`) + } else if (options.stream === false) { + write(stdout, `${result.answer}\n`) + } + return + } + + const rl = createInterface({ + input: deps.stdin ?? process.stdin, + output: stdout as Writable, + }) + + try { + let aiChatId = options.aiChatId + while (true) { + const question = (await rl.question('chatlab> ')).trim() + if (!question || question === 'exit' || question === 'quit') break + try { + const result = await runChatTurn({ ...options, aiChatId, question, json: false, stream: true }, deps) + aiChatId = result.aiChatId + } catch (error) { + write(stderr, `${error instanceof Error ? error.message : String(error)}\n`) + } + } + } finally { + rl.close() + } +} diff --git a/apps/cli/src/ai/custom-store.ts b/apps/cli/src/ai/custom-store.ts new file mode 100644 index 000000000..00a5fe705 --- /dev/null +++ b/apps/cli/src/ai/custom-store.ts @@ -0,0 +1,142 @@ +/** + * 自定义 Provider / Model 持久化存储(平台无关) + * + * 文件位置: + * - {aiDataDir}/custom-providers.json + * - {aiDataDir}/custom-models.json + */ + +import * as fs from 'fs' +import * as path from 'path' +import { randomUUID } from 'crypto' +import type { ProviderDefinition, ModelDefinition } from '@openchatlab/core' + +// ==================== Custom Providers ==================== + +function readProviders(aiDataDir: string): ProviderDefinition[] { + const storePath = path.join(aiDataDir, 'custom-providers.json') + if (!fs.existsSync(storePath)) return [] + try { + const content = fs.readFileSync(storePath, 'utf-8') + return JSON.parse(content) as ProviderDefinition[] + } catch { + return [] + } +} + +function writeProviders(aiDataDir: string, providers: ProviderDefinition[]): void { + const storePath = path.join(aiDataDir, 'custom-providers.json') + const dir = path.dirname(storePath) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(storePath, JSON.stringify(providers, null, 2), 'utf-8') +} + +export function addCustomProvider( + aiDataDir: string, + input: Omit +): ProviderDefinition { + const providers = readProviders(aiDataDir) + const newProvider: ProviderDefinition = { + ...input, + id: `custom:${randomUUID()}`, + builtin: false, + enabledByDefault: false, + } + providers.push(newProvider) + writeProviders(aiDataDir, providers) + return newProvider +} + +export function updateCustomProvider( + aiDataDir: string, + id: string, + updates: Partial> +): { success: boolean; error?: string } { + const providers = readProviders(aiDataDir) + const index = providers.findIndex((p) => p.id === id) + if (index === -1) return { success: false, error: 'Custom provider not found' } + + providers[index] = { ...providers[index], ...updates } + writeProviders(aiDataDir, providers) + return { success: true } +} + +export function deleteCustomProvider(aiDataDir: string, id: string): { success: boolean; error?: string } { + const providers = readProviders(aiDataDir) + const index = providers.findIndex((p) => p.id === id) + if (index === -1) return { success: false, error: 'Custom provider not found' } + + providers.splice(index, 1) + writeProviders(aiDataDir, providers) + return { success: true } +} + +// ==================== Custom Models ==================== + +function readModels(aiDataDir: string): ModelDefinition[] { + const storePath = path.join(aiDataDir, 'custom-models.json') + if (!fs.existsSync(storePath)) return [] + try { + const content = fs.readFileSync(storePath, 'utf-8') + return JSON.parse(content) as ModelDefinition[] + } catch { + return [] + } +} + +function writeModels(aiDataDir: string, models: ModelDefinition[]): void { + const storePath = path.join(aiDataDir, 'custom-models.json') + const dir = path.dirname(storePath) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(storePath, JSON.stringify(models, null, 2), 'utf-8') +} + +export function addCustomModel( + aiDataDir: string, + input: Omit +): { success: boolean; model?: ModelDefinition; error?: string } { + const models = readModels(aiDataDir) + + const existing = models.find((m) => m.id === input.id && m.providerId === input.providerId) + if (existing) { + return { success: false, error: `Model "${input.id}" already exists under provider "${input.providerId}"` } + } + + const newModel: ModelDefinition = { + ...input, + builtin: false, + editable: true, + } + models.push(newModel) + writeModels(aiDataDir, models) + return { success: true, model: newModel } +} + +export function updateCustomModel( + aiDataDir: string, + providerId: string, + modelId: string, + updates: Partial> +): { success: boolean; error?: string } { + const models = readModels(aiDataDir) + const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) + if (index === -1) return { success: false, error: 'Custom model not found' } + + models[index] = { ...models[index], ...updates } + writeModels(aiDataDir, models) + return { success: true } +} + +export function deleteCustomModel( + aiDataDir: string, + providerId: string, + modelId: string +): { success: boolean; error?: string } { + const models = readModels(aiDataDir) + const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) + if (index === -1) return { success: false, error: 'Custom model not found' } + + models.splice(index, 1) + writeModels(aiDataDir, models) + return { success: true } +} diff --git a/apps/cli/src/ai/llm-config.ts b/apps/cli/src/ai/llm-config.ts new file mode 100644 index 000000000..47d72bbc0 --- /dev/null +++ b/apps/cli/src/ai/llm-config.ts @@ -0,0 +1,256 @@ +/** + * 服务端 LLM 配置加载 + * + * API Key 从 ~/.chatlab/auth-profiles.json 读取, + * 匹配顺序:config.authProfile 精确匹配 > config.provider 兜底匹配。 + * + * 模型/Provider 等配置仍从 llm-config.json 读取。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { PiModel, PiApi } from '@openchatlab/node-runtime' +import { buildPiModel as buildPiModelCore, type PiModelConfig } from '@openchatlab/node-runtime' +import { resolveApiKey, writeAuthProfile } from '@openchatlab/config' +import { randomUUID } from 'crypto' + +// ==================== Types ==================== + +export interface AIServiceConfig { + id: string + name: string + provider: string + apiKey: string + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string + customModels?: Array<{ id: string; name: string }> + authProfile?: string +} + +interface ModelSlot { + configId: string + modelId?: string +} + +interface AIConfigStore { + configs: AIServiceConfig[] + defaultAssistant: ModelSlot | null + fastModel: ModelSlot | null +} + +// ==================== Config Loading ==================== + +export function loadLlmConfig(aiDataDir: string): AIConfigStore { + const configPath = path.join(aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) { + return { configs: [], defaultAssistant: null, fastModel: null } + } + + try { + const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + + const configs = (data.configs || []).map((c: Record) => { + const provider = (c.provider as string) || '' + const authProfile = c.authProfile as string | undefined + const profileKey = resolveApiKey(provider, authProfile) + const rawKey = (c.apiKey as string) || '' + const fallbackKey = rawKey.startsWith('enc:') ? '' : rawKey + return { + ...c, + apiKey: profileKey || fallbackKey, + } + }) + return { + configs, + defaultAssistant: data.defaultAssistant ?? null, + fastModel: data.fastModel ?? null, + } + } catch { + return { configs: [], defaultAssistant: null, fastModel: null } + } +} + +function resolveSlot(slot: ModelSlot | null | undefined, configs: AIServiceConfig[]): ModelSlot | null { + if (slot && configs.some((c) => c.id === slot.configId)) return slot + const fallback = configs[0] + if (!fallback) return null + return { configId: fallback.id, modelId: fallback.model } +} + +export function getDefaultAssistantConfig(aiDataDir: string): AIServiceConfig | null { + const store = loadLlmConfig(aiDataDir) + const slot = resolveSlot(store.defaultAssistant, store.configs) + if (!slot) return null + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) return null + return { ...config, model: slot.modelId || config.model } +} + +// ==================== Config Write Operations ==================== + +const MAX_CONFIG_COUNT = 99 + +function saveLlmConfig(aiDataDir: string, store: AIConfigStore): void { + const configPath = path.join(aiDataDir, 'llm-config.json') + const toSave = { + ...store, + configs: store.configs.map((c) => { + const { apiKey: _k, ...rest } = c + return rest + }), + schemaVersion: 3, + } + fs.writeFileSync(configPath, JSON.stringify(toSave, null, 2), 'utf-8') +} + +export function addLlmConfig( + aiDataDir: string, + config: Omit +): { success: boolean; config?: AIServiceConfig; error?: string } { + const store = loadLlmConfig(aiDataDir) + + if (store.configs.length >= MAX_CONFIG_COUNT) { + return { success: false, error: `Maximum ${MAX_CONFIG_COUNT} configs reached` } + } + + const newConfig: AIServiceConfig = { + ...config, + id: randomUUID(), + } + + const storeForSave = loadRawConfigStore(aiDataDir) + storeForSave.configs.push({ + ...newConfig, + apiKey: '', + }) + + if (storeForSave.configs.length === 1) { + storeForSave.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' } + } + + if (config.apiKey) { + const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider + writeAuthProfile(profileName, { + type: 'api_key', + provider: config.provider, + key: config.apiKey, + }) + ;(storeForSave.configs[storeForSave.configs.length - 1] as unknown as Record).authProfile = + profileName + } + + saveLlmConfig(aiDataDir, storeForSave) + return { success: true, config: { ...newConfig, apiKey: '' } } +} + +export function updateLlmConfig( + aiDataDir: string, + id: string, + updates: Partial> +): { success: boolean; error?: string } { + const storeForSave = loadRawConfigStore(aiDataDir) + const index = storeForSave.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: 'Config not found' } + } + + const { apiKey: newApiKey, ...restUpdates } = updates + const updated = { + ...storeForSave.configs[index], + ...restUpdates, + } + storeForSave.configs[index] = updated + + if (newApiKey) { + const profileName = updated.name?.toLowerCase().replace(/\s+/g, '-') || updated.provider + writeAuthProfile(profileName, { + type: 'api_key', + provider: updated.provider, + key: newApiKey, + }) + ;(storeForSave.configs[index] as unknown as Record).authProfile = profileName + } + + saveLlmConfig(aiDataDir, storeForSave) + return { success: true } +} + +export function deleteLlmConfig(aiDataDir: string, id: string): { success: boolean; error?: string } { + const storeForSave = loadRawConfigStore(aiDataDir) + const index = storeForSave.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: 'Config not found' } + } + + storeForSave.configs.splice(index, 1) + + const fallback = storeForSave.configs[0] + if (storeForSave.defaultAssistant?.configId === id) { + storeForSave.defaultAssistant = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null + } + if (storeForSave.fastModel?.configId === id) { + storeForSave.fastModel = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null + } + + saveLlmConfig(aiDataDir, storeForSave) + return { success: true } +} + +export function setDefaultAssistantSlot( + aiDataDir: string, + configId: string, + modelId: string +): { success: boolean; error?: string } { + const storeForSave = loadRawConfigStore(aiDataDir) + const config = storeForSave.configs.find((c) => c.id === configId) + + if (!config) { + return { success: false, error: 'Config not found' } + } + + storeForSave.defaultAssistant = { configId, modelId } + saveLlmConfig(aiDataDir, storeForSave) + return { success: true } +} + +export function setFastModelSlot(aiDataDir: string, slot: ModelSlot | null): { success: boolean; error?: string } { + const storeForSave = loadRawConfigStore(aiDataDir) + + if (slot !== null) { + const config = storeForSave.configs.find((c) => c.id === slot.configId) + if (!config) { + return { success: false, error: 'Config not found' } + } + } + + storeForSave.fastModel = slot + saveLlmConfig(aiDataDir, storeForSave) + return { success: true } +} + +function loadRawConfigStore(aiDataDir: string): AIConfigStore { + const configPath = path.join(aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) { + return { configs: [], defaultAssistant: null, fastModel: null } + } + try { + const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + return { + configs: data.configs || [], + defaultAssistant: data.defaultAssistant ?? null, + fastModel: data.fastModel ?? null, + } + } catch { + return { configs: [], defaultAssistant: null, fastModel: null } + } +} + +// ==================== PiModel Builder ==================== + +export function buildPiModel(config: AIServiceConfig): PiModel { + return buildPiModelCore(config as PiModelConfig) +} diff --git a/apps/cli/src/ai/logger.ts b/apps/cli/src/ai/logger.ts new file mode 100644 index 000000000..d28713c86 --- /dev/null +++ b/apps/cli/src/ai/logger.ts @@ -0,0 +1,28 @@ +/** + * Server 端 AI 日志 + * + * 复用 @openchatlab/node-runtime 的 AiLogger, + * 在 startHttpServer() 中通过 initServerAiLogger() 初始化。 + */ + +import { AiLogger } from '@openchatlab/node-runtime' + +let logger: AiLogger | null = null + +export function initServerAiLogger(logsDir: string): AiLogger { + if (!logger) { + logger = new AiLogger(logsDir) + } + return logger +} + +export function getServerAiLogger(): AiLogger | null { + return logger +} + +export function closeServerAiLogger(): void { + if (logger) { + logger.close() + logger = null + } +} diff --git a/apps/cli/src/ai/manager-factory.ts b/apps/cli/src/ai/manager-factory.ts new file mode 100644 index 000000000..e34aa9b0c --- /dev/null +++ b/apps/cli/src/ai/manager-factory.ts @@ -0,0 +1,74 @@ +/** + * Factory functions for creating AssistantManager and SkillManagerCore instances. + * + * Server-side equivalent of the Electron adapter in electron/main/ai/assistant/manager.ts. + * Uses Node.js fs directly (no Electron dependency). + */ + +import * as fs from 'fs' +import * as path from 'path' +import { createHash, randomUUID } from 'crypto' +import { + AssistantManager, + SkillManagerCore, + type AssistantManagerFs, + type SkillManagerFs, +} from '@openchatlab/node-runtime' + +const nodeFs: AssistantManagerFs & SkillManagerFs = { + ensureDir(dir: string) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + }, + listFiles(dir: string, ext: string) { + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir).filter((f) => f.endsWith(ext)) + }, + readFile(filePath: string) { + return fs.readFileSync(filePath, 'utf-8') + }, + writeFile(filePath: string, content: string) { + fs.writeFileSync(filePath, content, 'utf-8') + }, + deleteFile(filePath: string) { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + }, + fileExists(filePath: string) { + return fs.existsSync(filePath) + }, + joinPath(...parts: string[]) { + return path.join(...parts) + }, +} + +const managerCache = new Map() + +export function getAssistantManager(aiDataDir: string): AssistantManager { + let cached = managerCache.get(aiDataDir) + if (!cached) { + cached = { + assistant: new AssistantManager({ + fs: nodeFs, + assistantsDir: path.join(aiDataDir, 'assistants'), + builtinRawConfigs: [], + generateId: () => `custom_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + }), + skill: new SkillManagerCore({ + fs: nodeFs, + skillsDir: path.join(aiDataDir, 'skills'), + builtinRawSkills: [], + contentHash: (content: string) => createHash('md5').update(content).digest('hex'), + }), + } + managerCache.set(aiDataDir, cached) + } + return cached.assistant +} + +export function getSkillManagerCore(aiDataDir: string): SkillManagerCore { + let cached = managerCache.get(aiDataDir) + if (!cached) { + getAssistantManager(aiDataDir) + cached = managerCache.get(aiDataDir)! + } + return cached.skill +} diff --git a/apps/cli/src/ai/remote-api.ts b/apps/cli/src/ai/remote-api.ts new file mode 100644 index 000000000..90b644845 --- /dev/null +++ b/apps/cli/src/ai/remote-api.ts @@ -0,0 +1,12 @@ +/** + * Server-side remote LLM API operations. + * Thin wrapper over @openchatlab/node-runtime shared implementation. + */ + +export { + fetchRemoteModels, + validateApiKey, + type RemoteModel, + type FetchRemoteModelsResult, + type RemoteApiOptions, +} from '@openchatlab/node-runtime' diff --git a/apps/cli/src/ai/tool-adapter.test.ts b/apps/cli/src/ai/tool-adapter.test.ts new file mode 100644 index 000000000..40ee3460a --- /dev/null +++ b/apps/cli/src/ai/tool-adapter.test.ts @@ -0,0 +1,124 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { adaptToolsForAgent } from './tool-adapter' +import type { ToolDefinition } from '@openchatlab/tools' +import type { ChartPayload, DatabaseAdapter } from '@openchatlab/core' +import { CHART_SCHEMA_REQUIRED_MESSAGE } from '@openchatlab/node-runtime' + +const chart: ChartPayload = { + version: 1, + spec: { + version: 1, + type: 'pie', + title: 'Selected members', + encoding: { label: 'name', value: 'message_count' }, + }, + dataset: { + columns: [ + { name: 'name', type: 'category' }, + { name: 'message_count', type: 'integer' }, + ], + rows: [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + ], + }, + data: { + labels: ['Alice', 'Bob'], + values: [4, 3], + }, + rowCount: 2, +} + +describe('adaptToolsForAgent', () => { + it('requires get_schema before render_chart', async () => { + let chartCalls = 0 + const tool: ToolDefinition = { + name: 'render_chart', + description: 'Render a chart', + inputSchema: { type: 'object', properties: {} }, + async handler() { + chartCalls += 1 + return { content: 'Generated chart.' } + }, + } + + const [agentTool] = adaptToolsForAgent([tool], () => ({ + db: {} as DatabaseAdapter, + sessionId: 'session-1', + locale: 'en-US', + })) + + const result = await agentTool.execute('call-1', {}) + + assert.equal(chartCalls, 0) + assert.deepEqual(result.content, [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }]) + assert.equal(result.details, null) + }) + + it('preserves chart payloads in tool result details after schema lookup', async () => { + const getSchemaTool: ToolDefinition = { + name: 'get_schema', + description: 'Get schema', + inputSchema: { type: 'object', properties: {} }, + async handler() { + return { content: 'message(id, ts)' } + }, + } + const tool: ToolDefinition = { + name: 'render_chart', + description: 'Render a chart', + inputSchema: { type: 'object', properties: {} }, + async handler() { + return { + content: 'Generated chart.', + data: { rowCount: 2 }, + chart, + } + }, + } + + const [getSchema, agentTool] = adaptToolsForAgent([getSchemaTool, tool], () => ({ + db: {} as DatabaseAdapter, + sessionId: 'session-1', + locale: 'en-US', + })) + + assert.ok(agentTool) + await getSchema.execute('call-0', {}) + const result = await agentTool.execute('call-1', {}) + + assert.deepEqual(result.content, [{ type: 'text', text: 'Generated chart.' }]) + assert.deepEqual(result.details, { rowCount: 2, chart }) + }) + + it('keeps rawMessages mirrored in data out of the LLM-facing text', async () => { + const rawMessages = [ + { id: 1, senderName: 'Alice', content: 'hello world', timestamp: 1710000000 }, + { id: 2, senderName: 'Bob', content: 'hi there', timestamp: 1710000060 }, + ] + const tool: ToolDefinition = { + name: 'get_recent_messages', + description: 'Recent messages', + inputSchema: { type: 'object', properties: {} }, + async handler() { + const data = { total: 2, timeRange: '全部时间', rawMessages } + return { content: JSON.stringify(data), data, rawMessages } + }, + } + + const [agentTool] = adaptToolsForAgent([tool], () => ({ + db: {} as DatabaseAdapter, + sessionId: 'session-1', + locale: 'zh-CN', + })) + + const result = await agentTool.execute('call-1', {}) + const text = (result.content[0] as { type: 'text'; text: string }).text + + assert.ok(!text.includes('[object Object]')) + assert.ok(text.includes('total: 2')) + assert.ok(text.includes('hello world')) + assert.ok(text.includes('hi there')) + }) +}) diff --git a/apps/cli/src/ai/tool-adapter.ts b/apps/cli/src/ai/tool-adapter.ts new file mode 100644 index 000000000..c4655b068 --- /dev/null +++ b/apps/cli/src/ai/tool-adapter.ts @@ -0,0 +1,162 @@ +/** + * 工具适配层 + * + * 将 @openchatlab/tools 的 ToolDefinition 适配为 @earendil-works/pi-agent-core 的 AgentTool 格式。 + * 消息类工具返回 rawMessages 时自动执行预处理管道(清洗、去噪、脱敏、截断、格式化)。 + */ + +import type { + ToolDefinition, + ToolExecutionContext, + SemanticSearchToolService, + RawMessage, + ToolTimeRange, +} from '@openchatlab/tools' +import { CoreDataProvider } from '@openchatlab/tools' +import type { DatabaseAdapter } from '@openchatlab/core' +import { + applyPreprocessingPipeline, + batchSegmentWithFrequency, + preprocessMessages, + type AgentTool, + type AgentToolResult, + type PreprocessableMessage, + type PreprocessConfig, + type TruncationStrategy, + createChartSchemaGateState, + wrapWithChartSchemaGate, +} from '@openchatlab/node-runtime' +import { getServerAiLogger } from './logger' + +const DEFAULT_MAX_TOOL_RESULT_TOKENS = 8000 + +const TOOL_TRUNCATION_STRATEGY: Record = { + search_messages: 'keep_first', + deep_search_messages: 'keep_first', + get_recent_messages: 'keep_last', + get_message_context: 'keep_last', + get_segment_messages: 'keep_last', + get_conversation_between: 'keep_last', +} + +export interface ServerToolContext { + db: DatabaseAdapter + sessionId: string + locale?: string + /** 语义检索窄接口(仅当前会话可检索时由 runner 注入) */ + semanticIndexService?: SemanticSearchToolService + /** 预处理配置(脱敏/匿名化/清洗) */ + preprocessConfig?: Record + /** 当前用户平台 id(昵称匿名化 owner 识别) */ + ownerPlatformId?: string + /** 会话时间范围筛选(来自请求参数,供证据类工具继承) */ + timeFilter?: ToolTimeRange + /** 关键词搜索消息条数上限 */ + maxMessagesLimit?: number +} + +function convertJsonSchemaToParameters(schema: ToolDefinition['inputSchema']) { + const properties: Record = {} + for (const [key, prop] of Object.entries(schema.properties)) { + properties[key] = { ...prop } + } + return { + type: 'object' as const, + properties, + required: schema.required || [], + } +} + +export interface AdaptToolsOptions { + maxToolResultTokens?: number +} + +export function adaptToolsForAgent( + tools: ToolDefinition[], + getContext: () => ServerToolContext, + options?: AdaptToolsOptions +): AgentTool[] { + const tokenBudget = options?.maxToolResultTokens ?? DEFAULT_MAX_TOOL_RESULT_TOKENS + const chartSchemaGateState = createChartSchemaGateState() + + return tools.map((tool) => + wrapWithChartSchemaGate( + { + name: tool.name, + label: tool.name, + description: tool.description, + parameters: convertJsonSchemaToParameters(tool.inputSchema) as any, + async execute(_toolCallId: string, params: unknown): Promise> { + const toolParams = (params && typeof params === 'object' ? params : {}) as Record + const ctx = getContext() + const execCtx: ToolExecutionContext = { + db: ctx.db, + dataProvider: new CoreDataProvider(ctx.db), + sessionId: ctx.sessionId, + locale: ctx.locale, + semanticIndexService: ctx.semanticIndexService, + preprocessConfig: ctx.preprocessConfig, + ownerPlatformId: ctx.ownerPlatformId, + timeFilter: ctx.timeFilter, + maxMessagesLimit: ctx.maxMessagesLimit, + maxToolResultTokens: tokenBudget, + segmentText: (texts, locale, options) => batchSegmentWithFrequency(texts, locale as any, options as any), + desensitizeMessages: (messages: RawMessage[]): RawMessage[] => + preprocessMessages( + messages as PreprocessableMessage[], + ctx.preprocessConfig as PreprocessConfig | undefined + ) as RawMessage[], + } + try { + const result = await tool.handler(toolParams, execCtx) + const chartDetails = + result.chart || result.charts + ? { + ...(result.chart ? { chart: result.chart } : {}), + ...(result.charts ? { charts: result.charts } : {}), + } + : {} + + if (result.rawMessages && result.rawMessages.length > 0) { + // tools may mirror rawMessages inside data; keep it out of extraDetails + // so the pipeline only renders scalar metadata (same as the desktop adapter) + const { rawMessages: _rawInData, ...extraDetails } = (result.data ?? {}) as Record + const preprocessCfg = ctx.preprocessConfig as PreprocessConfig | undefined + const pipelineResult = applyPreprocessingPipeline({ + rawMessages: result.rawMessages as PreprocessableMessage[], + preprocessConfig: preprocessCfg, + anonymizeNames: preprocessCfg?.anonymizeNames ?? false, + ownerPlatformId: ctx.ownerPlatformId, + locale: ctx.locale, + maxToolResultTokens: tokenBudget, + truncationStrategy: TOOL_TRUNCATION_STRATEGY[tool.name] ?? 'keep_last', + extraDetails, + logger: getServerAiLogger() ?? undefined, + }) + return { + content: [{ type: 'text', text: pipelineResult.text }], + details: Object.keys(chartDetails).length > 0 ? chartDetails : null, + } + } + + const baseDetails = + typeof result.data === 'object' && result.data !== null + ? (result.data as Record) + : result.data === undefined + ? null + : { value: result.data } + + return { + content: [{ type: 'text', text: result.content }], + details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails, + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null } + } + }, + }, + chartSchemaGateState + ) + ) +} diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts new file mode 100644 index 000000000..2a124ceb4 --- /dev/null +++ b/apps/cli/src/cli.ts @@ -0,0 +1,633 @@ +/** + * ChatLab CLI entry point + * + * Dev: pnpm --filter chatlab-cli run cli -- sessions + */ + +import * as fs from 'fs' +import { execSync } from 'child_process' +import { Command } from 'commander' +import { DEFAULT_API_PORT, loadConfig, getConfigPath } from '@openchatlab/config' +import { + NodePathProvider, + DatabaseManager, + AIChatManager, + applyPendingNodeDataDirMigrationIfNeeded, + hasPendingElectronDataWarning, + verifyCliDataPath, + initAppLogger, + appLogger, + getSystemLogsDir, +} from '@openchatlab/node-runtime' +import { + getSessionMeta, + getSessionOverview, + getMemberActivity, + searchMessagesLike, + getMembers, + executeReadonlySql, +} from '@openchatlab/core' +import { getVersion } from './version' +import { resolveCliPath } from './paths' +import { isPortAvailable, formatPortInUseError } from './http/port' +import { assertCliDataDirCompatible } from './runtime-compat' + +const program = new Command() + +program.name('chatlab').description('ChatLab - Chat history analysis tool').version(getVersion(), '-v, --version') + +program.hook('preAction', async (_thisCommand, actionCommand) => { + if (actionCommand.name() === 'update') return + const { checkForUpdatesInteractive } = await import('./update-checker') + await checkForUpdatesInteractive() +}) + +program + .command('update') + .description('Update ChatLab CLI to the latest version') + .action(async () => { + const { performCliSelfUpdate } = await import('./update-checker') + const result = await performCliSelfUpdate({ + write: (text) => process.stderr.write(text), + }) + + if (result.success) { + console.error(' Updated successfully. Please restart chatlab to use the new version.\n') + return + } + + console.error(` Update failed: ${result.error || 'unknown error'}\n`) + process.exitCode = 1 + }) + +program + .command('sessions') + .description('List all imported chat sessions') + .option('--format ', 'Output format (table|json)', 'table') + .action((options) => { + const { dbManager } = initRuntime() + const sessionIds = dbManager.listSessionIds() + + if (sessionIds.length === 0) { + console.log('No chat sessions found.') + console.log(`Data directory: ${dbManager['pathProvider'].getUserDataDir()}`) + dbManager.closeAll() + return + } + + const sessions = sessionIds + .map((id) => { + const db = dbManager.open(id) + if (!db) return null + const meta = getSessionMeta(db) + if (!meta) return null + const overview = getSessionOverview(db) + return { id, ...meta, ...overview } + }) + .filter(Boolean) + + if (options.format === 'json') { + console.log(JSON.stringify(sessions, null, 2)) + } else { + console.log(`${sessions.length} session(s) found:\n`) + for (const s of sessions) { + if (!s) continue + const timeRange = formatTimeRange(s.firstMessageTs, s.lastMessageTs) + console.log(` ${s.name}`) + console.log(` ID: ${s.id}`) + console.log( + ` Platform: ${s.platform} | Type: ${s.type} | Members: ${s.totalMembers} | Messages: ${s.totalMessages}` + ) + if (timeRange) console.log(` Range: ${timeRange}`) + console.log() + } + } + + dbManager.closeAll() + }) + +program + .command('stats ') + .description('Show session statistics overview') + .option('--format ', 'Output format (table|json)', 'table') + .option('--top ', 'Show top N active members', '10') + .action((sessionId, options) => { + const { dbManager } = initRuntime() + const db = dbManager.open(sessionId) + if (!db) { + console.error(`Session ${sessionId} not found`) + process.exit(1) + } + + const meta = getSessionMeta(db) + const overview = getSessionOverview(db) + const topMembers = getMemberActivity(db).slice(0, parseInt(options.top)) + + if (options.format === 'json') { + console.log(JSON.stringify({ meta, overview, topMembers }, null, 2)) + } else { + console.log(`\nSession: ${meta?.name}`) + console.log(`Platform: ${meta?.platform} | Type: ${meta?.type}`) + console.log(`Total messages: ${overview.totalMessages}`) + console.log(`Total members: ${overview.totalMembers}`) + console.log(`Time range: ${formatTimeRange(overview.firstMessageTs, overview.lastMessageTs)}`) + + if (topMembers.length > 0) { + console.log(`\nActivity ranking (Top ${options.top}):`) + for (const [i, m] of topMembers.entries()) { + console.log(` ${i + 1}. ${m.name} - ${m.messageCount} messages (${m.percentage}%)`) + } + } + } + + dbManager.closeAll() + }) + +program + .command('search ') + .description('Search messages by keyword') + .option('--limit ', 'Max results to return', '20') + .option('--format ', 'Output format (table|json)', 'table') + .action((sessionId, keyword, options) => { + const { dbManager } = initRuntime() + const db = dbManager.open(sessionId) + if (!db) { + console.error(`Session ${sessionId} not found`) + process.exit(1) + } + + const limit = parseInt(options.limit) + const result = searchMessagesLike(db, keyword, { limit }) + + if (options.format === 'json') { + console.log(JSON.stringify(result, null, 2)) + } else { + console.log( + `Search "${keyword}" - ${result.total} result(s)${result.hasMore ? ' (showing first ' + limit + ')' : ''}:\n` + ) + for (const msg of result.messages) { + const time = new Date(msg.timestamp * 1000).toLocaleString() + console.log(` [${time}] ${msg.senderName}: ${msg.content}`) + } + } + + dbManager.closeAll() + }) + +program + .command('members ') + .description('List session members') + .option('--format ', 'Output format (table|json)', 'table') + .action((sessionId, options) => { + const { dbManager } = initRuntime() + const db = dbManager.open(sessionId) + if (!db) { + console.error(`Session ${sessionId} not found`) + process.exit(1) + } + + const members = getMembers(db) + + if (options.format === 'json') { + console.log(JSON.stringify(members, null, 2)) + } else { + console.log(`${members.length} member(s):\n`) + for (const [i, m] of members.entries()) { + console.log(` ${i + 1}. ${m.name} (${m.platformId}) - ${m.messageCount} messages`) + } + } + + dbManager.closeAll() + }) + +program + .command('query ') + .description('Execute a read-only SQL query on a session database') + .requiredOption('--sql ', 'SQL query statement') + .option('--format ', 'Output format (table|json)', 'table') + .action((sessionId, options) => { + const { dbManager } = initRuntime() + const db = dbManager.open(sessionId) + if (!db) { + console.error(`Session ${sessionId} not found`) + process.exit(1) + } + + try { + const result = executeReadonlySql(db, options.sql) + if (options.format === 'json') { + console.log(JSON.stringify(result, null, 2)) + } else { + if (result.rows.length === 0) { + console.log('No results.') + } else { + printTable(result.columns, result.rows) + console.log(`\n${result.rowCount} row(s)${result.truncated ? ' (truncated)' : ''}`) + } + } + } catch (err) { + console.error(`SQL error: ${err instanceof Error ? err.message : err}`) + process.exit(1) + } + + dbManager.closeAll() + }) + +program + .command('import ') + .description('Import a chat history file (14+ formats: QQ/WeChat/Telegram/WhatsApp/LINE/Discord/Instagram, etc.)') + .option('--session-id ', 'Specify session ID (auto-generated if omitted)') + .option('--format ', 'Specify format ID (skip auto-detection)') + .action(async (file, options) => { + if (!fs.existsSync(file)) { + console.error(`File not found: ${file}`) + process.exit(1) + } + + const { streamImport, detectFormat } = await import('./import') + const { dbManager, pathProvider } = initRuntime() + const nativeBinding = resolveNativeBinding() + + const format = detectFormat(file) + if (!format && !options.format) { + console.error(`Unrecognized file format: ${file}`) + console.error('Use --format to specify manually, or run "chatlab formats" to see supported formats') + process.exit(1) + } + + console.log(`Importing: ${file}`) + if (format) console.log(` Format: ${format.name} (${format.platform})`) + + try { + const result = await streamImport(dbManager, file, { + formatId: options.format, + nativeBinding, + onProgress: (p) => { + process.stdout.write(`\r ${p.stage}: ${p.progress}%`) + }, + }) + + if (result.success) { + console.log(`\n\nImport succeeded!`) + console.log(` Session ID: ${result.sessionId}`) + console.log(` Messages written: ${result.diagnostics?.messagesWritten ?? 0}`) + console.log(` Messages skipped: ${result.diagnostics?.messagesSkipped ?? 0}`) + + if (result.sessionId) { + try { + const { PreferencesManager, createDatabaseManagerAdapter, ownerProfileService } = + await import('@openchatlab/node-runtime') + const applied = ownerProfileService.tryApplyOwnerProfile( + createDatabaseManagerAdapter(dbManager), + new PreferencesManager(pathProvider.getSystemDir()), + result.sessionId + ) + if (applied.applied) { + console.log(` Owner auto-detected: ${applied.ownerId}`) + } + } catch (ownerErr) { + console.warn(` Owner profile apply skipped: ${ownerErr instanceof Error ? ownerErr.message : ownerErr}`) + } + } + } else { + console.error(`\n\nImport failed: ${result.error}`) + process.exit(1) + } + } catch (err) { + console.error(`\n\nImport error: ${err instanceof Error ? err.message : err}`) + process.exit(1) + } finally { + dbManager.closeAll() + } + }) + +program + .command('formats') + .description('List all supported chat history formats') + .action(async () => { + const { getSupportedFormats } = await import('./import') + const formats = getSupportedFormats() + console.log(`${formats.length} supported format(s):\n`) + for (const f of formats) { + console.log(` ${f.id.padEnd(30)} ${f.name} (${f.platform}) [${f.extensions.join(', ')}]`) + } + }) + +program + .command('mcp') + .description('Start MCP Server (stdio transport, for ClaudeCode / Cursor / AI agents)') + .action(async () => { + const { startCliMcpServer } = await import('./mcp') + await startCliMcpServer() + }) + +program + .command('chat') + .description('Ask ChatLab AI about an imported chat session') + .option('--session-id ', 'Source chat session ID') + .option('--ai-chat-id ', 'Existing AI chat ID to continue') + .option('-q, --question ', 'Question to ask') + .option('--json', 'Output structured JSON') + .option('--include-events', 'Include all agent stream chunks in JSON output') + .option('--no-stream', 'Disable streaming output') + .option('--locale ', 'AI response locale', 'zh-CN') + .action(async (options) => { + const { runChatCommand } = await import('./ai/chat-command') + const { dbManager, pathProvider } = initRuntime() + const aiChatManager = new AIChatManager(pathProvider.getAiDataDir(), { nativeBinding: resolveNativeBinding() }) + + try { + await runChatCommand( + { + sessionId: options.sessionId, + aiChatId: options.aiChatId, + question: options.question, + json: !!options.json, + stream: options.stream, + locale: options.locale, + includeEvents: !!options.includeEvents, + }, + { dbManager, pathProvider, aiChatManager } + ) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + } finally { + aiChatManager.close() + dbManager.closeAll() + } + }) + +program + .command('start') + .description('Start ChatLab (HTTP API + Web UI)') + .option('--port ', 'Server port', String(DEFAULT_API_PORT)) + .option('--host ', 'Listen address', '127.0.0.1') + .option('--token ', 'Custom Bearer Token (reads from config or auto-generates if omitted)') + .option('--headless', 'API-only mode, do not serve the Web UI') + .option('--require-auth', 'Require Bearer token for all routes including /_web/*') + .option('--no-open', 'Do not auto-open the browser') + .option('--daemon', 'Run as a resident system service (auto-start on login, macOS/Linux)') + .action(async (options) => { + // --daemon: install as system service and exit + if (options.daemon) { + const { serviceInstall } = await import('./daemon/service') + serviceInstall({ + port: parseInt(options.port, 10), + host: options.host, + token: options.token || undefined, + headless: options.headless, + requireAuth: options.requireAuth || undefined, + }) + return + } + + const { startHttpServer } = await import('./http') + const port = parseInt(options.port, 10) + + let webRoot: string | undefined + if (!options.headless) { + const webDir = resolveCliPath('dist-web') + if (fs.existsSync(webDir)) { + webRoot = webDir + } else { + console.warn('Warning: dist-web/ not found, starting in API-only mode') + } + } + + try { + // 启动前预检端口,快速失败,避免无谓的初始化后再报 EADDRINUSE; + // 置于 try 内确保非 EADDRINUSE 错误(EACCES/EADDRNOTAVAIL 等) + // 也能走到统一的 Startup failed 错误处理路径。 + if (!(await isPortAvailable(port, options.host))) { + console.error(formatPortInUseError(port)) + process.exit(1) + } + + const info = await startHttpServer({ + port, + host: options.host, + token: options.token || undefined, + webRoot, + requireAuth: options.requireAuth || undefined, + }) + + const { startPeriodicUpdateCheck } = await import('./update-checker') + startPeriodicUpdateCheck() + + const displayHost = info.host === '0.0.0.0' ? '127.0.0.1' : info.host + const url = `http://${displayHost}:${info.port}` + + console.log(`\nChatLab v${getVersion()}`) + if (webRoot) console.log(` Web UI: ${url}/`) + console.log(` API: ${url}`) + console.log(` Token: ${info.token}`) + console.log(`\nExample:`) + console.log(` curl -H "Authorization: Bearer ${info.token}" ${url}/api/v1/status`) + + if (webRoot && options.open) { + openBrowser(url) + console.log(`\nBrowser opened. Press Ctrl+C to stop.\n`) + } else { + console.log(`\nPress Ctrl+C to stop.\n`) + } + + const shutdown = async () => { + console.log('\nShutting down...') + const { stopHttpServer } = await import('./http') + await stopHttpServer() + process.exit(0) + } + + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes('EADDRINUSE')) { + // 极低概率的 TOCTOU 竞态(预检通过后端口被占),复用统一文案 + console.error(formatPortInUseError(port)) + } else { + console.error(`Startup failed: ${message}`) + } + process.exit(1) + } + }) + +const configCmd = program.command('config').description('Configuration management') + +configCmd + .command('path') + .description('Show config file path') + .action(() => { + console.log(getConfigPath()) + }) + +configCmd + .command('show') + .description('Show current configuration') + .action(() => { + const config = loadConfig() + console.log(JSON.stringify(config, null, 2)) + }) + +program + .command('stop') + .description('Stop the resident service and remove auto-start (reverse of start --daemon)') + .action(async () => { + const { serviceUninstall } = await import('./daemon/service') + serviceUninstall() + }) + +program + .command('status') + .description('Show resident service status') + .action(async () => { + const { getServiceStatus } = await import('./daemon/service') + const svc = getServiceStatus() + + console.log('\nChatLab Status') + console.log('─'.repeat(36)) + console.log(` Logs: ${getSystemLogsDir()}`) + + if (svc.installed) { + const portStr = svc.port ? `http://${svc.host ?? '127.0.0.1'}:${svc.port}` : '' + console.log(` Service: ${svc.running ? 'running' : 'installed (not running)'}`) + if (portStr) console.log(` Address: ${portStr}`) + console.log(` Auto-start: enabled`) + console.log(`\n Use \`chatlab stop\` to remove the service.\n`) + } else { + console.log(` Service: not installed`) + console.log(` Auto-start: disabled`) + console.log(`\n Use \`chatlab start --daemon\` to install as a system service.\n`) + } + }) + +// --- 工具函数 --- + +function openBrowser(url: string): void { + try { + const cmd = + process.platform === 'darwin' + ? `open "${url}"` + : process.platform === 'win32' + ? `start "" "${url}"` + : `xdg-open "${url}"` + execSync(cmd, { stdio: 'ignore' }) + } catch { + console.log(` Open manually: ${url}`) + } +} + +/** + * Resolve standalone better-sqlite3 native module path. + * Used in non-Electron environments to avoid electron-rebuild conflicts. + */ +function resolveNativeBinding(): string | undefined { + if (process.versions.electron) return undefined + const nativePath = resolveCliPath('native/better_sqlite3.node') + if (fs.existsSync(nativePath)) return nativePath + return undefined +} + +function initRuntime() { + let config = loadConfig() + const pendingMigration = applyPendingNodeDataDirMigrationIfNeeded() + if (!pendingMigration.skipped) { + if (pendingMigration.success) { + console.log('[Migration] Pending data directory migration completed') + config = loadConfig() + } else { + console.error('[Migration] Pending data directory migration failed:', pendingMigration.error) + } + } + const userDataDir = config.data.user_data_dir || undefined + const pathProvider = new NodePathProvider(userDataDir) + pathProvider.ensureAllDirs() + const runtime = assertCliDataDirCompatible(pathProvider, 'cli') + + if (hasPendingElectronDataWarning() || !verifyCliDataPath(pathProvider.getDatabaseDir())) { + printElectronDataError() + process.exit(1) + } + + const nativeBinding = resolveNativeBinding() + const dbManager = new DatabaseManager(pathProvider, { nativeBinding, runtime }) + return { config, pathProvider, dbManager } +} + +function printElectronDataError(): void { + console.error('\n' + '='.repeat(68)) + console.error(' ChatLab: Electron desktop data not found') + console.error('='.repeat(68)) + console.error('') + console.error(' Detected that ChatLab desktop app was installed on this machine,') + console.error(' but could not locate your chat databases.') + console.error('') + console.error(' This usually means you changed the data directory in desktop settings.') + console.error('') + console.error(' To fix this, choose one of:') + console.error('') + console.error(' 1. Open ChatLab desktop app — it will auto-migrate your data') + console.error(' 2. Set the data directory manually:') + console.error(' export CHATLAB_DATA_DIR="/path/to/your/data"') + console.error(' 3. Edit ~/.chatlab/config.toml:') + console.error(' [data]') + console.error(' user_data_dir = "/path/to/your/data"') + console.error('') + console.error('='.repeat(68) + '\n') +} + +function formatTimeRange(first: number | null, last: number | null): string { + if (!first || !last) return 'Unknown' + const from = new Date(first * 1000).toLocaleDateString() + const to = new Date(last * 1000).toLocaleDateString() + return `${from} ~ ${to}` +} + +function printTable(columns: string[], rows: Record[]): void { + const widths = columns.map((col) => { + const maxData = rows.reduce((max, row) => { + const val = String(row[col] ?? '') + return Math.max(max, val.length) + }, 0) + return Math.max(col.length, Math.min(maxData, 40)) + }) + + const header = columns.map((col, i) => col.padEnd(widths[i])).join(' | ') + const separator = widths.map((w) => '-'.repeat(w)).join('-+-') + console.log(header) + console.log(separator) + + for (const row of rows) { + const line = columns + .map((col, i) => { + const val = String(row[col] ?? '') + return val.length > 40 ? val.slice(0, 37) + '...' : val.padEnd(widths[i]) + }) + .join(' | ') + console.log(line) + } +} + +/** CLI entry function */ +export function run(argv?: string[]): void { + // Logs go to ~/.chatlab/logs/app.log regardless of configured user data dir. + initAppLogger(getSystemLogsDir()) + process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error) + appLogger.error('crash', 'uncaughtException', error) + process.exit(1) + }) + process.on('unhandledRejection', (reason) => { + console.error('Unhandled Rejection:', reason) + appLogger.error('crash', 'unhandledRejection', reason) + process.exit(1) + }) + program.parse(argv) +} + +// Auto-execute when run directly as a script +const isDirectRun = process.argv[1]?.endsWith('cli.ts') || process.argv[1]?.endsWith('cli.js') +if (isDirectRun) { + run() +} diff --git a/apps/cli/src/daemon/service.ts b/apps/cli/src/daemon/service.ts new file mode 100644 index 000000000..e31eca361 --- /dev/null +++ b/apps/cli/src/daemon/service.ts @@ -0,0 +1,311 @@ +/** + * System service management — register ChatLab as a launchd (macOS) or + * systemd --user (Linux) service for auto-start on login and crash recovery. + * + * macOS : ~/Library/LaunchAgents/fun.chatlab.daemon.plist + * Linux : ~/.config/systemd/user/chatlab.service + * Meta : ~/.chatlab/daemon.json { port, host, installedAt } + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import { execSync } from 'child_process' +import { resolveCliPath } from '../paths' + +const SERVICE_LABEL = 'fun.chatlab.daemon' +const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`) +const SYSTEMD_DIR = path.join(os.homedir(), '.config', 'systemd', 'user') +const SYSTEMD_SERVICE = path.join(SYSTEMD_DIR, 'chatlab.service') +const SYSTEM_DIR = path.join(os.homedir(), '.chatlab') +const LOG_FILE = path.join(SYSTEM_DIR, 'logs', 'daemon.log') +const META_FILE = path.join(SYSTEM_DIR, 'daemon.json') + +interface DaemonMeta { + port: number + host: string + installedAt: string +} + +function writeMeta(meta: DaemonMeta): void { + fs.mkdirSync(SYSTEM_DIR, { recursive: true }) + fs.writeFileSync(META_FILE, JSON.stringify(meta, null, 2)) +} + +function readMeta(): DaemonMeta | null { + try { + if (!fs.existsSync(META_FILE)) return null + return JSON.parse(fs.readFileSync(META_FILE, 'utf-8')) as DaemonMeta + } catch { + return null + } +} + +function removeMeta(): void { + try { + fs.unlinkSync(META_FILE) + } catch { + // already removed, ignore + } +} + +// ── Public types ────────────────────────────────────────────────────── + +export interface ServiceInstallOptions { + port?: number + host?: string + token?: string + headless?: boolean + requireAuth?: boolean +} + +export interface ServiceStatus { + installed: boolean + running: boolean + port?: number + host?: string +} + +// ── macOS (launchd) ─────────────────────────────────────────────────── + +function buildPlist(options: ServiceInstallOptions): string { + const port = options.port ?? 3110 + const host = options.host ?? '127.0.0.1' + const cliEntry = resolveCliPath('bin/chatlab.mjs') + const args = ['start', '--no-open', '--port', String(port), '--host', host] + if (options.token) args.push('--token', options.token) + if (options.headless) args.push('--headless') + if (options.requireAuth) args.push('--require-auth') + + const programArgs = [process.execPath, cliEntry, ...args].map((a) => ` ${a}`).join('\n') + + fs.mkdirSync(path.join(SYSTEM_DIR, 'logs'), { recursive: true }) + + return ` + + + + Label + ${SERVICE_LABEL} + ProgramArguments + +${programArgs} + + RunAtLoad + + KeepAlive + + StandardOutPath + ${LOG_FILE} + StandardErrorPath + ${LOG_FILE} + + +` +} + +function getUid(): string { + return execSync('id -u', { encoding: 'utf-8' }).trim() +} + +function launchctlLoad(plistPath: string): void { + try { + execSync(`launchctl bootstrap gui/${getUid()} "${plistPath}"`, { stdio: 'pipe' }) + } catch { + try { + execSync(`launchctl load "${plistPath}"`, { stdio: 'pipe' }) + } catch (err) { + throw new Error(`launchctl load failed: ${err instanceof Error ? err.message : err}`) + } + } +} + +function launchctlUnload(plistPath: string): void { + try { + execSync(`launchctl bootout gui/${getUid()}/${SERVICE_LABEL}`, { stdio: 'pipe' }) + } catch { + try { + execSync(`launchctl unload "${plistPath}"`, { stdio: 'pipe' }) + } catch { + // already unloaded, ignore + } + } +} + +function installMacos(options: ServiceInstallOptions): void { + fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true }) + + if (fs.existsSync(PLIST_PATH)) { + launchctlUnload(PLIST_PATH) + } + + fs.writeFileSync(PLIST_PATH, buildPlist(options)) + launchctlLoad(PLIST_PATH) + + const port = options.port ?? 3110 + const host = options.host ?? '127.0.0.1' + writeMeta({ port, host, installedAt: new Date().toISOString() }) + + console.log(`\nChatLab is now running as a system service`) + console.log(` API: http://${host}:${port}`) + console.log(` Logs: ${LOG_FILE}`) + console.log(` Service: ${SERVICE_LABEL} (launchd)`) + console.log(`\nAuto-starts on login. Use \`chatlab stop\` to remove.\n`) +} + +function uninstallMacos(): void { + if (!fs.existsSync(PLIST_PATH)) { + console.log('No system service found.') + removeMeta() + return + } + launchctlUnload(PLIST_PATH) + fs.unlinkSync(PLIST_PATH) + removeMeta() + console.log(`ChatLab system service removed.`) +} + +function statusMacos(): ServiceStatus { + if (!fs.existsSync(PLIST_PATH)) return { installed: false, running: false } + let running = false + try { + const out = execSync(`launchctl list | grep ${SERVICE_LABEL}`, { + encoding: 'utf-8', + stdio: 'pipe', + }).trim() + // launchctl list: PID Status Label — PID is '-' when not running + running = out.length > 0 && !out.startsWith('-') + } catch { + running = false + } + const meta = readMeta() + return { installed: true, running, port: meta?.port, host: meta?.host } +} + +// ── Linux (systemd --user) ──────────────────────────────────────────── + +function escapeSystemdArg(arg: string): string { + return '"' + arg.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"' +} + +function buildUnit(options: ServiceInstallOptions): string { + const port = options.port ?? 3110 + const host = options.host ?? '127.0.0.1' + const cliEntry = resolveCliPath('bin/chatlab.mjs') + const args = ['start', '--no-open', '--port', String(port), '--host', host] + if (options.token) args.push('--token', options.token) + if (options.headless) args.push('--headless') + if (options.requireAuth) args.push('--require-auth') + const execStart = [process.execPath, cliEntry, ...args].map(escapeSystemdArg).join(' ') + + return `[Unit] +Description=ChatLab daemon +After=network.target + +[Service] +ExecStart=${execStart} +Restart=always +RestartSec=5 +StandardOutput=append:${LOG_FILE} +StandardError=append:${LOG_FILE} + +[Install] +WantedBy=default.target +` +} + +function installLinux(options: ServiceInstallOptions): void { + fs.mkdirSync(SYSTEMD_DIR, { recursive: true }) + fs.mkdirSync(path.join(SYSTEM_DIR, 'logs'), { recursive: true }) + + fs.writeFileSync(SYSTEMD_SERVICE, buildUnit(options)) + + try { + execSync('systemctl --user daemon-reload', { stdio: 'pipe' }) + execSync('systemctl --user enable --now chatlab.service', { stdio: 'pipe' }) + } catch (err) { + throw new Error(`systemctl failed: ${err instanceof Error ? err.message : err}`) + } + + const port = options.port ?? 3110 + const host = options.host ?? '127.0.0.1' + writeMeta({ port, host, installedAt: new Date().toISOString() }) + + console.log(`\nChatLab is now running as a system service`) + console.log(` API: http://${host}:${port}`) + console.log(` Logs: ${LOG_FILE}`) + console.log(` Unit: ${SYSTEMD_SERVICE} (systemd)`) + console.log(`\nAuto-starts on login. Use \`chatlab stop\` to remove.\n`) +} + +function uninstallLinux(): void { + if (!fs.existsSync(SYSTEMD_SERVICE)) { + console.log('No system service found.') + removeMeta() + return + } + try { + execSync('systemctl --user disable --now chatlab.service', { stdio: 'pipe' }) + } catch { + // already stopped + } + try { + execSync('systemctl --user daemon-reload', { stdio: 'pipe' }) + } catch { + // ignore + } + fs.unlinkSync(SYSTEMD_SERVICE) + removeMeta() + console.log(`ChatLab system service removed.`) +} + +function statusLinux(): ServiceStatus { + if (!fs.existsSync(SYSTEMD_SERVICE)) return { installed: false, running: false } + let running = false + try { + execSync('systemctl --user is-active --quiet chatlab.service', { stdio: 'pipe' }) + running = true + } catch { + running = false + } + const meta = readMeta() + return { installed: true, running, port: meta?.port, host: meta?.host } +} + +// ── Public API ──────────────────────────────────────────────────────── + +export function serviceInstall(options: ServiceInstallOptions): void { + switch (process.platform) { + case 'darwin': + return installMacos(options) + case 'linux': + return installLinux(options) + default: + console.error('Windows is not yet supported for daemon mode.') + console.error('Please use `chatlab start` to run in the foreground instead.') + process.exit(1) + } +} + +export function serviceUninstall(): void { + switch (process.platform) { + case 'darwin': + return uninstallMacos() + case 'linux': + return uninstallLinux() + default: + console.error('Windows is not yet supported for daemon mode.') + process.exit(1) + } +} + +export function getServiceStatus(): ServiceStatus { + switch (process.platform) { + case 'darwin': + return statusMacos() + case 'linux': + return statusLinux() + default: + return { installed: false, running: false } + } +} diff --git a/apps/cli/src/http/auth.test.ts b/apps/cli/src/http/auth.test.ts new file mode 100644 index 000000000..981a5cc8c --- /dev/null +++ b/apps/cli/src/http/auth.test.ts @@ -0,0 +1,139 @@ +import { describe, it, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { setAuthToken, setRequireAuth, authHook } from '@openchatlab/http-routes' + +function fakeRequest(url: string, authorization?: string) { + return { url, headers: { authorization } } as never +} + +function fakeReply() { + let sentCode = 0 + let sentBody: unknown = null + return { + code(c: number) { + sentCode = c + return this + }, + send(body: unknown) { + sentBody = body + }, + get statusCode() { + return sentCode + }, + get body() { + return sentBody + }, + } +} + +const VALID_TOKEN = 'clb_test_token_12345' + +describe('authHook — authentication matrix', () => { + beforeEach(() => { + setAuthToken(VALID_TOKEN) + setRequireAuth(false) + }) + + // ── No token configured: all routes open ── + + it('passes all routes when no token is configured', async () => { + setAuthToken('' as never) + // Hack: reset internal state — setAuthToken('') won't set null, use the real function + // Actually setAuthToken sets cachedToken to the string value. Empty string is falsy → all pass. + const reply = fakeReply() + await authHook(fakeRequest('/api/v1/status'), reply as never) + assert.equal(reply.statusCode, 0, '/api/* should pass without token configured') + }) + + // ── /api/* always requires auth ── + + it('rejects /api/* without Bearer header', async () => { + const reply = fakeReply() + await authHook(fakeRequest('/api/v1/status'), reply as never) + assert.equal(reply.statusCode, 401) + }) + + it('rejects /api/* with wrong token', async () => { + const reply = fakeReply() + await authHook(fakeRequest('/api/v1/status', 'Bearer wrong_token'), reply as never) + assert.equal(reply.statusCode, 401) + }) + + it('allows /api/* with correct token', async () => { + const reply = fakeReply() + await authHook(fakeRequest('/api/v1/status', `Bearer ${VALID_TOKEN}`), reply as never) + assert.equal(reply.statusCode, 0, 'should not send any error') + }) + + // ── /_web/* default (requireAuth=false): bypass ── + + it('allows /_web/* without auth when requireAuth=false', async () => { + const reply = fakeReply() + await authHook(fakeRequest('/_web/sessions'), reply as never) + assert.equal(reply.statusCode, 0) + }) + + // ── /_web/* with requireAuth=true: requires token ── + + it('rejects /_web/* without Bearer when requireAuth=true', async () => { + setRequireAuth(true) + const reply = fakeReply() + await authHook(fakeRequest('/_web/sessions'), reply as never) + assert.equal(reply.statusCode, 401) + }) + + it('rejects /_web/* with wrong token when requireAuth=true', async () => { + setRequireAuth(true) + const reply = fakeReply() + await authHook(fakeRequest('/_web/sessions', 'Bearer bad'), reply as never) + assert.equal(reply.statusCode, 401) + }) + + it('allows /_web/* with correct token when requireAuth=true', async () => { + setRequireAuth(true) + const reply = fakeReply() + await authHook(fakeRequest('/_web/sessions', `Bearer ${VALID_TOKEN}`), reply as never) + assert.equal(reply.statusCode, 0) + }) + + // ── Static files / SPA: always public ── + + it('allows static file paths without auth', async () => { + const reply = fakeReply() + await authHook(fakeRequest('/index.html'), reply as never) + assert.equal(reply.statusCode, 0) + }) + + it('allows static file paths without auth even with requireAuth=true', async () => { + setRequireAuth(true) + const reply = fakeReply() + await authHook(fakeRequest('/assets/main.js'), reply as never) + assert.equal(reply.statusCode, 0) + }) + + // ── Combined: webRoot + requireAuth (the P0 scenario) ── + + it('P0 regression: /_web/* is protected when both webRoot and requireAuth are active', async () => { + setRequireAuth(true) + const reply = fakeReply() + await authHook(fakeRequest('/_web/ai/llm/providers'), reply as never) + assert.equal(reply.statusCode, 401, '/_web/* must NOT bypass auth when requireAuth=true') + }) + + it('P0 regression: /api/* remains protected regardless of requireAuth flag', async () => { + setRequireAuth(false) + const reply = fakeReply() + await authHook(fakeRequest('/api/v1/status'), reply as never) + assert.equal(reply.statusCode, 401, '/api/* must always require auth') + }) + + // ── setRequireAuth reset ── + + it('setRequireAuth(false) properly resets protection on /_web/*', async () => { + setRequireAuth(true) + setRequireAuth(false) + const reply = fakeReply() + await authHook(fakeRequest('/_web/sessions'), reply as never) + assert.equal(reply.statusCode, 0, '/_web/* should be open after reset') + }) +}) diff --git a/apps/cli/src/http/index.ts b/apps/cli/src/http/index.ts new file mode 100644 index 000000000..248a2a017 --- /dev/null +++ b/apps/cli/src/http/index.ts @@ -0,0 +1,272 @@ +/** + * ChatLab HTTP API — Server lifecycle manager + * + * 独立于 Electron 的 HTTP API 服务入口。 + * 使用 DatabaseManager + @openchatlab/core 直接访问数据。 + */ + +import * as fs from 'fs' +import * as crypto from 'crypto' +import type { FastifyInstance } from 'fastify' +import { loadConfig, writeConfigField, MigrationRunner, ALL_MIGRATIONS } from '@openchatlab/config' +import type { ChatLabConfig } from '@openchatlab/config' +import { + NodePathProvider, + DatabaseManager, + AIChatManager, + LLMConfigStore, + CustomProviderStore, + CustomModelStore, + applyPendingNodeDataDirMigrationIfNeeded, + hasPendingElectronDataWarning, + verifyCliDataPath, + createSemanticIndexWorkerRuntimeClient, + initAppLogger, + appLogger, +} from '@openchatlab/node-runtime' +import type { ConfigStorage, SemanticIndexRuntime } from '@openchatlab/node-runtime' +import { createServer } from './server' +import { setAuthToken, setRequireAuth } from '@openchatlab/http-routes' +import { registerWebRoutes } from './routes/web' +import { registerProxyRoutes } from './routes/proxy' +import { initServerAiLogger, closeServerAiLogger } from '../ai/logger' +import { getAssistantManager, getSkillManagerCore } from '../ai/manager-factory' +import { createCliRunAgentStream } from '../ai/agent-stream-runner' +import { initSync, cleanupSync } from '../sync' +import { resolveCliPath } from '../paths' +import { resolveApiKey, writeAuthProfile, deleteAuthProfile } from '@openchatlab/config' +import { assertCliDataDirCompatible } from '../runtime-compat' + +let server: FastifyInstance | null = null +let dbManager: DatabaseManager | null = null +let aiChatManager: AIChatManager | null = null + +function createFileConfigStorage(aiDataDir: string): ConfigStorage { + return { + readJson(key: string): T | null { + try { + return JSON.parse(fs.readFileSync(`${aiDataDir}/${key}.json`, 'utf-8')) as T + } catch { + return null + } + }, + writeJson(key: string, data: T): void { + if (!fs.existsSync(aiDataDir)) fs.mkdirSync(aiDataDir, { recursive: true }) + fs.writeFileSync(`${aiDataDir}/${key}.json`, JSON.stringify(data, null, 2), 'utf-8') + }, + } +} + +export interface HttpServerOptions { + port?: number + host?: string + token?: string + /** dist-web/ 目录路径,启用后托管 Web SPA 静态资源 */ + webRoot?: string + /** When true, /_web/* also requires Bearer token (for server/headless deployments) */ + requireAuth?: boolean +} + +function resolveNativeBinding(): string | undefined { + if (process.versions.electron) return undefined + const nativePath = resolveCliPath('native/better_sqlite3.node') + if (fs.existsSync(nativePath)) return nativePath + return undefined +} + +function ensureToken(config: ChatLabConfig): string { + if (config.api.token) return config.api.token + + const token = `clb_${crypto.randomBytes(32).toString('hex')}` + try { + writeConfigField('api', 'token', token) + } catch { + // best-effort: token still usable for this session + } + return token +} + +/** + * 启动独立 HTTP API 服务 + */ +export async function startHttpServer(options?: HttpServerOptions): Promise<{ + port: number + host: string + token: string +}> { + if (server) { + throw new Error('HTTP server is already running') + } + + let config = loadConfig() + const port = options?.port ?? config.api.port + const host = options?.host ?? config.api.host + const token = options?.token ?? ensureToken(config) + + const pendingMigration = applyPendingNodeDataDirMigrationIfNeeded() + if (!pendingMigration.skipped) { + if (pendingMigration.success) { + console.log('[Migration] Pending data directory migration completed') + config = loadConfig() + } else { + console.error('[Migration] Pending data directory migration failed:', pendingMigration.error) + } + } + + const userDataDir = config.data.user_data_dir || undefined + const pathProvider = new NodePathProvider(userDataDir) + pathProvider.ensureAllDirs() + const runtime = assertCliDataDirCompatible(pathProvider, 'cli') + + if (hasPendingElectronDataWarning() || !verifyCliDataPath(pathProvider.getDatabaseDir())) { + console.error( + '\n' + + '='.repeat(68) + + '\n' + + ' ChatLab: Electron desktop data not found\n' + + '='.repeat(68) + + '\n\n' + + ' Detected that ChatLab desktop app was installed on this machine,\n' + + ' but could not locate your chat databases.\n\n' + + ' This usually means you changed the data directory in desktop settings.\n\n' + + ' To fix this, choose one of:\n\n' + + ' 1. Open ChatLab desktop app — it will auto-migrate your data\n' + + ' 2. Set the data directory manually:\n' + + ' export CHATLAB_DATA_DIR="/path/to/your/data"\n' + + ' 3. Edit ~/.chatlab/config.toml:\n' + + ' [data]\n' + + ' user_data_dir = "/path/to/your/data"\n\n' + + '='.repeat(68) + + '\n' + ) + } + + const migrationRunner = new MigrationRunner(ALL_MIGRATIONS, { + dataDir: pathProvider.getSystemDir(), + aiDataDir: pathProvider.getAiDataDir(), + logger: { + info: (_cat: string, msg: string) => console.log(`[Migration] ${msg}`), + warn: (_cat: string, msg: string) => console.warn(`[Migration] ${msg}`), + error: (_cat: string, msg: string, ...args: unknown[]) => console.error(`[Migration] ${msg}`, ...args), + }, + }) + await migrationRunner.run() + const nativeBinding = resolveNativeBinding() + dbManager = new DatabaseManager(pathProvider, { nativeBinding, runtime }) + + const aiDataDir = pathProvider.getAiDataDir() + aiChatManager = new AIChatManager(aiDataDir, { nativeBinding }) + + const assistantManager = getAssistantManager(aiDataDir) + const skillManagerCore = getSkillManagerCore(aiDataDir) + const llmConfigStore = new LLMConfigStore(createFileConfigStorage(aiDataDir), { + resolveApiKey: (provider, authProfile) => resolveApiKey(provider, authProfile) || undefined, + onApiKeyCreated: (config, apiKey) => { + const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider + writeAuthProfile(profileName, { type: 'api_key', provider: config.provider, key: apiKey }) + return profileName + }, + onApiKeyDeleted: (config) => { + const profileName = (config as unknown as Record).authProfile as string | undefined + if (profileName) deleteAuthProfile(profileName) + }, + }) + + initAppLogger(pathProvider.getLogsDir()) + initServerAiLogger(pathProvider.getLogsDir()) + appLogger.info('server', `HTTP server starting on ${host}:${port}`) + + setAuthToken(token) + setRequireAuth(!!(options?.requireAuth ?? config.api.require_auth)) + + server = createServer() + + const multipart = await import('@fastify/multipart') + await server.register(multipart.default, { + limits: { fileSize: 1024 * 1024 * 1024 }, // 1GB + }) + + // 语义索引 worker client:启动 HTTP 服务时不拉起 worker;状态/构建/检索按需 lazy start。 + let semanticIndexService: SemanticIndexRuntime | undefined + try { + semanticIndexService = createSemanticIndexWorkerRuntimeClient({ + pathProvider, + runtime, + nativeBinding, + workerEntryUrl: import.meta.url.endsWith('.ts') + ? undefined + : new URL('./semantic-index-worker.mjs', import.meta.url), + }) + server.addHook('onClose', async () => semanticIndexService?.close()) + } catch (err) { + console.warn('[semantic-index] worker client unavailable:', err instanceof Error ? err.message : String(err)) + semanticIndexService = undefined + } + + registerWebRoutes(server, dbManager, { + pathProvider, + nativeBinding, + runtimeIdentity: runtime, + semanticIndexService, + aiContext: { + aiDataDir, + aiChatManager, + assistantManager, + skillManagerCore, + llmConfigStore, + customProviderStore: new CustomProviderStore(createFileConfigStorage(aiDataDir)), + customModelStore: new CustomModelStore(createFileConfigStorage(aiDataDir)), + runAgentStream: createCliRunAgentStream(dbManager, aiChatManager, semanticIndexService), + }, + }) + + initSync(server, dbManager, pathProvider, { port, host, token }) + + if (options?.webRoot && fs.existsSync(options.webRoot)) { + // 注册反向代理:将 /_proxy/chatlab.fun/* 转发至 https://chatlab.fun, + // 行为与 vite dev proxy 一致,解决浏览器 CORS 问题(见 vite.web.config.mts:138-144)。 + // 必须在 @fastify/static 之前注册,确保显式路由优先于静态文件/SPA fallback。 + registerProxyRoutes(server) + const fastifyStatic = await import('@fastify/static') + await server.register(fastifyStatic.default, { + root: options.webRoot, + prefix: '/', + wildcard: false, + }) + // SPA fallback: 所有非 API/非静态文件路由返回 index.html + server.setNotFoundHandler(async (_request, reply) => { + return reply.sendFile('index.html') + }) + } + + await server.listen({ port, host }) + + return { port, host, token } +} + +/** + * 停止 HTTP API 服务 + */ +export async function stopHttpServer(): Promise { + if (!server) return + + try { + await server.close() + } finally { + cleanupSync() + if (aiChatManager) { + aiChatManager.close() + aiChatManager = null + } + if (dbManager) { + dbManager.closeAll() + dbManager = null + } + closeServerAiLogger() + setRequireAuth(false) + server = null + } +} + +export { createServer } from './server' +export { registerWebRoutes } from './routes/web' diff --git a/apps/cli/src/http/port.ts b/apps/cli/src/http/port.ts new file mode 100644 index 000000000..cc5420fca --- /dev/null +++ b/apps/cli/src/http/port.ts @@ -0,0 +1,39 @@ +/** + * 端口可用性检测与错误文案 + */ + +import * as net from 'net' + +/** + * 检测指定端口在给定 host 上是否可用。 + * 使用 net.createServer() 试探性绑定,无副作用(成功后立即释放)。 + */ +export function isPortAvailable(port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + resolve(false) + } else { + reject(err) + } + }) + server.once('listening', () => server.close(() => resolve(true))) + server.listen(port, host) + }) +} + +/** + * 返回端口被占用时统一的友好报错文案。 + */ +export function formatPortInUseError(port: number): string { + return [ + ``, + ` ✖ Error: port ${port} is already in use.`, + ``, + ` Another process (possibly a ChatLab instance) is using this port. You can:`, + ` • Use another port: chatlab start --port `, + ` • Find the process: lsof -iTCP:${port} -sTCP:LISTEN`, + ``, + ].join('\n') +} diff --git a/apps/cli/src/http/routes/ai-config-display.test.ts b/apps/cli/src/http/routes/ai-config-display.test.ts new file mode 100644 index 000000000..ae8affd98 --- /dev/null +++ b/apps/cli/src/http/routes/ai-config-display.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { toLlmConfigDisplay } from './ai-config-display' + +describe('toLlmConfigDisplay', () => { + it('marks apiKeySet when provider fallback auth resolves a key', () => { + const result = toLlmConfigDisplay( + { + id: 'cfg-1', + name: 'OpenAI compatible', + provider: 'openai-compatible', + apiKey: '', + }, + (provider, authProfile) => (provider === 'openai-compatible' && !authProfile ? 'sk-provider-fallback' : '') + ) + + assert.equal(result.apiKey, '') + assert.equal(result.apiKeySet, true) + }) + + it('does not mark the local no-key placeholder as a real API key', () => { + const result = toLlmConfigDisplay( + { + id: 'cfg-2', + name: 'LAN Ollama', + provider: 'openai-compatible', + apiKey: '', + baseUrl: 'http://127.0.0.1:11434', + authProfile: 'lan-ollama', + }, + () => 'sk-no-key-required' + ) + + assert.equal(result.apiKey, '') + assert.equal(result.apiKeySet, false) + }) + + it('does not mark legacy plaintext apiKey as reusable', () => { + const result = toLlmConfigDisplay( + { + id: 'cfg-3', + name: 'Legacy OpenAI', + provider: 'openai', + apiKey: 'sk-legacy-plaintext', + }, + () => '' + ) + + assert.equal(result.apiKey, '') + assert.equal(result.apiKeySet, false) + }) +}) diff --git a/apps/cli/src/http/routes/ai-config-display.ts b/apps/cli/src/http/routes/ai-config-display.ts new file mode 100644 index 000000000..2399b63ce --- /dev/null +++ b/apps/cli/src/http/routes/ai-config-display.ts @@ -0,0 +1,21 @@ +type LlmConfigRecord = Record + +type ResolveApiKey = (provider: string, authProfile?: string) => string | undefined +const NO_KEY_PLACEHOLDER = 'sk-no-key-required' + +function hasRealApiKey(apiKey: string): boolean { + return !!apiKey && apiKey !== NO_KEY_PLACEHOLDER +} + +export function toLlmConfigDisplay(config: LlmConfigRecord, resolveApiKey: ResolveApiKey): LlmConfigRecord { + const { apiKey: _rawApiKey, ...rest } = config + const provider = typeof config.provider === 'string' ? config.provider : '' + const authProfile = typeof config.authProfile === 'string' ? config.authProfile : undefined + const resolvedApiKey = provider ? resolveApiKey(provider, authProfile) : '' + + return { + ...rest, + apiKey: '', + apiKeySet: hasRealApiKey(resolvedApiKey || ''), + } +} diff --git a/apps/cli/src/http/routes/proxy.ts b/apps/cli/src/http/routes/proxy.ts new file mode 100644 index 000000000..a6eb8de63 --- /dev/null +++ b/apps/cli/src/http/routes/proxy.ts @@ -0,0 +1,42 @@ +/** + * 反向代理路由 — /_proxy/chatlab.fun/* + * + * 在 Web 生产模式(chatlab start)下,将 /_proxy/chatlab.fun/* 代理到 + * https://chatlab.fun,行为与 vite.web.config.mts 中的 dev proxy 保持一致, + * 以解决浏览器 CORS 限制。 + * + * 目标 host 硬编码为 https://chatlab.fun,不允许外部指定,避免开放代理风险。 + */ + +import type { FastifyInstance } from 'fastify' + +const PROXY_TARGET = 'https://chatlab.fun' + +export function registerProxyRoutes(server: FastifyInstance): void { + server.get<{ Params: { '*': string } }>('/_proxy/chatlab.fun/*', async (request, reply) => { + const subPath = request.params['*'] ?? '' + const queryString = new URLSearchParams( + request.query as Record, + ).toString() + const targetUrl = queryString + ? `${PROXY_TARGET}/${subPath}?${queryString}` + : `${PROXY_TARGET}/${subPath}` + + try { + const upstream = await fetch(targetUrl, { + headers: { Accept: request.headers.accept ?? '*/*' }, + signal: AbortSignal.timeout(10_000), + }) + + const contentType = upstream.headers.get('content-type') ?? 'application/octet-stream' + const body = Buffer.from(await upstream.arrayBuffer()) + + reply.code(upstream.status).header('content-type', contentType).send(body) + } catch (err) { + reply.code(502).send({ + error: 'Proxy error', + message: err instanceof Error ? err.message : String(err), + }) + } + }) +} diff --git a/apps/cli/src/http/routes/web/cache.test.ts b/apps/cli/src/http/routes/web/cache.test.ts new file mode 100644 index 000000000..48534811d --- /dev/null +++ b/apps/cli/src/http/routes/web/cache.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { openDirectoryPath, showPathInFolder } from './cache' + +describe('CLI Web cache route helpers', () => { + it('opens directories with execFile arguments instead of shell commands', async () => { + const calls: Array<{ file: string; args: string[] }> = [] + const dirPath = '/tmp/chat"lab; touch /tmp/pwned' + + await openDirectoryPath(dirPath, 'darwin', (file, args, callback) => { + calls.push({ file, args }) + callback(null) + }) + + assert.deepEqual(calls, [{ file: 'open', args: [dirPath] }]) + }) + + it('reveals files with execFile arguments instead of shell commands', async () => { + const calls: Array<{ file: string; args: string[] }> = [] + const filePath = '/tmp/chat"lab; touch /tmp/pwned/export.json' + + await showPathInFolder(filePath, 'darwin', (file, args, callback) => { + calls.push({ file, args }) + callback(null) + }) + + assert.deepEqual(calls, [{ file: 'open', args: ['-R', filePath] }]) + }) +}) diff --git a/apps/cli/src/http/routes/web/cache.ts b/apps/cli/src/http/routes/web/cache.ts new file mode 100644 index 000000000..ef4903b8b --- /dev/null +++ b/apps/cli/src/http/routes/web/cache.ts @@ -0,0 +1,51 @@ +/** + * CLI-specific shell helpers for cache routes. + * + * Route registration has been migrated to packages/http-routes (shared). + * Only the platform-specific openDirectoryPath / showPathInFolder functions + * remain here, injected into HttpRouteContext by the CLI web-route initializer. + */ +import * as path from 'path' +import { execFile } from 'child_process' + +type ExecFileRunner = (file: string, args: string[], callback: (error: Error | null) => void) => unknown + +function runExecFile(file: string, args: string[], runner: ExecFileRunner = execFile): Promise { + return new Promise((resolve, reject) => { + runner(file, args, (error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +export async function openDirectoryPath( + dirPath: string, + platform: NodeJS.Platform = process.platform, + runner?: ExecFileRunner +): Promise { + if (platform === 'darwin') { + await runExecFile('open', [dirPath], runner) + } else if (platform === 'win32') { + await runExecFile('explorer.exe', [dirPath], runner) + } else { + await runExecFile('xdg-open', [dirPath], runner) + } +} + +export async function showPathInFolder( + filePath: string, + platform: NodeJS.Platform = process.platform, + runner?: ExecFileRunner +): Promise { + if (platform === 'darwin') { + await runExecFile('open', ['-R', filePath], runner) + } else if (platform === 'win32') { + await runExecFile('explorer.exe', [`/select,${filePath}`], runner) + } else { + await runExecFile('xdg-open', [path.dirname(filePath)], runner) + } +} diff --git a/apps/cli/src/http/routes/web/helpers.ts b/apps/cli/src/http/routes/web/helpers.ts new file mode 100644 index 000000000..5c97832ab --- /dev/null +++ b/apps/cli/src/http/routes/web/helpers.ts @@ -0,0 +1,41 @@ +/** + * Shared helpers for web route modules. + */ + +import * as fs from 'fs' +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseManager } from '@openchatlab/node-runtime' +import { resolveCliPath } from '../../../paths' + +export function parseTimeFilter(query: Record): TimeFilter | undefined { + const { startTs, endTs, memberId } = query + if (!startTs && !endTs && !memberId) return undefined + const filter: TimeFilter = {} + if (startTs) filter.startTs = parseInt(startTs, 10) + if (endTs) filter.endTs = parseInt(endTs, 10) + if (memberId) filter.memberId = parseInt(memberId, 10) + return filter +} + +export function resolveNativeBinding(): string | undefined { + if (process.versions.electron) return undefined + const nativePath = resolveCliPath('native/better_sqlite3.node') + if (fs.existsSync(nativePath)) return nativePath + return undefined +} + +export function getAiDataDir(dbManager: DatabaseManager): string { + const pathProvider = (dbManager as any)['pathProvider'] + if (!pathProvider) { + throw Object.assign(new Error('PathProvider not available'), { statusCode: 500 }) + } + return pathProvider.getAiDataDir() +} + +export function ensureDb(dbManager: DatabaseManager, sessionId: string) { + const db = dbManager.open(sessionId) + if (!db) { + throw Object.assign(new Error(`Session not found: ${sessionId}`), { statusCode: 404 }) + } + return db +} diff --git a/apps/cli/src/http/routes/web/import-source.test.ts b/apps/cli/src/http/routes/web/import-source.test.ts new file mode 100644 index 000000000..5f40cd1ef --- /dev/null +++ b/apps/cli/src/http/routes/web/import-source.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, it } from 'node:test' +import Fastify from 'fastify' +import multipart from '@fastify/multipart' +import { ArchiveImportSourceManager } from '@openchatlab/node-runtime' + +import { writeZipFixture } from '../../../../../../packages/node-runtime/src/import/archive/test-utils' +import { registerImportRoutes } from './import' + +function createTakeoutZip(zipPath: string): void { + writeZipFixture(zipPath, [ + { + name: 'Takeout/Google Chat/Users/User sample/user_info.json', + content: JSON.stringify({ + user: { email: 'owner@example.com', name: 'Owner', user_type: 'Human' }, + }), + }, + { + name: 'Takeout/Google Chat/Groups/DM sample/group_info.json', + content: JSON.stringify({ + members: [ + { email: 'owner@example.com', name: 'Owner', user_type: 'Human' }, + { email: 'other@example.com', name: 'Other User', user_type: 'Human' }, + ], + }), + }, + { + name: 'Takeout/Google Chat/Groups/DM sample/messages.json', + content: JSON.stringify({ messages: [] }), + }, + ]) +} + +function multipartPayload(fileName: string, file: Buffer): { payload: Buffer; contentType: string } { + const boundary = '----chatlab-import-source-test' + const header = Buffer.from( + [ + `--${boundary}`, + `Content-Disposition: form-data; name="file"; filename="${fileName}"`, + 'Content-Type: application/zip', + '', + '', + ].join('\r\n') + ) + const footer = Buffer.from(`\r\n--${boundary}--\r\n`) + return { + payload: Buffer.concat([header, file, footer]), + contentType: `multipart/form-data; boundary=${boundary}`, + } +} + +describe('CLI Web archive import source routes', () => { + it('uploads once, reuses the source for imports, and releases idempotently', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-import-source-route-')) + const app = Fastify() + try { + const zipPath = join(dir, 'takeout.zip') + createTakeoutZip(zipPath) + await app.register(multipart, { limits: { fileSize: 20 * 1024 * 1024 } }) + + const manager = new ArchiveImportSourceManager({ tempRoot: join(dir, 'sources') }) + const importedManifests: string[] = [] + registerImportRoutes(app, {} as any, { + sourceManager: manager, + runPreparedImport: async (manifestPath) => { + importedManifests.push(readFileSync(manifestPath, 'utf8')) + return { success: true, sessionId: `session-${importedManifests.length}` } + }, + }) + + const body = multipartPayload('takeout.zip', readFileSync(zipPath)) + const prepare = await app.inject({ + method: 'POST', + url: '/_web/import-sources', + headers: { 'content-type': body.contentType }, + payload: body.payload, + }) + assert.equal(prepare.statusCode, 200) + const prepared = prepare.json() + assert.equal(prepared.success, true) + assert.equal(prepared.source.platform, 'google-chat') + assert.equal(prepared.source.chats[0].chatId, 'Groups/DM sample') + + for (let index = 0; index < 2; index++) { + const response = await app.inject({ + method: 'POST', + url: `/_web/import-sources/${prepared.source.sourceId}/import`, + payload: { chatId: 'Groups/DM sample' }, + }) + assert.equal(response.statusCode, 200) + assert.match(response.body, /event: done/) + assert.match(response.body, new RegExp(`session-${index + 1}`)) + } + assert.equal(importedManifests.length, 2) + + for (let index = 0; index < 2; index++) { + const response = await app.inject({ + method: 'DELETE', + url: `/_web/import-sources/${prepared.source.sourceId}`, + }) + assert.equal(response.statusCode, 200) + } + } finally { + await app.close() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('returns stable errors for unsupported archives and missing sources', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-import-source-errors-')) + const app = Fastify() + try { + await app.register(multipart) + const manager = new ArchiveImportSourceManager({ tempRoot: join(dir, 'sources') }) + registerImportRoutes(app, {} as any, { + sourceManager: manager, + runPreparedImport: async () => ({ success: true, sessionId: 'unused' }), + }) + + const body = multipartPayload('invalid.zip', Buffer.from('not a zip')) + const prepare = await app.inject({ + method: 'POST', + url: '/_web/import-sources', + headers: { 'content-type': body.contentType }, + payload: body.payload, + }) + assert.equal(prepare.statusCode, 400) + assert.equal(prepare.json().error, 'error.archive_corrupt') + + const missing = await app.inject({ + method: 'POST', + url: '/_web/import-sources/missing/import', + payload: { chatId: 'Groups/DM sample' }, + }) + assert.equal(missing.statusCode, 200) + assert.match(missing.body, /error\.import_source_not_found/) + } finally { + await app.close() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/cli/src/http/routes/web/import.ts b/apps/cli/src/http/routes/web/import.ts new file mode 100644 index 000000000..95021d75f --- /dev/null +++ b/apps/cli/src/http/routes/web/import.ts @@ -0,0 +1,485 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { randomUUID } from 'node:crypto' +import { pipeline } from 'node:stream/promises' +import type { FastifyInstance } from 'fastify' +import { ArchiveImportError, ArchiveImportSourceManager, type DatabaseManager } from '@openchatlab/node-runtime' +import { + streamImport, + incrementalImport, + analyzeIncrementalImport, + analyzeNewImport, + detectFormat, + detectAllFormats, + getSupportedFormats, + scanMultiChatFile, + findEntryFileInDirectory, +} from '../../../import' +import { resolveNativeBinding } from './helpers' + +const DEMO_BASE_URL = 'https://chatlab.fun/assets/demo' +const ARCHIVE_UPLOAD_LIMIT = 50 * 1024 * 1024 * 1024 + +interface ImportRouteOptions { + sourceManager?: ArchiveImportSourceManager + runPreparedImport?: ( + manifestPath: string, + onProgress: (progress: unknown) => void + ) => Promise<{ success: boolean; sessionId?: string; error?: string; messageCount?: number; memberCount?: number }> +} + +function cleanupTemp(...paths: string[]) { + for (const p of paths) { + try { + const stat = fs.statSync(p) + if (stat.isDirectory()) { + fs.rmSync(p, { recursive: true, force: true }) + } else { + fs.unlinkSync(p) + } + } catch { + /* ignore */ + } + } +} + +export function registerImportRoutes( + server: FastifyInstance, + dbManager: DatabaseManager, + options: ImportRouteOptions = {} +): void { + const sourceManager = + options.sourceManager ?? + new ArchiveImportSourceManager({ + tempRoot: fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-import-sources-')), + }) + const runPreparedImport = + options.runPreparedImport ?? + (async (manifestPath: string, onProgress: (progress: unknown) => void) => { + const result = await streamImport(dbManager, manifestPath, { + formatId: 'google-chat-takeout', + nativeBinding: resolveNativeBinding(), + onProgress: onProgress as any, + }) + return { + success: result.success, + sessionId: result.sessionId, + error: result.error, + messageCount: result.diagnostics?.messagesWritten ?? 0, + memberCount: 0, + } + }) + + server.addHook('onClose', async () => { + await sourceManager.close() + }) + + server.post('/_web/import-sources', async (request, reply) => { + const data = await (request as any).file({ + limits: { fileSize: ARCHIVE_UPLOAD_LIMIT }, + }) + if (!data) return reply.code(400).send({ success: false, error: 'error.no_file_selected' }) + + const uploadPath = path.join(os.tmpdir(), `chatlab-archive-${randomUUID()}.zip`) + try { + await pipeline(data.file, fs.createWriteStream(uploadPath, { flags: 'wx' })) + if (data.file.truncated) { + cleanupTemp(uploadPath) + return reply.code(413).send({ success: false, error: 'error.archive_limit_exceeded' }) + } + + const source = await sourceManager.prepareOwnedArchive(uploadPath) + return { success: true, source } + } catch (error) { + cleanupTemp(uploadPath) + if (error instanceof ArchiveImportError) { + return reply.code(400).send({ success: false, error: error.code }) + } + return reply.code(400).send({ + success: false, + error: error instanceof Error ? error.message : String(error), + }) + } + }) + + server.post<{ Params: { sourceId: string }; Body: { chatId?: string } }>( + '/_web/import-sources/:sourceId/import', + async (request, reply) => { + const chatId = request.body?.chatId + if (!chatId) return reply.code(400).send({ success: false, error: 'error.no_chat_selected' }) + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + function sendEvent(event: string, eventData: unknown) { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(eventData)}\n\n`) + } + + try { + const result = await sourceManager.withMaterializedChat(request.params.sourceId, chatId, (manifestPath) => + runPreparedImport(manifestPath, (progress) => sendEvent('progress', progress)) + ) + if (result.success) { + sendEvent('done', result) + } else { + sendEvent('error', { success: false, error: result.error || 'error.import_failed' }) + } + } catch (error) { + sendEvent('error', { + success: false, + error: + error instanceof ArchiveImportError ? error.code : error instanceof Error ? error.message : String(error), + }) + } finally { + reply.raw.end() + } + } + ) + + server.delete<{ Params: { sourceId: string } }>('/_web/import-sources/:sourceId', async (request) => { + await sourceManager.release(request.params.sourceId) + return { success: true } + }) + + server.get('/_web/supported-formats', async () => { + return getSupportedFormats() + }) + + server.post('/_web/detect-format', async (request, reply) => { + const data = await (request as any).file() + if (!data) return reply.code(400).send({ error: 'No file uploaded' }) + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-detect-')) + const tmpPath = path.join(tmpDir, data.filename || 'upload') + + try { + const chunks: Buffer[] = [] + for await (const chunk of data.file) { + chunks.push(chunk) + } + fs.writeFileSync(tmpPath, Buffer.concat(chunks)) + + const format = detectFormat(tmpPath) + const allFormats = detectAllFormats(tmpPath) + return { format, allFormats } + } finally { + cleanupTemp(tmpPath, tmpDir) + } + }) + + server.post('/_web/scan-multi-chat', async (request, reply) => { + const data = await (request as any).file() + if (!data) return reply.code(400).send({ error: 'No file uploaded' }) + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-scan-')) + const tmpPath = path.join(tmpDir, data.filename || 'upload') + + try { + const chunks: Buffer[] = [] + for await (const chunk of data.file) { + chunks.push(chunk) + } + fs.writeFileSync(tmpPath, Buffer.concat(chunks)) + + const chats = await scanMultiChatFile(tmpPath) + return { chats } + } finally { + cleanupTemp(tmpPath, tmpDir) + } + }) + + server.post('/_web/import', async (request, reply) => { + const data = await (request as any).file() + if (!data) return reply.code(400).send({ error: 'No file uploaded' }) + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-import-')) + const tmpPath = path.join(tmpDir, data.filename || 'upload') + + const chunks: Buffer[] = [] + for await (const chunk of data.file) { + chunks.push(chunk) + } + fs.writeFileSync(tmpPath, Buffer.concat(chunks)) + + const formatId = (data.fields?.formatId as any)?.value as string | undefined + const chatIndexStr = (data.fields?.chatIndex as any)?.value as string | undefined + const chatIndex = chatIndexStr !== undefined ? parseInt(chatIndexStr, 10) : undefined + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + function sendEvent(event: string, eventData: unknown) { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(eventData)}\n\n`) + } + + try { + const nativeBinding = resolveNativeBinding() + const result = await streamImport(dbManager, tmpPath, { + formatId, + chatIndex, + nativeBinding, + onProgress: (p) => sendEvent('progress', p), + }) + + if (result.success) { + sendEvent('done', { + success: true, + sessionId: result.sessionId, + messageCount: result.diagnostics?.messagesWritten ?? 0, + memberCount: 0, + }) + } else { + sendEvent('error', { success: false, error: result.error }) + } + } catch (err) { + sendEvent('error', { success: false, error: err instanceof Error ? err.message : String(err) }) + } finally { + reply.raw.end() + cleanupTemp(tmpPath, tmpDir) + } + }) + + // ==================== Directory Import ==================== + + server.post('/_web/import-directory', async (request, reply) => { + const parts = (request as any).parts() + if (!parts) return reply.code(400).send({ error: 'No files uploaded' }) + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-dir-import-')) + const relativePaths: string[] = [] + const fileBuffers: { data: Buffer; filename: string }[] = [] + + try { + for await (const part of parts) { + if (part.type === 'field' && part.fieldname === 'relativePaths') { + relativePaths.push(String(part.value)) + } else if (part.type === 'file') { + const chunks: Buffer[] = [] + for await (const chunk of part.file) { + chunks.push(chunk) + } + fileBuffers.push({ data: Buffer.concat(chunks), filename: part.filename || '' }) + } + } + + for (let i = 0; i < fileBuffers.length; i++) { + let relPath = relativePaths[i] || fileBuffers[i].filename || `file_${i}` + const segments = relPath.split('/') + if (segments.length > 1) { + relPath = segments.slice(1).join('/') + } + const targetPath = path.resolve(tmpDir, relPath) + if (!targetPath.startsWith(tmpDir + path.sep)) continue + fs.mkdirSync(path.dirname(targetPath), { recursive: true }) + fs.writeFileSync(targetPath, fileBuffers[i].data) + } + + const entryPath = findEntryFileInDirectory(tmpDir) + if (!entryPath) { + return reply.code(400).send({ error: 'No recognizable import format found in directory' }) + } + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + function sendEvent(event: string, eventData: unknown) { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(eventData)}\n\n`) + } + + const nativeBinding = resolveNativeBinding() + const result = await streamImport(dbManager, entryPath, { + nativeBinding, + onProgress: (p) => sendEvent('progress', p), + }) + + if (result.success) { + sendEvent('done', { + success: true, + sessionId: result.sessionId, + messageCount: result.diagnostics?.messagesWritten ?? 0, + memberCount: 0, + }) + } else { + sendEvent('error', { success: false, error: result.error }) + } + } catch (err) { + if (!reply.raw.headersSent) { + return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) }) + } + reply.raw.write( + `event: error\ndata: ${JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })}\n\n` + ) + } finally { + reply.raw.end() + cleanupTemp(tmpDir) + } + }) + + // ==================== Incremental Import ==================== + + server.post<{ Params: { id: string } }>('/_web/sessions/:id/import/incremental', async (request, reply) => { + const sessionId = request.params.id + const data = await (request as any).file() + if (!data) return reply.code(400).send({ error: 'No file uploaded' }) + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-inc-')) + const tmpPath = path.join(tmpDir, data.filename || 'upload') + + const chunks: Buffer[] = [] + for await (const chunk of data.file) { + chunks.push(chunk) + } + fs.writeFileSync(tmpPath, Buffer.concat(chunks)) + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + function sendEvent(event: string, eventData: unknown) { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(eventData)}\n\n`) + } + + try { + const result = await incrementalImport(dbManager, sessionId, tmpPath, { + onProgress: (p) => sendEvent('progress', p), + }) + + if (result.success) { + sendEvent('done', result) + } else { + sendEvent('error', { success: false, error: result.error }) + } + } catch (err) { + sendEvent('error', { success: false, error: err instanceof Error ? err.message : String(err) }) + } finally { + reply.raw.end() + cleanupTemp(tmpPath, tmpDir) + } + }) + + server.post<{ Params: { id: string } }>('/_web/sessions/:id/import/incremental/analyze', async (request, reply) => { + const sessionId = request.params.id + const data = await (request as any).file() + if (!data) return reply.code(400).send({ error: 'No file uploaded' }) + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-analyze-')) + const tmpPath = path.join(tmpDir, data.filename || 'upload') + + const chunks: Buffer[] = [] + for await (const chunk of data.file) { + chunks.push(chunk) + } + fs.writeFileSync(tmpPath, Buffer.concat(chunks)) + + try { + return await analyzeIncrementalImport(dbManager, sessionId, tmpPath) + } catch (err) { + return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) }) + } finally { + cleanupTemp(tmpPath, tmpDir) + } + }) + + // ==================== Analyze New Import (dry-run) ==================== + + server.post('/_web/import/analyze', async (request, reply) => { + const data = await (request as any).file() + if (!data) return reply.code(400).send({ error: 'No file uploaded' }) + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-analyze-')) + const tmpPath = path.join(tmpDir, data.filename || 'upload') + + const chunks: Buffer[] = [] + for await (const chunk of data.file) { + chunks.push(chunk) + } + fs.writeFileSync(tmpPath, Buffer.concat(chunks)) + + try { + return await analyzeNewImport(tmpPath) + } catch (err) { + return reply.code(500).send({ error: err instanceof Error ? err.message : String(err) }) + } finally { + cleanupTemp(tmpPath, tmpDir) + } + }) + + // ==================== Demo Import ==================== + + const DEMO_FILES = [ + 'demo-group.json', + 'demo-private-A-cuilan.json', + 'demo-private-B-wukong.json', + 'demo-private-C-spider.json', + ] + + server.post<{ Body: { locale?: string } }>('/_web/demo/import', async (request, reply) => { + const locale = (request.body as any)?.locale || 'en' + const nativeBinding = resolveNativeBinding() + const total = DEMO_FILES.length + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + function sendEvent(event: string, eventData: unknown) { + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(eventData)}\n\n`) + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-demo-')) + + try { + const localPaths: string[] = [] + for (let i = 0; i < total; i++) { + sendEvent('progress', { stage: 'downloading', current: i + 1, total }) + const localPath = path.join(tmpDir, DEMO_FILES[i]) + const resp = await fetch(`${DEMO_BASE_URL}/${locale}/${DEMO_FILES[i]}`, { + signal: AbortSignal.timeout(60_000), + }) + if (!resp.ok) throw new Error(`Download demo failed (${DEMO_FILES[i]}): ${resp.status}`) + fs.writeFileSync(localPath, Buffer.from(await resp.arrayBuffer())) + localPaths.push(localPath) + } + + sendEvent('progress', { stage: 'importing', current: 1, total }) + const groupResult = await streamImport(dbManager, localPaths[0], { nativeBinding }) + if (!groupResult.success) throw new Error(groupResult.error || 'Failed to import group demo') + + const privateSessionIds: string[] = [] + for (let i = 1; i < localPaths.length; i++) { + sendEvent('progress', { stage: 'importing', current: i + 1, total }) + const result = await streamImport(dbManager, localPaths[i], { nativeBinding }) + if (!result.success) throw new Error(result.error || `Failed to import private demo: ${DEMO_FILES[i]}`) + if (result.sessionId) privateSessionIds.push(result.sessionId) + } + + sendEvent('progress', { stage: 'done', current: total, total }) + sendEvent('result', { + success: true, + groupSessionId: groupResult.sessionId, + privateSessionIds, + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + sendEvent('progress', { stage: 'error', current: 0, total, message }) + sendEvent('result', { success: false, error: message }) + } finally { + reply.raw.end() + cleanupTemp(tmpDir) + } + }) +} diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts new file mode 100644 index 000000000..f44beebdc --- /dev/null +++ b/apps/cli/src/http/routes/web/index.ts @@ -0,0 +1,182 @@ +/** + * ChatLab Internal Web API — /_web/ routes + * + * 供 CLI Web 前端使用的内部 API(无认证、UI 友好的响应格式)。 + * 数据格式直接对齐 QueryAdapter 接口,避免前端二次转换。 + * + * Route modules: + * sessions – Session CRUD + * members – Member management + * analytics – Stats and advanced analytics + * sql – SQL Lab and plugin query + * sessionIndex – Session index generation + FTS + * summaries – LLM summary generation + * import – File / directory / incremental import + demo + * merge – Merge parse / conflicts / execute + * export – Markdown export + * cache – Storage management + save to downloads + show in folder + */ + +import * as os from 'os' +import * as path from 'path' +import type { FastifyInstance } from 'fastify' +import type { PathProvider } from '@openchatlab/core' +import type { + DatabaseManager, + AIChatManager, + AssistantManager, + SkillManagerCore, + LLMConfigStore, + CustomProviderStore, + CustomModelStore, + PendingDataDirMigration, + RuntimeIdentity, +} from '@openchatlab/node-runtime' +import { + createDatabaseManagerAdapter, + createNodeDataDirSwitch, + getDefaultNodeUserDataDir, + getPendingNodeDataDirMigration, + AnalyticsService, + type SemanticIndexRuntime, +} from '@openchatlab/node-runtime' +import { registerSharedRoutes } from '@openchatlab/http-routes' +import type { HttpRouteContext } from '@openchatlab/http-routes' +import { MergeSessionCache } from '../../../merger/merge-cache' +import { registerImportRoutes } from './import' +import { openDirectoryPath, showPathInFolder } from './cache' +import { getVersion } from '../../../version' +import { buildWebUpdateCheckResult } from './update-check' + +export interface AiContextOptions { + aiDataDir: string + aiChatManager: AIChatManager + assistantManager: AssistantManager + skillManagerCore: SkillManagerCore + llmConfigStore: LLMConfigStore + customProviderStore: CustomProviderStore + customModelStore: CustomModelStore + runAgentStream?: HttpRouteContext['runAgentStream'] +} + +export function registerWebRoutes( + server: FastifyInstance, + dbManager: DatabaseManager, + options?: { + pathProvider?: PathProvider + nativeBinding?: string + runtimeIdentity?: RuntimeIdentity + aiContext?: AiContextOptions + /** 由 server 入口注入的共享语义索引运行时;传入时由调用方管理生命周期 */ + semanticIndexService?: SemanticIndexRuntime + } +): void { + const adapter = createDatabaseManagerAdapter(dbManager) + + const mergeCache = options?.pathProvider + ? new MergeSessionCache(options.pathProvider, { nativeBinding: options.nativeBinding }) + : null + mergeCache?.cleanupOrphans() + + const fallbackPathProvider: PathProvider = { + getSystemDir: () => path.join(os.homedir(), '.chatlab'), + getUserDataDir: () => path.join(os.homedir(), '.chatlab', 'data'), + getDatabaseDir: () => path.join(os.homedir(), '.chatlab', 'data', 'databases'), + getVectorDir: () => path.join(os.homedir(), '.chatlab', 'data', 'vector'), + getAiDataDir: () => path.join(os.homedir(), '.chatlab', 'ai'), + getSettingsDir: () => path.join(os.homedir(), '.chatlab', 'settings'), + getCacheDir: () => path.join(os.homedir(), '.chatlab', 'cache'), + getTempDir: () => path.join(os.homedir(), '.chatlab', 'temp'), + getLogsDir: () => path.join(os.homedir(), '.chatlab', 'logs'), + getDownloadsDir: () => path.join(os.homedir(), 'Downloads'), + } + const resolvedPathProvider = options?.pathProvider ?? fallbackPathProvider + + const ai = options?.aiContext + + const cliStreamImport = async (dm: typeof dbManager, filePath: string) => { + const { streamImport } = await import('../../../import/stream-import') + const result = await streamImport(dm, filePath) + if (!result.sessionId) throw new Error('Import succeeded but no sessionId returned') + return { sessionId: result.sessionId } + } + + const defaultUserDataDir = getDefaultNodeUserDataDir() + const isCustom = path.resolve(resolvedPathProvider.getUserDataDir()) !== path.resolve(defaultUserDataDir) + + const semanticIndexService = options?.semanticIndexService + + const analyticsService = process.env.APTABASE_APP_KEY + ? new AnalyticsService(resolvedPathProvider.getSystemDir(), process.env.APTABASE_APP_KEY, getVersion()) + : undefined + + registerSharedRoutes( + server, + { + dbManager, + sessionAdapter: adapter, + pathProvider: resolvedPathProvider, + runtimeIdentity: options?.runtimeIdentity, + getVersion, + nativeBinding: options?.nativeBinding, + semanticIndexService, + analyticsService, + openDirectory: openDirectoryPath, + showInFolder: showPathInFolder, + downloadsDir: resolvedPathProvider.getDownloadsDir(), + defaultUserDataDir, + isCustomDataDir: isCustom, + canSetDataDir: !process.env.CHATLAB_DATA_DIR, + getPendingDataDirMigration: (): PendingDataDirMigration | null => + getPendingNodeDataDirMigration(resolvedPathProvider.getSystemDir()), + setDataDir: (dirPath, migrate) => + createNodeDataDirSwitch({ + systemDir: resolvedPathProvider.getSystemDir(), + currentDir: resolvedPathProvider.getUserDataDir(), + targetDir: dirPath, + defaultDir: defaultUserDataDir, + migrate, + envDataDir: process.env.CHATLAB_DATA_DIR, + }), + ...(mergeCache && { + mergeSessionCache: mergeCache, + streamImport: cliStreamImport, + }), + ...(ai && { + aiDataDir: ai.aiDataDir, + aiChatManager: ai.aiChatManager, + assistantManager: ai.assistantManager, + skillManagerCore: ai.skillManagerCore, + llmConfigStore: ai.llmConfigStore, + customProviderStore: ai.customProviderStore, + customModelStore: ai.customModelStore, + runAgentStream: ai.runAgentStream, + }), + }, + ai ? { requireAi: true } : undefined + ) + + // CLI-specific routes not yet migrated to @openchatlab/http-routes + registerImportRoutes(server, dbManager) + + server.get('/_web/system/check-update', async () => { + const currentVersion = getVersion() + try { + const resp = await fetch('https://chatlab.fun/latest-version', { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(10_000), + }) + if (!resp.ok) { + return { hasUpdate: false, currentVersion, error: `latest-version HTTP ${resp.status}` } + } + const data = (await resp.json()) as { version?: string } + return buildWebUpdateCheckResult({ currentVersion, latestVersion: data.version }) + } catch (err) { + return { + hasUpdate: false, + currentVersion, + error: err instanceof Error ? err.message : String(err), + } + } + }) +} diff --git a/apps/cli/src/http/routes/web/sessions.test.ts b/apps/cli/src/http/routes/web/sessions.test.ts new file mode 100644 index 000000000..c262cc4a3 --- /dev/null +++ b/apps/cli/src/http/routes/web/sessions.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import Fastify from 'fastify' +import type { SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '@openchatlab/http-routes' +import { registerSessionRoutes } from '@openchatlab/http-routes' + +function createMissingSessionAdapter(): SessionRuntimeAdapter { + return { + listSessionIds: () => [], + openReadonly: () => null, + openWritable: () => null, + closeSession: () => {}, + getDbPath: (sessionId) => `/tmp/${sessionId}.db`, + deleteSessionFile: () => false, + ensureReadonly: () => { + throw Object.assign(new Error('Session not found'), { statusCode: 404 }) + }, + ensureWritable: () => { + throw Object.assign(new Error('Session not found'), { statusCode: 404 }) + }, + } +} + +function createMockContext(adapter: SessionRuntimeAdapter): HttpRouteContext { + return { + dbManager: {} as any, + sessionAdapter: adapter, + pathProvider: {} as any, + getVersion: () => '0.0.0-test', + } +} + +describe('shared session routes', () => { + it('returns 404 when requesting a missing session by id', async () => { + const app = Fastify() + const ctx = createMockContext(createMissingSessionAdapter()) + registerSessionRoutes(app, ctx) + + const response = await app.inject({ method: 'GET', url: '/_web/sessions/missing' }) + await app.close() + + assert.equal(response.statusCode, 404) + }) +}) diff --git a/apps/cli/src/http/routes/web/summaries.ts b/apps/cli/src/http/routes/web/summaries.ts new file mode 100644 index 000000000..fc8d7beef --- /dev/null +++ b/apps/cli/src/http/routes/web/summaries.ts @@ -0,0 +1,64 @@ +import type { FastifyInstance } from 'fastify' +import type { DatabaseManager, SessionRuntimeAdapter, SummaryServiceDeps } from '@openchatlab/node-runtime' +import { summaryService } from '@openchatlab/node-runtime' +import { getAiDataDir } from './helpers' +import { getDefaultAssistantConfig, buildPiModel } from '../../../ai/llm-config' + +function createSummaryDeps(dbManager: DatabaseManager): SummaryServiceDeps { + const aiDataDir = getAiDataDir(dbManager) + return { + getLlmConfig() { + return getDefaultAssistantConfig(aiDataDir) + }, + buildPiModel(config) { + return buildPiModel(config as ReturnType & object) + }, + } +} + +export function registerSummaryRoutes( + server: FastifyInstance, + dbManager: DatabaseManager, + adapter: SessionRuntimeAdapter +): void { + const deps = createSummaryDeps(dbManager) + + server.post<{ + Params: { id: string } + Body: { segmentId: number; locale?: string; forceRegenerate?: boolean; strategy?: 'brief' | 'standard' } + }>('/_web/sessions/:id/summaries/generate', async (request, reply) => { + const { segmentId, locale, forceRegenerate, strategy } = request.body + const result = await summaryService.generateSummary(adapter, request.params.id, segmentId, deps, { + locale, + forceRegenerate, + strategy, + }) + if ('error' in result && !result.success) { + return reply.code(400).send({ error: result.error }) + } + return result + }) + + server.post<{ + Params: { id: string } + Body: { locale?: string; forceRegenerate?: boolean } + }>('/_web/sessions/:id/summaries/generate-all', async (request, reply) => { + const { locale, forceRegenerate } = request.body + const result = await summaryService.generateAllSummaries(adapter, request.params.id, deps, { + locale, + forceRegenerate, + }) + if (result.error) { + return reply.code(400).send({ error: result.error }) + } + return result + }) + + server.post<{ + Params: { id: string } + Body: { segmentIds: number[] } + }>('/_web/sessions/:id/summaries/check-can-generate', async (request) => { + const { segmentIds } = request.body + return summaryService.checkCanGenerate(adapter, request.params.id, segmentIds) + }) +} diff --git a/apps/cli/src/http/routes/web/update-check.test.ts b/apps/cli/src/http/routes/web/update-check.test.ts new file mode 100644 index 000000000..8912f1f00 --- /dev/null +++ b/apps/cli/src/http/routes/web/update-check.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildWebUpdateCheckResult } from './update-check' + +describe('CLI Web update check', () => { + it('does not show an update notice for local development placeholder versions', () => { + assert.deepEqual(buildWebUpdateCheckResult({ currentVersion: '0.0.0', latestVersion: '0.24.0' }), { + hasUpdate: false, + currentVersion: '0.0.0', + latestVersion: '0.24.0', + }) + assert.deepEqual(buildWebUpdateCheckResult({ currentVersion: '0.0.0-dev', latestVersion: '0.24.0' }), { + hasUpdate: false, + currentVersion: '0.0.0-dev', + latestVersion: '0.24.0', + }) + }) + + it('reports stable updates for real CLI versions', () => { + assert.deepEqual(buildWebUpdateCheckResult({ currentVersion: '0.23.0', latestVersion: '0.24.0' }), { + hasUpdate: true, + currentVersion: '0.23.0', + latestVersion: '0.24.0', + }) + }) +}) diff --git a/apps/cli/src/http/routes/web/update-check.ts b/apps/cli/src/http/routes/web/update-check.ts new file mode 100644 index 000000000..11889a9fa --- /dev/null +++ b/apps/cli/src/http/routes/web/update-check.ts @@ -0,0 +1,25 @@ +import { isNewerStableVersion } from '@openchatlab/core' + +export interface WebUpdateCheckResult { + hasUpdate: boolean + currentVersion: string + latestVersion?: string +} + +function isDevelopmentPlaceholderVersion(version: string): boolean { + return /^0\.0\.0(?:$|-)/.test(version.trim()) +} + +export function buildWebUpdateCheckResult(options: { + currentVersion: string + latestVersion?: string | null +}): WebUpdateCheckResult { + const latestVersion = options.latestVersion || options.currentVersion + return { + hasUpdate: + !isDevelopmentPlaceholderVersion(options.currentVersion) && + isNewerStableVersion(latestVersion, options.currentVersion), + currentVersion: options.currentVersion, + latestVersion, + } +} diff --git a/apps/cli/src/http/server.test.ts b/apps/cli/src/http/server.test.ts new file mode 100644 index 000000000..c402ba9e7 --- /dev/null +++ b/apps/cli/src/http/server.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat' +import { createServer } from './server' + +test('createServer returns DATA_DIR_INCOMPATIBLE when a route hits the data directory gate', async () => { + const server = createServer() + server.get('/boom', async () => { + throw new DataDirCompatibilityError( + 'DATA_DIR_REQUIRES_NEWER_RUNTIME', + 'ChatLab data directory requires runtime version 0.25.1 or newer; current version is 0.25.0.', + { + userDataDir: '/tmp/chatlab-data', + metaPath: '/tmp/chatlab-data/.chatlab-meta.json', + currentVersion: '0.25.0', + minRuntimeVersion: '0.25.1', + } + ) + }) + + try { + const resp = await server.inject({ method: 'GET', url: '/boom' }) + + assert.equal(resp.statusCode, 409) + assert.deepEqual(resp.json(), { + success: false, + error: { + code: 'DATA_DIR_INCOMPATIBLE', + message: 'ChatLab data directory requires runtime version 0.25.1 or newer; current version is 0.25.0.', + }, + }) + } finally { + await server.close() + } +}) diff --git a/apps/cli/src/http/server.ts b/apps/cli/src/http/server.ts new file mode 100644 index 000000000..bd5c61e76 --- /dev/null +++ b/apps/cli/src/http/server.ts @@ -0,0 +1,53 @@ +/** + * ChatLab HTTP API — Fastify server factory + * + * 从 electron/main/api/server.ts 迁移,完全平台无关。 + */ + +import Fastify, { type FastifyInstance, type FastifyError } from 'fastify' +import { + authHook, + ApiError, + ApiErrorCode, + apiErrorFromUnknown, + errorResponse, + serverError, +} from '@openchatlab/http-routes' +import { appLogger } from '@openchatlab/node-runtime' + +const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB + +export function createServer(): FastifyInstance { + const server = Fastify({ + logger: false, + bodyLimit: JSON_BODY_LIMIT, + }) + + server.addHook('onRequest', authHook) + + server.setErrorHandler((error: FastifyError, request, reply) => { + const apiError = apiErrorFromUnknown(error) + if (apiError) { + reply.code(apiError.statusCode).send(errorResponse(apiError)) + return + } + + if (error.statusCode === 413) { + const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') + reply.code(413).send(errorResponse(bodyErr)) + return + } + + const statusCode = (error as any).statusCode + if (statusCode && statusCode >= 400 && statusCode < 600) { + reply.code(statusCode).send({ success: false, error: { code: 'CLIENT_ERROR', message: error.message } }) + return + } + + appLogger.error('http', `${request.method} ${request.url} -> 500`, error) + const err = serverError(error.message) + reply.code(err.statusCode).send(errorResponse(err)) + }) + + return server +} diff --git a/apps/cli/src/import/importer.test.ts b/apps/cli/src/import/importer.test.ts new file mode 100644 index 000000000..9f48ba003 --- /dev/null +++ b/apps/cli/src/import/importer.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { PathProvider } from '@openchatlab/core' +import { DatabaseManager, raiseDataDirMinRuntimeVersion, readDataDirCompatibilityMeta } from '@openchatlab/node-runtime' +import { streamImport } from './stream-import' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-import-')) +} + +function createPathProvider(root: string): PathProvider { + return { + getSystemDir: () => root, + getUserDataDir: () => path.join(root, 'data'), + getDatabaseDir: () => path.join(root, 'data', 'databases'), + getVectorDir: () => path.join(root, 'data', 'vector'), + getAiDataDir: () => path.join(root, 'ai'), + getSettingsDir: () => path.join(root, 'settings'), + getCacheDir: () => path.join(root, 'cache'), + getTempDir: () => path.join(root, 'temp'), + getLogsDir: () => path.join(root, 'logs'), + getDownloadsDir: () => path.join(root, 'downloads'), + } +} + +/** Write a minimal valid ChatLab Format JSON to a temp file and return the path. */ +function writeTempChatFile(dir: string): string { + const filePath = path.join(dir, 'test-chat.json') + fs.writeFileSync( + filePath, + JSON.stringify({ + chatlab: { version: '0.0.2', exportedAt: 1711468800 }, + meta: { name: 'Test Chat', platform: 'qq', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + // accountName is required by streaming-importer (skipped otherwise) + messages: [{ sender: 'u1', accountName: 'Alice', timestamp: 1711468800, type: 0, content: 'hello' }], + }) + ) + return filePath +} + +test('streamImport raises the data directory gate after creating a current-schema database', async () => { + const root = makeTempDir() + fs.mkdirSync(path.join(root, 'data', 'databases'), { recursive: true }) + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + + const chatFile = writeTempChatFile(root) + const result = await streamImport(manager, chatFile, { sessionId: 'test-session', nativeBinding }) + + assert.equal(result.success, true) + const meta = readDataDirCompatibilityMeta(path.join(root, 'data')) + assert.equal(meta?.minRuntimeVersion, '0.25.1') + assert.equal(meta?.dataCompatibilityVersion, 1) + assert.deepEqual(meta?.reasons, ['segment-schema']) +}) + +test('streamImport re-checks data directory compatibility before raw database writes', async () => { + const root = makeTempDir() + fs.mkdirSync(path.join(root, 'data', 'databases'), { recursive: true }) + const pathProvider = createPathProvider(root) + raiseDataDirMinRuntimeVersion(pathProvider, { + minRuntimeVersion: '0.26.0', + dataCompatibilityVersion: 2, + reason: 'future-schema', + runtime: { version: '0.26.0', kind: 'desktop' }, + module: 'future-migration', + now: () => 1780830000, + }) + const manager = new DatabaseManager(pathProvider, { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + + const chatFile = writeTempChatFile(root) + // DataDirCompatibilityError is thrown before the inner try/catch in streaming-importer, + // so streamImport propagates it. Normalise to a result shape for assertions. + const result = await streamImport(manager, chatFile, { sessionId: 'test-session', nativeBinding }).catch( + (err: Error) => ({ success: false as const, error: err.message }) + ) + + assert.equal(result.success, false) + assert.match(result.error ?? '', /requires runtime version 0\.26\.0 or newer/) + assert.equal(fs.readdirSync(path.join(root, 'data', 'databases')).filter((name) => name.endsWith('.db')).length, 0) +}) diff --git a/apps/cli/src/import/index.ts b/apps/cli/src/import/index.ts new file mode 100644 index 000000000..c33e3f6f7 --- /dev/null +++ b/apps/cli/src/import/index.ts @@ -0,0 +1,23 @@ +// Full-format stream import via @openchatlab/parser + node-runtime streaming importer +export { + streamImport, + incrementalImport, + analyzeIncrementalImport, + analyzeNewImport, + detectFormat, + detectAllFormats, + getFormatFeatureById, + getSupportedFormats, + scanMultiChatFile, + findEntryFileInDirectory, +} from './stream-import' +export type { + StreamImportProgress, + StreamImportResult, + StreamImportOptions, + FormatFeature, + MultiChatInfo, + IncrementalImportResult, + IncrementalAnalyzeResult, + AnalyzeNewImportResult, +} from './stream-import' diff --git a/apps/cli/src/import/stream-import.test.ts b/apps/cli/src/import/stream-import.test.ts new file mode 100644 index 000000000..797480811 --- /dev/null +++ b/apps/cli/src/import/stream-import.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { PathProvider } from '@openchatlab/core' +import { + DatabaseManager, + DataDirCompatibilityError, + raiseDataDirMinRuntimeVersion, + readDataDirCompatibilityMeta, +} from '@openchatlab/node-runtime' +import { analyzeIncrementalImport, incrementalImport } from './stream-import' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-stream-import-')) +} + +function createPathProvider(root: string): PathProvider { + return { + getSystemDir: () => root, + getUserDataDir: () => path.join(root, 'data'), + getDatabaseDir: () => path.join(root, 'data', 'databases'), + getVectorDir: () => path.join(root, 'data', 'vector'), + getAiDataDir: () => path.join(root, 'ai'), + getSettingsDir: () => path.join(root, 'settings'), + getCacheDir: () => path.join(root, 'cache'), + getTempDir: () => path.join(root, 'temp'), + getLogsDir: () => path.join(root, 'logs'), + getDownloadsDir: () => path.join(root, 'downloads'), + } +} + +function writeIncrementalJsonl(filePath: string): void { + const rows = [ + { + _type: 'header', + chatlab: { version: '1.0.0', exportedAt: 1780830000 }, + meta: { name: 'Incremental Chat', platform: 'qq', type: 'group' }, + }, + { _type: 'member', platformId: 'u1', accountName: 'Alice' }, + { + _type: 'message', + sender: 'u1', + accountName: 'Alice', + timestamp: 2000, + type: 0, + content: 'new incremental message', + platformMessageId: 'm1', + }, + ] + fs.writeFileSync(filePath, rows.map((row) => JSON.stringify(row)).join('\n'), 'utf-8') +} + +test('incrementalImport raises the data directory gate after successful writes', async () => { + const root = makeTempDir() + const pathProvider = createPathProvider(root) + const manager = new DatabaseManager(pathProvider, { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + + const db = manager.openRawSessionDatabase('existing', { create: true, initializeChatTables: true }) + db.prepare('INSERT INTO meta (name, platform, type, imported_at) VALUES (?, ?, ?, ?)').run( + 'Existing Chat', + 'qq', + 'group', + 1000 + ) + db.prepare('INSERT INTO member (platform_id, account_name) VALUES (?, ?)').run('u0', 'Existing User') + db.close() + + const filePath = path.join(root, 'incremental.jsonl') + writeIncrementalJsonl(filePath) + + const result = await incrementalImport(manager, 'existing', filePath) + + assert.equal(result.success, true) + assert.equal(result.newMessageCount, 1) + const meta = readDataDirCompatibilityMeta(pathProvider.getUserDataDir()) + assert.equal(meta?.minRuntimeVersion, '0.25.1') + assert.equal(meta?.dataCompatibilityVersion, 1) + assert.deepEqual(meta?.reasons, ['segment-schema']) +}) + +test('analyzeIncrementalImport propagates data directory compatibility errors', async () => { + const root = makeTempDir() + const pathProvider = createPathProvider(root) + fs.mkdirSync(pathProvider.getDatabaseDir(), { recursive: true }) + fs.writeFileSync(path.join(pathProvider.getDatabaseDir(), 'existing.db'), 'not opened before compatibility check') + raiseDataDirMinRuntimeVersion(pathProvider, { + minRuntimeVersion: '0.26.0', + dataCompatibilityVersion: 2, + reason: 'future-schema', + runtime: { version: '0.26.0', kind: 'desktop' }, + module: 'future-migration', + now: () => 1780830000, + }) + const manager = new DatabaseManager(pathProvider, { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + const filePath = path.join(root, 'incremental.jsonl') + writeIncrementalJsonl(filePath) + + await assert.rejects( + () => analyzeIncrementalImport(manager, 'existing', filePath), + (error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' + ) +}) diff --git a/apps/cli/src/import/stream-import.ts b/apps/cli/src/import/stream-import.ts new file mode 100644 index 000000000..4c5138f6d --- /dev/null +++ b/apps/cli/src/import/stream-import.ts @@ -0,0 +1,249 @@ +/** + * Server/CLI streaming import — adapter for @openchatlab/node-runtime StreamingImporter. + * + * Replaces the old buffered-in-memory approach with the same high-performance + * streaming pipeline used by Electron (batched transactions, deferred indexes, + * nickname history, FTS, format fallback). + */ + +import type { DatabaseManager } from '@openchatlab/node-runtime' +import { + DataDirCompatibilityError, + streamingImport, + analyzeNewImport as sharedAnalyzeNewImport, + analyzeIncrementalImport as sharedAnalyzeIncremental, + incrementalImport as sharedIncrementalImport, +} from '@openchatlab/node-runtime' +import type { + StreamImportResult, + StreamImportDeps, + ImportProgressCallback, + IncrementalImportResult, + IncrementalAnalyzeResult, + IncrementalImportDeps, + ImportOptions, + AnalyzeNewImportResult, +} from '@openchatlab/node-runtime' +import { + detectFormat as parserDetectFormat, + detectAllFormats, + getFormatFeatureById, + getSupportedFormats as parserGetSupportedFormats, + scanMultiChatFile as parserScanMultiChatFile, + findEntryFileInDirectory, + type FormatFeature, + type MultiChatInfo, + type ParseProgress, +} from '@openchatlab/parser' +import * as crypto from 'crypto' + +// ==================== Legacy progress interface (for SSE routes) ==================== + +export interface StreamImportProgress { + stage: 'detecting' | 'parsing' | 'saving' | 'indexing' | 'done' | 'error' + progress: number + message: string + bytesRead?: number + totalBytes?: number + messagesProcessed?: number +} + +export interface StreamImportOptions { + formatId?: string + chatIndex?: number + nativeBinding?: string + onProgress?: (progress: StreamImportProgress) => void + /** Fix the target session ID instead of auto-generating one. Used by sync/pull adapters. */ + sessionId?: string +} + +function generateSessionId(): string { + const ts = Date.now() + const rand = crypto.randomBytes(4).toString('hex') + return `chat_${ts}_${rand}` +} + +function buildStreamImportDeps(dbManager: DatabaseManager, onProgress?: ImportProgressCallback): StreamImportDeps { + return { + openDatabase(sessionId: string) { + return dbManager.openRawSessionDatabase(sessionId, { create: true, initializeChatTables: true }) + }, + deleteDatabase(sessionId: string) { + dbManager.deleteSessionDatabaseFiles(sessionId) + }, + onProgress: onProgress ?? (() => {}), + generateSessionId, + } +} + +function deleteSessionDatabase(dbManager: DatabaseManager, sessionId: string): void { + dbManager.deleteSessionDatabaseFiles(sessionId) +} + +/** + * High-performance streaming import: parse a file and write to DB + * with batched transactions, deferred indexes, and FTS. + */ +export async function streamImport( + dbManager: DatabaseManager, + filePath: string, + options?: StreamImportOptions +): Promise { + const { formatId, chatIndex, onProgress, sessionId } = options || {} + + const formatOptions: Record = {} + if (formatId) formatOptions.formatId = formatId + if (chatIndex !== undefined) formatOptions.chatIndex = chatIndex + + const progressAdapter: ImportProgressCallback = onProgress + ? (progress) => { + let stage: StreamImportProgress['stage'] = 'parsing' + let pct = 0 + switch (progress.stage) { + case 'detecting': + stage = 'detecting' + pct = 5 + break + case 'parsing': + stage = 'parsing' + pct = Math.min(Math.round(progress.percentage * 0.7), 70) + break + case 'importing': + case 'saving': + stage = 'saving' + pct = 80 + break + case 'done': + stage = 'done' + pct = 100 + break + case 'error': + stage = 'error' + pct = 0 + break + } + onProgress({ + stage, + progress: pct, + message: progress.message || '', + bytesRead: progress.bytesRead, + totalBytes: progress.totalBytes, + messagesProcessed: progress.messagesProcessed, + }) + } + : () => {} + + const deps = buildStreamImportDeps(dbManager, progressAdapter) + const result = await streamingImport(filePath, deps, formatOptions, sessionId) + if (!result.success || !result.sessionId) return result + + try { + dbManager.raiseCurrentChatDbCompatibilityGate() + } catch (error) { + deleteSessionDatabase(dbManager, result.sessionId) + return { + success: false, + error: error instanceof Error ? error.message : String(error), + diagnostics: result.diagnostics, + } + } + + return result +} + +// ==================== Incremental import ==================== + +function buildIncrementalDeps( + dbManager: DatabaseManager, + onProgress?: ImportProgressCallback, + onCompatibilityError?: (error: DataDirCompatibilityError) => void +): IncrementalImportDeps { + return { + openDatabase(sessionId: string, readonly?: boolean) { + try { + return dbManager.openRawSessionDatabase(sessionId, { readonly: readonly ?? false }) + } catch (error) { + if (error instanceof DataDirCompatibilityError) onCompatibilityError?.(error) + throw error + } + }, + onProgress: onProgress ?? (() => {}), + } +} + +export async function incrementalImport( + dbManager: DatabaseManager, + sessionId: string, + filePath: string, + options?: ImportOptions & { onProgress?: ImportProgressCallback } +): Promise { + const { onProgress, ...importOpts } = options || {} + let compatibilityError: DataDirCompatibilityError | null = null + const result = await sharedIncrementalImport( + sessionId, + filePath, + buildIncrementalDeps(dbManager, onProgress, (error) => { + compatibilityError = error + }), + importOpts + ) + if (compatibilityError) throw compatibilityError + if (!result.success) return result + + try { + dbManager.raiseCurrentChatDbCompatibilityGate() + } catch (error) { + return { + ...result, + success: false, + newMessageCount: 0, + error: error instanceof Error ? error.message : String(error), + } + } + + return result +} + +export async function analyzeIncrementalImport( + dbManager: DatabaseManager, + sessionId: string, + filePath: string, + onProgress?: ImportProgressCallback +): Promise { + let compatibilityError: DataDirCompatibilityError | null = null + const result = await sharedAnalyzeIncremental( + sessionId, + filePath, + buildIncrementalDeps(dbManager, onProgress, (error) => { + compatibilityError = error + }) + ) + if (compatibilityError) throw compatibilityError + return result +} + +export async function analyzeNewImport( + filePath: string, + onProgress?: ImportProgressCallback +): Promise { + return sharedAnalyzeNewImport(filePath, onProgress ?? (() => {})) +} + +// ==================== Re-exports from parser ==================== + +export { + parserDetectFormat as detectFormat, + detectAllFormats, + getFormatFeatureById, + parserGetSupportedFormats as getSupportedFormats, + parserScanMultiChatFile as scanMultiChatFile, + findEntryFileInDirectory, +} +export type { FormatFeature, MultiChatInfo, ParseProgress } +export type { + StreamImportResult, + IncrementalImportResult, + IncrementalAnalyzeResult, + AnalyzeNewImportResult, + ImportOptions, +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts new file mode 100644 index 000000000..8c42ba921 --- /dev/null +++ b/apps/cli/src/index.ts @@ -0,0 +1,8 @@ +/** + * chatlab-cli + * + * Programmatic API for ChatLab CLI, HTTP API server and MCP server. + */ + +export { run } from './cli' +export { startHttpServer, stopHttpServer } from './http' diff --git a/apps/cli/src/mcp.ts b/apps/cli/src/mcp.ts new file mode 100644 index 000000000..4534c967d --- /dev/null +++ b/apps/cli/src/mcp.ts @@ -0,0 +1,47 @@ +/** + * CLI MCP Server entry point + * + * Thin wrapper: initializes Node.js runtime, then delegates to chatlab-mcp. + */ + +import fs from 'fs' +import { loadConfig } from '@openchatlab/config' +import { + NodePathProvider, + DatabaseManager, + hasPendingElectronDataWarning, + verifyCliDataPath, +} from '@openchatlab/node-runtime' +import { startMcpServer } from 'chatlab-mcp' +import { getVersion } from './version' +import { resolveCliPath } from './paths' +import { assertCliDataDirCompatible } from './runtime-compat' + +function resolveNativeBinding(): string | undefined { + if (process.versions.electron) return undefined + const nativePath = resolveCliPath('native/better_sqlite3.node') + if (fs.existsSync(nativePath)) return nativePath + return undefined +} + +export function initMcpRuntime(): DatabaseManager { + const config = loadConfig() + const userDataDir = config.data.user_data_dir || undefined + const pathProvider = new NodePathProvider(userDataDir) + pathProvider.ensureAllDirs() + const runtime = assertCliDataDirCompatible(pathProvider, 'mcp') + + if (hasPendingElectronDataWarning() || !verifyCliDataPath(pathProvider.getDatabaseDir())) { + console.error('[MCP] Electron desktop data detected but databases not found.') + console.error('[MCP] Set CHATLAB_DATA_DIR or edit ~/.chatlab/config.toml to point to your data directory.') + process.exit(1) + } + + const nativeBinding = resolveNativeBinding() + return new DatabaseManager(pathProvider, { nativeBinding, runtime }) +} + +export async function startCliMcpServer(): Promise { + const dbManager = initMcpRuntime() + await startMcpServer({ version: getVersion(), dbManager }) +} diff --git a/apps/cli/src/merger/merge-cache.ts b/apps/cli/src/merger/merge-cache.ts new file mode 100644 index 000000000..cdee8d281 --- /dev/null +++ b/apps/cli/src/merger/merge-cache.ts @@ -0,0 +1 @@ +export { MergeSessionCache } from '@openchatlab/node-runtime' diff --git a/apps/cli/src/paths.ts b/apps/cli/src/paths.ts new file mode 100644 index 000000000..ddc1c5375 --- /dev/null +++ b/apps/cli/src/paths.ts @@ -0,0 +1,8 @@ +import path from 'path' +import { fileURLToPath } from 'url' + +export function resolveCliPath(...segments: string[]): string { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)) + const packageRoot = path.basename(moduleDir) === 'dist' ? path.dirname(moduleDir) : path.resolve(moduleDir, '..') + return path.resolve(packageRoot, ...segments) +} diff --git a/apps/cli/src/release-workflow.test.ts b/apps/cli/src/release-workflow.test.ts new file mode 100644 index 000000000..960d4499a --- /dev/null +++ b/apps/cli/src/release-workflow.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const releaseWorkflow = readFileSync(new URL('../../../.github/workflows/release.yml', import.meta.url), 'utf-8') + +function extractSection(source: string, startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker) + assert.notEqual(start, -1, `Missing workflow section: ${startMarker}`) + + const end = source.indexOf(endMarker, start + startMarker.length) + assert.notEqual(end, -1, `Missing workflow section terminator: ${endMarker}`) + + return source.slice(start, end) +} + +test('release CLI build injects the Aptabase app key before tsup inlines env values', () => { + const buildCliJob = extractSection(releaseWorkflow, ' build-cli:', ' release:') + const buildCliStep = extractSection( + buildCliJob, + ' - name: Build CLI (with Web UI)', + ' - name: Upload CLI build output' + ) + + assert.match(buildCliStep, /run: pnpm --filter chatlab-cli run build:full/) + assert.match(buildCliStep, /APTABASE_APP_KEY: \$\{\{ secrets\.APTABASE_APP_KEY \}\}/) +}) diff --git a/apps/cli/src/runtime-compat-startup.test.ts b/apps/cli/src/runtime-compat-startup.test.ts new file mode 100644 index 000000000..fe8bc0884 --- /dev/null +++ b/apps/cli/src/runtime-compat-startup.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { startHttpServer, stopHttpServer } from './http' +import { initMcpRuntime } from './mcp' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-startup-')) +} + +function writeIncompatibleMeta(userDataDir: string): void { + fs.mkdirSync(userDataDir, { recursive: true }) + fs.writeFileSync( + path.join(userDataDir, '.chatlab-meta.json'), + JSON.stringify({ + formatVersion: 1, + minRuntimeVersion: '999.0.0', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '999.0.0' }, + updatedAt: 1780830000, + }), + 'utf-8' + ) +} + +async function withDataDirEnv(userDataDir: string, fn: () => Promise | T): Promise { + const previousDataDir = process.env.CHATLAB_DATA_DIR + const previousOverride = process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR + process.env.CHATLAB_DATA_DIR = userDataDir + delete process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR + + try { + return await fn() + } finally { + if (previousDataDir === undefined) { + delete process.env.CHATLAB_DATA_DIR + } else { + process.env.CHATLAB_DATA_DIR = previousDataDir + } + + if (previousOverride === undefined) { + delete process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR + } else { + process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR = previousOverride + } + } +} + +test('startHttpServer fails before serving when data directory requires a newer runtime', async () => { + const userDataDir = makeTempDir() + writeIncompatibleMeta(userDataDir) + + await withDataDirEnv(userDataDir, async () => { + await assert.rejects( + () => startHttpServer({ port: 0, host: '127.0.0.1', token: 'test_token', requireAuth: false }), + /requires ChatLab 999\.0\.0 or newer/ + ) + }) + + await stopHttpServer() +}) + +test('initMcpRuntime fails before starting MCP when data directory requires a newer runtime', async () => { + const userDataDir = makeTempDir() + writeIncompatibleMeta(userDataDir) + + await withDataDirEnv(userDataDir, () => { + assert.throws(() => initMcpRuntime(), /requires ChatLab 999\.0\.0 or newer/) + }) +}) diff --git a/apps/cli/src/runtime-compat.test.ts b/apps/cli/src/runtime-compat.test.ts new file mode 100644 index 000000000..d947b4ce5 --- /dev/null +++ b/apps/cli/src/runtime-compat.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { PathProvider } from '@openchatlab/core' +import { assertCliDataDirCompatible } from './runtime-compat' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-compat-')) +} + +function makePathProvider(userDataDir: string): PathProvider { + return { + getSystemDir: () => path.join(userDataDir, '..', 'system'), + getUserDataDir: () => userDataDir, + getDatabaseDir: () => path.join(userDataDir, 'databases'), + getVectorDir: () => path.join(userDataDir, 'vector'), + getAiDataDir: () => path.join(userDataDir, '..', 'system', 'ai'), + getSettingsDir: () => path.join(userDataDir, '..', 'system', 'settings'), + getCacheDir: () => path.join(userDataDir, '..', 'system', 'cache'), + getTempDir: () => path.join(userDataDir, '..', 'system', 'temp'), + getLogsDir: () => path.join(userDataDir, '..', 'system', 'logs'), + getDownloadsDir: () => path.join(userDataDir, '..', 'downloads'), + } +} + +test('assertCliDataDirCompatible formats a startup error for older CLI runtimes', () => { + const userDataDir = makeTempDir() + fs.writeFileSync( + path.join(userDataDir, '.chatlab-meta.json'), + JSON.stringify({ + formatVersion: 1, + minRuntimeVersion: '999.0.0', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '999.0.0' }, + updatedAt: 1780830000, + }), + 'utf-8' + ) + + assert.throws( + () => assertCliDataDirCompatible(makePathProvider(userDataDir), 'cli', { version: '0.25.0' }), + (error) => { + assert.ok(error instanceof Error) + assert.match(error.message, /requires ChatLab 999\.0\.0 or newer/) + assert.match(error.message, /Current cli version: 0\.25\.0/) + assert.match(error.message, new RegExp(userDataDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.match(error.message, /npm install -g chatlab-cli@latest/) + return true + } + ) +}) diff --git a/apps/cli/src/runtime-compat.ts b/apps/cli/src/runtime-compat.ts new file mode 100644 index 000000000..25314b9c2 --- /dev/null +++ b/apps/cli/src/runtime-compat.ts @@ -0,0 +1,45 @@ +import type { PathProvider } from '@openchatlab/core' +import { + assertDataDirCompatible, + DataDirCompatibilityError, + type RuntimeIdentity, + type RuntimeKind, +} from '@openchatlab/node-runtime' +import { getVersion } from './version' + +export function createCliRuntimeIdentity(kind: Extract): RuntimeIdentity { + return { version: getVersion(), kind } +} + +export function assertCliDataDirCompatible( + pathProvider: PathProvider, + kind: Extract, + options?: { version?: string } +): RuntimeIdentity { + const runtime: RuntimeIdentity = { version: options?.version ?? getVersion(), kind } + + try { + assertDataDirCompatible(pathProvider, runtime) + } catch (error) { + if ( + error instanceof DataDirCompatibilityError && + error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' && + error.minRuntimeVersion + ) { + throw new Error(formatCliDataDirCompatibilityError(error, runtime), { cause: error }) + } + + throw error + } + + return runtime +} + +function formatCliDataDirCompatibilityError(error: DataDirCompatibilityError, runtime: RuntimeIdentity): string { + return [ + `ChatLab data directory requires ChatLab ${error.minRuntimeVersion} or newer.`, + `Current ${runtime.kind} version: ${runtime.version}.`, + `Data directory: ${error.userDataDir}.`, + 'Upgrade: npm install -g chatlab-cli@latest', + ].join('\n') +} diff --git a/apps/cli/src/sync/adapters.test.ts b/apps/cli/src/sync/adapters.test.ts new file mode 100644 index 000000000..01a22eeec --- /dev/null +++ b/apps/cli/src/sync/adapters.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { PathProvider } from '@openchatlab/core' +import { DatabaseManager, DataDirCompatibilityError, raiseDataDirMinRuntimeVersion } from '@openchatlab/node-runtime' +import { DirectImporter } from './adapters' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-sync-')) +} + +function createPathProvider(root: string): PathProvider { + return { + getSystemDir: () => root, + getUserDataDir: () => path.join(root, 'data'), + getDatabaseDir: () => path.join(root, 'data', 'databases'), + getVectorDir: () => path.join(root, 'data', 'vector'), + getAiDataDir: () => path.join(root, 'ai'), + getSettingsDir: () => path.join(root, 'settings'), + getCacheDir: () => path.join(root, 'cache'), + getTempDir: () => path.join(root, 'temp'), + getLogsDir: () => path.join(root, 'logs'), + getDownloadsDir: () => path.join(root, 'downloads'), + } +} + +test('DirectImporter.sessionExists preserves session DBs when compatibility gate blocks access', () => { + const root = makeTempDir() + const pathProvider = createPathProvider(root) + const dbDir = pathProvider.getDatabaseDir() + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'blocked.db') + fs.writeFileSync(dbPath, 'sqlite content is not opened before the compatibility check', 'utf-8') + raiseDataDirMinRuntimeVersion(pathProvider, { + minRuntimeVersion: '0.26.0', + dataCompatibilityVersion: 2, + reason: 'future-schema', + runtime: { version: '0.26.0', kind: 'desktop' }, + module: 'future-migration', + now: () => 1780830000, + }) + + const manager = new DatabaseManager(pathProvider, { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + const importer = new DirectImporter(manager) + + assert.throws( + () => importer.sessionExists('blocked'), + (error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' + ) + assert.equal(fs.existsSync(dbPath), true) +}) diff --git a/apps/cli/src/sync/adapters.ts b/apps/cli/src/sync/adapters.ts new file mode 100644 index 000000000..fe3600bf3 --- /dev/null +++ b/apps/cli/src/sync/adapters.ts @@ -0,0 +1,156 @@ +/** + * Server-side implementations of @openchatlab/sync abstractions. + * + * NodeFetcher: uses Node.js fetch API + * DirectImporter: uses DatabaseManager + streamImport/incrementalImport + * NoopNotifier: placeholder (future: SSE push) + */ + +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import * as crypto from 'crypto' +import type { HttpFetcher, DataImporter, SyncNotifier, ImportResult, FetchParams, SyncLogger } from '@openchatlab/sync' +import { NOOP_LOGGER } from '@openchatlab/sync' +import { buildPullUrl } from '@openchatlab/sync' +import type { DatabaseManager } from '@openchatlab/node-runtime' +import { DataDirCompatibilityError } from '@openchatlab/node-runtime' +import { streamImport, incrementalImport } from '../import/stream-import' + +function getTempFilePath(ext: string): string { + const id = crypto.randomBytes(8).toString('hex') + return path.join(os.tmpdir(), `chatlab-pull-${id}${ext}`) +} + +// ==================== NodeFetcher ==================== + +export class NodeFetcher implements HttpFetcher { + async fetchToTempFile(baseUrl: string, remoteSessionId: string, token: string, params: FetchParams): Promise { + const url = buildPullUrl(baseUrl, remoteSessionId, params) + const headers: Record = { + Accept: 'application/json', + } + if (token) headers['Authorization'] = `Bearer ${token}` + + const response = await fetch(url, { headers, signal: AbortSignal.timeout(120_000) }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const contentType = response.headers.get('content-type') || 'application/json' + const isJsonl = contentType.includes('ndjson') || contentType.includes('jsonl') + const tempFile = getTempFilePath(isJsonl ? '.jsonl' : '.json') + + const buffer = Buffer.from(await response.arrayBuffer()) + fs.writeFileSync(tempFile, buffer) + return tempFile + } +} + +// ==================== DirectImporter ==================== + +export class DirectImporter implements DataImporter { + private dbManager: DatabaseManager + private logger: SyncLogger + + constructor(dbManager: DatabaseManager, logger?: SyncLogger) { + this.dbManager = dbManager + this.logger = logger ?? NOOP_LOGGER + } + + sessionExists(sessionId: string): boolean { + const dbPath = this.dbManager.getDbPath(sessionId) + if (!fs.existsSync(dbPath)) return false + try { + const db = this.dbManager.openRawSessionDatabase(sessionId, { readonly: true }) + const row = db + .prepare("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name='message'") + .get() as { cnt: number } + db.close() + if (row.cnt === 0) { + this.logger.warn(`[DirectImporter] DB file exists but has no message table: ${sessionId}, removing`) + try { + fs.unlinkSync(dbPath) + } catch { + /* ignore */ + } + return false + } + return true + } catch (error) { + if (error instanceof DataDirCompatibilityError) throw error + + this.logger.warn(`[DirectImporter] Cannot validate DB file: ${sessionId}, removing`) + try { + fs.unlinkSync(dbPath) + } catch { + /* ignore */ + } + return false + } + } + + async importFile(tempFile: string, targetSessionId: string | undefined, externalId: string): Promise { + if (targetSessionId && this.sessionExists(targetSessionId)) { + return this.incrementalImportFile(targetSessionId, tempFile) + } + + if (targetSessionId) { + this.logger.info(`[DirectImporter] Session ${targetSessionId} not found locally, need full resync`) + return { success: false, newMessageCount: 0, needFullResync: true } + } + + return this.fullImportFile(tempFile, externalId) + } + + private async incrementalImportFile(sessionId: string, tempFile: string): Promise { + try { + this.dbManager.close(sessionId) + const result = await incrementalImport(this.dbManager, sessionId, tempFile) + + if (result.success) { + const newMessageCount = result.newMessageCount + const duplicateCount = result.batch?.duplicateCount ?? 0 + this.logger.info( + `[DirectImporter] Incremental OK: +${newMessageCount} messages (${duplicateCount} duplicates skipped)` + ) + return { success: true, newMessageCount, sessionId } + } + + if (result.error === 'error.session_not_found' || result.error?.includes('no such table')) { + return { success: false, newMessageCount: 0, sessionId, needFullResync: true } + } + + return { success: false, newMessageCount: 0, sessionId, error: result.error } + } catch (err: any) { + this.logger.error(`[DirectImporter] Incremental import failed`, err) + return { success: false, newMessageCount: 0, sessionId, error: err.message } + } + } + + private async fullImportFile(tempFile: string, externalId: string): Promise { + try { + const result = await streamImport(this.dbManager, tempFile, { sessionId: externalId }) + + if (result.success) { + const newMessageCount = result.diagnostics?.messagesWritten ?? 0 + this.logger.info(`[DirectImporter] Full import OK: +${newMessageCount} messages`) + return { success: true, newMessageCount, sessionId: result.sessionId ?? externalId } + } + + return { success: false, newMessageCount: 0, error: result.error } + } catch (err: any) { + this.logger.error(`[DirectImporter] Full import failed`, err) + return { success: false, newMessageCount: 0, error: err.message } + } + } +} + +// ==================== NoopNotifier ==================== + +const noop = () => {} + +export class NoopNotifier implements SyncNotifier { + onSessionListChanged = noop + onPullResult = noop +} diff --git a/apps/cli/src/sync/index.ts b/apps/cli/src/sync/index.ts new file mode 100644 index 000000000..829b1d148 --- /dev/null +++ b/apps/cli/src/sync/index.ts @@ -0,0 +1,77 @@ +/** + * ChatLab Server — Sync module entry point + * + * Wires @openchatlab/sync with server-side implementations. + */ + +import type { FastifyInstance } from 'fastify' +import type { DatabaseManager } from '@openchatlab/node-runtime' +import { PreferencesManager, createDatabaseManagerAdapter, ownerProfileService } from '@openchatlab/node-runtime' +import type { PathProvider } from '@openchatlab/core' +import { DataSourceManager, PullEngine, initScheduler, stopAllTimers } from '@openchatlab/sync' +import type { SyncLogger } from '@openchatlab/sync' +import { NodeFetcher, DirectImporter, NoopNotifier } from './adapters' +import { registerAutomationRoutes } from './routes' + +export interface SyncRouteContext { + dsManager: DataSourceManager + pullEngine: PullEngine + dbManager: DatabaseManager + serverInfo: { port: number; host: string; token: string } +} + +const syncLogger: SyncLogger = { + info: (msg) => console.log(`[Sync] ${msg}`), + warn: (msg) => console.warn(`[Sync] ${msg}`), + error: (msg, err?) => console.error(`[Sync] ${msg}`, err ?? ''), +} + +let dsManager: DataSourceManager | null = null +let pullEngine: PullEngine | null = null + +export function initSync( + server: FastifyInstance, + dbManager: DatabaseManager, + pathProvider: PathProvider, + serverInfo: { port: number; host: string; token: string } +): void { + const settingsDir = pathProvider.getSettingsDir() + + dsManager = new DataSourceManager(settingsDir, syncLogger) + + const fetcher = new NodeFetcher() + const importer = new DirectImporter(dbManager, syncLogger) + const notifier = new NoopNotifier() + + const sessionAdapter = createDatabaseManagerAdapter(dbManager) + const preferences = new PreferencesManager(pathProvider.getSystemDir()) + + pullEngine = new PullEngine({ + fetcher, + importer, + notifier, + dsManager, + logger: syncLogger, + onSessionImported: (localSessionId) => { + preferences.invalidateCache() + const result = ownerProfileService.tryApplyOwnerProfile(sessionAdapter, preferences, localSessionId) + if (result.applied) { + syncLogger.info(`[Pull] Applied owner profile to session ${localSessionId} (owner: ${result.ownerId})`) + } + }, + }) + + registerAutomationRoutes(server, { dsManager, pullEngine, dbManager, serverInfo }) + + initScheduler({ + dsManager, + pullEngine, + logger: syncLogger, + }) +} + +export function cleanupSync(): void { + stopAllTimers() + dsManager = null + pullEngine = null +} diff --git a/apps/cli/src/sync/routes.test.ts b/apps/cli/src/sync/routes.test.ts new file mode 100644 index 000000000..835e41cef --- /dev/null +++ b/apps/cli/src/sync/routes.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import Fastify from 'fastify' +import { DataSourceManager } from '@openchatlab/sync' +import { registerAutomationRoutes } from './routes' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-sync-routes-')) +} + +test('DELETE import session deletes imported local session when deleteData=true', async () => { + const settingsDir = makeTempDir() + const dsManager = new DataSourceManager(settingsDir) + const ds = dsManager.add({ + baseUrl: 'http://example.com', + token: 'token', + intervalMinutes: 60, + }) + const [session] = dsManager.addSessions(ds.id, [{ name: 'First chat', remoteSessionId: 'remote-1' }]) + dsManager.updateSession(ds.id, session.id, { targetSessionId: 'local-1' }) + + const deletedSessionIds: string[] = [] + const app = Fastify() + registerAutomationRoutes(app, { + dsManager, + pullEngine: { + triggerPull: async () => ({ success: true, newMessageCount: 0 }), + triggerPullAll: async () => ({ success: true, newMessageCount: 0 }), + getProgress: () => [], + } as any, + dbManager: { + deleteSessionDatabaseFiles: (sessionId: string) => { + deletedSessionIds.push(sessionId) + }, + } as any, + serverInfo: { port: 5200, host: '127.0.0.1', token: 'api-token' }, + }) + await app.ready() + + const resp = await app.inject({ + method: 'DELETE', + url: `/_web/automation/data-sources/${ds.id}/sessions/${session.id}?deleteData=true`, + }) + + await app.close() + + assert.equal(resp.statusCode, 200) + assert.deepEqual(deletedSessionIds, ['local-1']) + assert.equal(dsManager.get(ds.id)?.sessions.length, 0) +}) diff --git a/apps/cli/src/sync/routes.ts b/apps/cli/src/sync/routes.ts new file mode 100644 index 000000000..8d1463414 --- /dev/null +++ b/apps/cli/src/sync/routes.ts @@ -0,0 +1,145 @@ +/** + * ChatLab Internal Web API — /_web/automation/ routes + * + * Exposes sync engine functionality to the Web UI via HTTP endpoints. + */ + +import type { FastifyInstance } from 'fastify' +import { + buildRemoteSessionsUrl, + parseRemoteSessionsResponse, + normalizeBaseUrl, + reloadTimer, + stopTimer, +} from '@openchatlab/sync' +import type { SyncRouteContext } from './index' + +export function registerAutomationRoutes(server: FastifyInstance, ctx: SyncRouteContext): void { + const { dsManager, pullEngine, dbManager, serverInfo } = ctx + + // ==================== Config (read-only in CLI mode) ==================== + + server.get('/_web/automation/config', async () => { + return { + enabled: true, + port: serverInfo.port, + token: serverInfo.token, + host: serverInfo.host, + } + }) + + // ==================== Data Sources ==================== + + server.get('/_web/automation/data-sources', async () => { + return dsManager.loadAll() + }) + + server.post<{ + Body: { name?: string; baseUrl: string; token: string; intervalMinutes: number; pullLimit?: number } + }>('/_web/automation/data-sources', async (request) => { + const ds = dsManager.add(request.body) + reloadTimer(ds.id) + return ds + }) + + server.patch<{ + Params: { id: string } + Body: { + name?: string + baseUrl?: string + token?: string + intervalMinutes?: number + pullLimit?: number + enabled?: boolean + } + }>('/_web/automation/data-sources/:id', async (request, reply) => { + const ds = dsManager.update(request.params.id, request.body) + if (!ds) return reply.code(404).send({ error: 'Data source not found' }) + reloadTimer(ds.id) + return ds + }) + + server.delete<{ Params: { id: string } }>('/_web/automation/data-sources/:id', async (request, reply) => { + stopTimer(request.params.id) + const ok = dsManager.delete(request.params.id) + if (!ok) return reply.code(404).send({ error: 'Data source not found' }) + return { success: true } + }) + + // ==================== Import Sessions ==================== + + server.post<{ + Params: { id: string } + Body: { sessions: Array<{ name: string; remoteSessionId: string }> } + }>('/_web/automation/data-sources/:id/sessions', async (request, reply) => { + const added = dsManager.addSessions(request.params.id, request.body.sessions) + if (added.length === 0 && !dsManager.get(request.params.id)) { + return reply.code(404).send({ error: 'Data source not found' }) + } + reloadTimer(request.params.id, true) + for (const sess of added) { + pullEngine.triggerPull(request.params.id, sess.id).catch(() => {}) + } + return added + }) + + server.delete<{ + Params: { id: string; sessId: string } + Querystring: { deleteData?: string } + }>('/_web/automation/data-sources/:id/sessions/:sessId', async (request, reply) => { + const removed = dsManager.removeSession(request.params.id, request.params.sessId) + if (!removed) return reply.code(404).send({ error: 'Session not found' }) + reloadTimer(request.params.id) + if (request.query.deleteData === 'true' && removed.targetSessionId) { + dbManager.deleteSessionDatabaseFiles(removed.targetSessionId) + } + return { success: true } + }) + + // ==================== Pull / Sync ==================== + + server.post<{ Params: { id: string }; Body: { sessionId?: string } }>( + '/_web/automation/data-sources/:id/pull', + async (request) => { + return pullEngine.triggerPull(request.params.id, request.body?.sessionId) + } + ) + + server.post<{ Params: { id: string } }>('/_web/automation/data-sources/:id/pull-all', async (request) => { + return pullEngine.triggerPullAll(request.params.id) + }) + + // ==================== Sync Progress ==================== + + server.get('/_web/automation/sync-progress', async () => { + return pullEngine.getProgress() + }) + + // ==================== Remote Session Discovery ==================== + + server.get<{ + Querystring: { baseUrl: string; token?: string; keyword?: string; limit?: string; cursor?: string } + }>('/_web/automation/remote-sessions', async (request, reply) => { + const { baseUrl, token, keyword, limit, cursor } = request.query + if (!baseUrl) return reply.code(400).send({ error: 'baseUrl is required' }) + + const normalizedUrl = normalizeBaseUrl(baseUrl) + const url = buildRemoteSessionsUrl(normalizedUrl, { + keyword, + limit: limit ? parseInt(limit, 10) : undefined, + cursor, + }) + + const headers: Record = { Accept: 'application/json' } + if (token) headers['Authorization'] = `Bearer ${token}` + + try { + const resp = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) }) + if (!resp.ok) throw new Error(`Remote server returned HTTP ${resp.status}`) + const body = await resp.text() + return parseRemoteSessionsResponse(body) + } catch (err: any) { + return reply.code(502).send({ error: err.message || 'Failed to fetch remote sessions' }) + } + }) +} diff --git a/apps/cli/src/update-checker.test.ts b/apps/cli/src/update-checker.test.ts new file mode 100644 index 000000000..8ef07b7f2 --- /dev/null +++ b/apps/cli/src/update-checker.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { performCliSelfUpdate } from './update-checker' + +describe('performCliSelfUpdate', () => { + it('installs the latest chatlab-cli package globally', async () => { + const calls: Array<{ command: string; args: string[] }> = [] + + const result = await performCliSelfUpdate({ + runCommand: async (command, args) => { + calls.push({ command, args }) + return { success: true } + }, + write: () => {}, + platform: 'darwin', + }) + + assert.deepEqual(calls, [ + { + command: 'npm', + args: ['install', '-g', 'chatlab-cli@latest'], + }, + ]) + assert.deepEqual(result, { success: true }) + }) +}) diff --git a/apps/cli/src/update-checker.ts b/apps/cli/src/update-checker.ts new file mode 100644 index 000000000..947ae6dc2 --- /dev/null +++ b/apps/cli/src/update-checker.ts @@ -0,0 +1,277 @@ +/** + * CLI startup update checker — npm registry version comparison + * with interactive prompt (Codex-style). + * + * Caches check results to avoid hitting npm on every invocation. + * Respects user "skip until next version" preference. + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import { execFile } from 'child_process' +import { getVersion } from './version' + +const PACKAGE_NAME = 'chatlab-cli' +const CACHE_STALE_MS = 6 * 60 * 60 * 1000 // 6h — startup cache TTL +const PERIODIC_CHECK_MS = 24 * 60 * 60 * 1000 // 24h — long-running service interval +const CACHE_FILE = path.join(os.homedir(), '.chatlab', 'update-check.json') + +interface UpdateCache { + lastCheckTime: number + latestVersion: string | null + skippedVersion?: string +} + +export interface UpdateCommandResult { + success: boolean + error?: string +} + +export interface PerformCliSelfUpdateOptions { + runCommand?: (command: string, args: string[]) => Promise + write?: (text: string) => void + platform?: NodeJS.Platform +} + +function readCache(): UpdateCache | null { + try { + if (fs.existsSync(CACHE_FILE)) { + return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8')) + } + } catch { + // corrupted cache + } + return null +} + +function writeCache(cache: UpdateCache): void { + try { + const dir = path.dirname(CACHE_FILE) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(CACHE_FILE, JSON.stringify(cache), 'utf-8') + } catch { + // non-critical + } +} + +function isNewerVersion(latest: string, current: string): boolean { + const parse = (v: string) => { + const [core, pre] = v.split('-', 2) + const parts = core.split('.').map(Number) + return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0, pre } + } + const l = parse(latest) + const c = parse(current) + if (l.major !== c.major) return l.major > c.major + if (l.minor !== c.minor) return l.minor > c.minor + if (l.patch !== c.patch) return l.patch > c.patch + // Design note: prerelease users are not prompted for prerelease-to-prerelease updates. + // Only a stable release with the same core version should supersede a prerelease build. + if (c.pre && !l.pre) return true + return false +} + +async function fetchLatestVersion(): Promise { + try { + const resp = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(8_000), + }) + if (!resp.ok) return null + const data = (await resp.json()) as { version?: string } + return data.version || null + } catch { + return null + } +} + +function promptUser(question: string, choices: string[]): Promise { + return new Promise((resolve) => { + let selected = 0 + + const render = () => { + // Move cursor up to rewrite choices (skip on first draw) + if (rendered) process.stderr.write(`\x1b[${choices.length}A`) + choices.forEach((c, i) => { + process.stderr.write(`\x1b[2K${i === selected ? '› ' : ' '}${i + 1}. ${c}\n`) + }) + } + + process.stderr.write(`${question}\n\n`) + + if (!process.stdin.isTTY || !process.stdin.setRawMode) { + resolve(2) + return + } + + let rendered = false + render() + rendered = true + process.stderr.write('\n') + + process.stdin.setRawMode(true) + process.stdin.resume() + + const cleanup = () => { + process.stdin.removeListener('data', onData) + process.stdin.setRawMode!(false) + process.stdin.pause() + } + + const onData = (data: Buffer) => { + const key = data.toString() + if (key === '\x03') { + cleanup() + process.exit(0) + } + // Arrow up / k + if (key === '\x1b[A' || key === 'k') { + selected = (selected - 1 + choices.length) % choices.length + render() + return + } + // Arrow down / j + if (key === '\x1b[B' || key === 'j') { + selected = (selected + 1) % choices.length + render() + return + } + // Enter — confirm current selection + if (key === '\r' || key === '\n') { + cleanup() + process.stderr.write('\n') + resolve(selected + 1) + return + } + // Digit key — direct choice + if (key.length === 1) { + const num = parseInt(key) + if (num >= 1 && num <= choices.length) { + cleanup() + process.stderr.write('\n') + resolve(num) + } + } + } + process.stdin.on('data', onData) + }) +} + +function runNpmUpdateCommand( + command: string, + args: string[], + write: (text: string) => void +): Promise { + return new Promise((resolve) => { + const child = execFile(command, args, { timeout: 120_000 }, (err, _stdout, stderr) => { + if (err) { + resolve({ success: false, error: stderr || err.message }) + } else { + resolve({ success: true }) + } + }) + child.stdout?.on('data', (chunk) => write(String(chunk))) + child.stderr?.on('data', (chunk) => write(String(chunk))) + }) +} + +export function performCliSelfUpdate(options: PerformCliSelfUpdateOptions = {}): Promise { + const platformName = options.platform ?? process.platform + const npmCmd = platformName === 'win32' ? 'npm.cmd' : 'npm' + const args = ['install', '-g', `${PACKAGE_NAME}@latest`] + const write = options.write ?? ((text) => process.stderr.write(text)) + const runCommand = options.runCommand ?? ((command, commandArgs) => runNpmUpdateCommand(command, commandArgs, write)) + + write(`\n Running: ${npmCmd} ${args.join(' ')}\n\n`) + return runCommand(npmCmd, args) +} + +function isDevEnvironment(): boolean { + if (process.env.CHATLAB_SKIP_UPDATE_CHECK) return true + if (process.env.NODE_ENV === 'development') return true + const entryFile = process.argv[1] || '' + return entryFile.endsWith('.ts') || entryFile.endsWith('.mts') +} + +/** + * Fire-and-forget: fetch latest version from npm and update local cache. + * Runs in background so CLI startup is never blocked by network IO. + */ +function refreshCacheInBackground(existingCache: UpdateCache | null): void { + fetchLatestVersion() + .then((latestVersion) => { + writeCache({ + lastCheckTime: Date.now(), + latestVersion, + skippedVersion: existingCache?.skippedVersion, + }) + }) + .catch(() => {}) +} + +/** + * Start a periodic background cache refresh for long-running services. + * Immediately refreshes once on startup, then every 24h thereafter. + * The update prompt will appear on the *next* CLI startup. + * The timer is unref'd so it won't prevent process exit. + */ +export function startPeriodicUpdateCheck(): void { + if (isDevEnvironment()) return + refreshCacheInBackground(readCache()) + const timer = setInterval(() => { + refreshCacheInBackground(readCache()) + }, PERIODIC_CHECK_MS) + timer.unref() +} + +/** + * Check for updates and prompt user interactively. + * + * Strategy (Codex-style): + * - Read local cache synchronously (instant, no network). + * - If cache is stale, kick off a background fetch for *next* run. + * - Prompt is only shown when cache already contains a newer version. + * - This means the first run after a new release sees no prompt; + * the second run (with fresh cache) shows it — zero startup delay. + */ +export async function checkForUpdatesInteractive(): Promise { + if (!process.stdin.isTTY || process.env.CI || isDevEnvironment()) return + + const currentVersion = getVersion() + const cache = readCache() + + // Kick off background refresh if cache is missing or stale + if (!cache || Date.now() - cache.lastCheckTime >= CACHE_STALE_MS) { + refreshCacheInBackground(cache) + } + + // Prompt is based on cached data only — no network wait + const latestVersion = cache?.latestVersion + if (!latestVersion || !isNewerVersion(latestVersion, currentVersion)) return + if (cache?.skippedVersion === latestVersion) return + + const choice = await promptUser(` ✨ Update available! ${currentVersion} → ${latestVersion}`, [ + `Update now (runs \`npm install -g ${PACKAGE_NAME}\`)`, + 'Skip', + 'Skip until next version', + ]) + + if (choice === 1) { + const result = await performCliSelfUpdate() + if (result.success) { + process.stderr.write( + ` \x1b[32m🎉 Updated successfully! Please restart chatlab to use the new version.\x1b[0m\n\n` + ) + process.exit(0) + } else { + process.stderr.write(` ❌ Update failed: ${result.error}\n\n`) + } + } else if (choice === 3) { + writeCache({ + lastCheckTime: Date.now(), + latestVersion, + skippedVersion: latestVersion, + }) + } +} diff --git a/apps/cli/src/version.ts b/apps/cli/src/version.ts new file mode 100644 index 000000000..a0e118bf6 --- /dev/null +++ b/apps/cli/src/version.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'fs' +import { resolveCliPath } from './paths' + +let cached: string | undefined + +/** + * Read version from apps/cli/package.json at runtime. + * Works for both tsx dev (src/) and bundled (dist/). + */ +export function getVersion(): string { + if (cached) return cached + try { + const pkgPath = resolveCliPath('package.json') + cached = JSON.parse(readFileSync(pkgPath, 'utf-8')).version + } catch { + cached = '0.0.0-dev' + } + return cached! +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 000000000..15682e5bf --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "composite": true, + "noEmit": true + }, + "include": [ + "src/**/*.ts", + "../../packages/shared-types/**/*", + "../../packages/core/**/*", + "../../packages/node-runtime/**/*", + "../../packages/config/**/*", + "../../packages/parser/**/*", + "../../packages/tools/**/*", + "../../packages/sync/**/*", + "../../packages/mcp-server/**/*", + "../../packages/http-routes/**/*" + ] +} diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts new file mode 100644 index 000000000..09a70a1ca --- /dev/null +++ b/apps/cli/tsup.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { + cli: 'src/cli.ts', + index: 'src/index.ts', + 'semantic-index-worker': '../../packages/node-runtime/src/semantic-index/worker-thread-entry.ts', + 'contacts-worker': '../../packages/node-runtime/src/services/contacts/worker-entry.ts', + 'people-relationships-worker': '../../packages/node-runtime/src/services/people/relationships/worker-entry.ts', + }, + format: ['esm'], + outDir: 'dist', + outExtension: () => ({ js: '.mjs' }), + splitting: true, + sourcemap: true, + clean: true, + target: 'node20', + platform: 'node', + define: { + 'process.env.APTABASE_APP_KEY': JSON.stringify(process.env.APTABASE_APP_KEY || ''), + }, + noExternal: [/^@openchatlab\//, 'chatlab-mcp', 'stream-json'], + external: ['better-sqlite3', '@node-rs/jieba'], + banner: { + js: [ + "import { createRequire as __createRequire } from 'module';", + "import { dirname as __pathDirname } from 'path';", + "import { fileURLToPath as __fileURLToPath } from 'url';", + 'const require = __createRequire(import.meta.url);', + 'const __filename = __fileURLToPath(import.meta.url);', + 'const __dirname = __pathDirname(__filename);', + ].join('\n'), + }, +}) diff --git a/build/entitlements.mac.plist b/apps/desktop/build/entitlements.mac.plist similarity index 100% rename from build/entitlements.mac.plist rename to apps/desktop/build/entitlements.mac.plist diff --git a/apps/desktop/build/icon.icns b/apps/desktop/build/icon.icns new file mode 100644 index 000000000..6d37cfb24 Binary files /dev/null and b/apps/desktop/build/icon.icns differ diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico new file mode 100644 index 000000000..b6320e3a2 Binary files /dev/null and b/apps/desktop/build/icon.ico differ diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png new file mode 100644 index 000000000..229fedfe8 Binary files /dev/null and b/apps/desktop/build/icon.png differ diff --git a/build/installer-dpi.nsh b/apps/desktop/build/installer-dpi.nsh similarity index 100% rename from build/installer-dpi.nsh rename to apps/desktop/build/installer-dpi.nsh diff --git a/electron-builder.yml b/apps/desktop/electron-builder.yml similarity index 54% rename from electron-builder.yml rename to apps/desktop/electron-builder.yml index 1172b4028..76fe5d26c 100644 --- a/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -5,7 +5,7 @@ productName: ChatLab # 发布配置 publish: provider: github - owner: hellodigua + owner: ChatLab repo: ChatLab # 构建资源所在的目录 directories: @@ -14,14 +14,32 @@ directories: files: - out/**/* - '!**/.vscode/*' - - '!src/*' - '!electron.vite.config.{js,ts,mjs,cjs}' - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' + - '!{tsconfig.json,tsconfig.node.json}' + # jieba 词库文件已改为启动时远程下载,无需打包内置 + - '!node_modules/@node-rs/jieba/dict.txt' + - '!node_modules/@node-rs/jieba/idf.txt' + - '!node_modules/@node-rs/jieba/dict.js' + - '!node_modules/@node-rs/jieba/dict.d.ts' + # onnxruntime-node 自带 darwin/linux/win32 全平台全架构原生二进制(合计 ~210M)。 + # 只保留当前构建目标 ${platform}/${arch} 一份,避免 mac 包含 Linux/Windows 二进制(反之亦然)。 + # 先排除整组 bin/napi-v6,再按目标平台/架构重新包含;electron-builder 后写规则覆盖先写规则。 + # ${platform} -> darwin/linux/win32,${arch} -> x64/arm64,正好匹配该包目录结构。 + - '!node_modules/onnxruntime-node/bin/napi-v6/**' + - 'node_modules/onnxruntime-node/bin/napi-v6/${platform}/${arch}/**' # 哪些文件将不会被压缩,而是解压到构建目录 +# 仅含 .dylib(无 .node)的原生包不会被 electron-builder 自动解包,必须显式列出, +# 否则 dlopen 无法从 asar 内加载: +# - sqlite-vec*:vec0.dylib(SQLite 扩展) +# - onnxruntime-node:onnxruntime_binding.node + libonnxruntime.*.dylib(本地向量模型推理) +# - @img/**:sharp 原生绑定与 libvips-cpp.dylib(transformers 本地模型 eager require sharp) asarUnpack: - resources/** + - node_modules/sqlite-vec*/** + - node_modules/onnxruntime-node/** + - node_modules/@img/** # Windows 平台配置 win: @@ -40,6 +58,7 @@ nsis: installerIcon: build/icon.ico uninstallerIcon: build/icon.ico include: installer-dpi.nsh + differentialPackage: false # macOS 平台配置 mac: @@ -53,34 +72,14 @@ mac: - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. target: + # 架构由 CI 通过 --x64/--arm64 指定,避免在单个 job 里同时打双架构 - target: dmg - arch: - - x64 - - arm64 - target: zip - arch: - - x64 - - arm64 artifactName: ChatLab-${version}-${arch}.${ext} # macOS 平台的 DMG 配置 dmg: artifactName: ChatLab-${version}-${arch}.${ext} -# Linux 平台配置 -linux: - executableName: chatlab - icon: build/icon.png - target: - - AppImage - - deb - - rpm - - tar.gz - category: Utility - -# AppImage 配置 -appImage: - artifactName: ChatLab-${version}.${ext} - # 是否在构建之前重新编译原生模块 -npmRebuild: false +npmRebuild: true diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts new file mode 100644 index 000000000..b151b7163 --- /dev/null +++ b/apps/desktop/electron.vite.config.ts @@ -0,0 +1,138 @@ +import { resolve } from 'path' +import { readFileSync } from 'fs' +import { defineConfig } from 'electron-vite' +import vue from '@vitejs/plugin-vue' +import ui from '@nuxt/ui/vite' + +const rootDir = resolve(__dirname, '../..') +const rootPkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8')) +const appVersion: string = rootPkg.version + +export default defineConfig({ + main: { + resolve: { + alias: { + '@openchatlab': resolve(rootDir, 'packages'), + }, + }, + define: { + __APP_VERSION__: JSON.stringify(appVersion), + 'process.env.APTABASE_APP_KEY': JSON.stringify(process.env.APTABASE_APP_KEY || ''), + // ws 的原生加速依赖是可选项;主进程打包时禁用它们,避免 Vite 将缺失的可选依赖改写为启动即抛错。 + 'process.env.WS_NO_BUFFER_UTIL': JSON.stringify('true'), + 'process.env.WS_NO_UTF_8_VALIDATE': JSON.stringify('true'), + }, + build: { + minify: 'esbuild', + rollupOptions: { + input: { + index: resolve(__dirname, 'main/index.ts'), + 'worker/dbWorker': resolve(__dirname, 'main/worker/dbWorker.ts'), + 'semantic-index-worker': resolve(rootDir, 'packages/node-runtime/src/semantic-index/worker-thread-entry.ts'), + 'contacts-worker': resolve(rootDir, 'packages/node-runtime/src/services/contacts/worker-entry.ts'), + 'people-relationships-worker': resolve( + rootDir, + 'packages/node-runtime/src/services/people/relationships/worker-entry.ts' + ), + }, + }, + }, + }, + preload: { + resolve: { + alias: { + '@openchatlab': resolve(rootDir, 'packages'), + }, + }, + build: { + minify: 'esbuild', + rollupOptions: { + input: { + index: resolve(__dirname, 'preload/index.ts'), + }, + }, + }, + }, + renderer: { + resolve: { + alias: { + '@': resolve(rootDir, 'src/'), + '~': resolve(rootDir, 'src/'), + '@openchatlab': resolve(rootDir, 'packages'), + '@electron': resolve(__dirname), + }, + }, + define: { + __IS_ELECTRON__: JSON.stringify(true), + }, + // 分析页(私聊/群聊)懒加载时才会引入图表/Markdown/截图等重依赖, + // 默认冷启动扫描发现不到,首次进入会触发二次依赖优化,导致已加载 chunk 失效报 + // 504 (Outdated Optimize Dep) 并使动态导入页面失败。这里显式预打包,避免运行中再次优化。 + optimizeDeps: { + include: [ + 'echarts/core', + 'echarts/charts', + 'echarts/components', + 'echarts/renderers', + 'echarts-wordcloud', + 'markdown-it', + '@zumer/snapdom', + '@tanstack/vue-virtual', + '@internationalized/date', + '@vueuse/core', + ], + }, + plugins: [ + vue(), + ui({ + ui: { + colors: { + primary: 'pink', + neutral: 'zinc', + }, + }, + }), + ], + root: resolve(rootDir, 'src/'), + build: { + sourcemap: false, + rollupOptions: { + input: { + index: resolve(rootDir, 'src/index.html'), + }, + output: { + manualChunks(id) { + if (id.includes('node_modules/echarts-wordcloud')) { + return 'vendor-echarts-wordcloud' + } + if (id.includes('node_modules/zrender')) { + return 'vendor-zrender' + } + if (id.includes('node_modules/echarts')) { + return 'vendor-echarts' + } + if (id.includes('node_modules/@nuxt/ui')) { + return 'vendor-nuxt-ui' + } + if (id.includes('node_modules/reka-ui')) { + return 'vendor-reka-ui' + } + if (id.includes('node_modules/@zumer/snapdom')) { + return 'vendor-snapdom' + } + return undefined + }, + }, + }, + }, + server: { + host: '0.0.0.0', + port: 13100, + hmr: { + protocol: 'ws', + host: 'localhost', + port: 13100, + }, + }, + }, +}) diff --git a/apps/desktop/main/ai/agent-stream-runner.ts b/apps/desktop/main/ai/agent-stream-runner.ts new file mode 100644 index 000000000..1b27eee57 --- /dev/null +++ b/apps/desktop/main/ai/agent-stream-runner.ts @@ -0,0 +1,300 @@ +/** + * Electron Agent stream runner — provides runAgentStream implementation + * for the shared HTTP route context. + * + * Mirrors the logic from ipc/ai.ts `agent:runStream` handler, + * but outputs events via callback instead of IPC. + */ + +import type { AgentStreamChunk as SharedAgentStreamChunk, SemanticIndexRuntime } from '@openchatlab/node-runtime' +import type { AgentStreamRequest } from '@openchatlab/http-routes' +import type { ChartAutoMode } from '@openchatlab/shared-types' +import { + buildSkillMenuWithBuiltinChart, + CHART_CAPABILITY_ANALYSIS_TOOLS, + checkAndCompress, + createCompressionLlmAdapter, + createDataSnapshotFromOverview, + formatAIError, + getAllowedBuiltinToolsForChartAutoSkill, + getChartCapabilitySkill, + initTokenizer, + resolveChartRuntimeForRequest, + buildSemanticSearchGuidance, +} from '@openchatlab/node-runtime' +import type { CompressionConfig, CompressionLlmAdapter, AgentRuntimeStatus } from '@openchatlab/node-runtime' +import { Agent, type AgentStreamChunk, type SkillContext } from './agent' +import type { ToolContext } from './tools/types' +import { getDefaultAssistantConfig, buildPiModel, findModelDefinition } from './llm' +import type { AIServiceConfig } from './llm/types' +import { getDefaultGeneralAssistantId } from './assistant/defaultGeneral' +import * as assistantManager from './assistant' +import type { AssistantConfig } from './assistant/types' +import * as skillManager from './skills' +import { aiLogger } from './logger' +import { serializeError } from './serialize-error' +import { getManager as getAIChatManager } from './chats' +import { t } from '../i18n' +import * as workerManager from '../worker/workerManager' +import { getProviderInfo, type LLMProvider } from './llm' + +const DEFAULT_CONTEXT_WINDOW = 128000 + +function resolveProviderName(provider?: LLMProvider): string { + if (provider === 'openai-compatible') return t('llm.genericProviderName') + return provider ? getProviderInfo(provider)?.name || provider : t('llm.genericProviderName') +} + +function buildCompressionAdapter(activeAIConfig: AIServiceConfig, onCompressing?: () => void): CompressionLlmAdapter { + const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '') + return createCompressionLlmAdapter({ + piModel: buildPiModel(activeAIConfig), + apiKey: activeAIConfig.apiKey, + contextWindow: modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW, + onCompressing, + onError: (error) => aiLogger.warn('Compression', 'LLM compression attempt failed', { error: String(error) }), + }) +} + +const compressionLogger = { + info: (cat: string, msg: string, extra?: Record) => aiLogger.info(cat, msg, extra), + warn: (cat: string, msg: string, extra?: Record) => aiLogger.warn(cat, msg, extra), + error: (cat: string, msg: string, extra?: Record) => aiLogger.error(cat, msg, extra), +} + +export function createElectronRunAgentStream( + semanticIndexService?: SemanticIndexRuntime +): ( + params: AgentStreamRequest, + onEvent: (chunk: SharedAgentStreamChunk) => void, + abortSignal: AbortSignal +) => Promise { + return async (params, onEvent, abortSignal) => { + const { + userMessage, + aiChatId, + historyLeafMessageId, + sessionId, + chatType, + locale, + assistantId, + skillId, + enableAutoSkill, + chartAutoMode, + compressionConfig, + ownerInfo, + mentionedMembers, + thinkingLevel, + } = params + + // 确保 tokenizer rank 表已加载(compression + agent 路径均依赖) + await initTokenizer() + + const requestId = `internal_${Date.now()}` + + aiLogger.info('AgentStream', `Agent stream request: ${requestId}`, { + userMessage: userMessage.slice(0, 100), + sessionId, + aiChatId, + chatType: chatType ?? 'group', + assistantId: assistantId ?? '(none)', + skillId: skillId ?? '(none)', + enableAutoSkill: enableAutoSkill ?? false, + }) + + const activeAIConfig = getDefaultAssistantConfig() + if (!activeAIConfig) { + onEvent({ type: 'error', error: { name: 'ConfigError', message: t('llm.notConfigured') } }) + return + } + const piModel = buildPiModel(activeAIConfig) + + if (compressionConfig?.enabled && aiChatId && historyLeafMessageId === undefined) { + try { + const tempAssistantConfig = assistantId + ? (assistantManager.getAssistantConfig(assistantId) ?? undefined) + : undefined + const systemPromptForCompression = tempAssistantConfig?.systemPrompt || '' + + const compressionResult = await checkAndCompress( + aiChatId, + compressionConfig as CompressionConfig, + systemPromptForCompression, + buildCompressionAdapter(activeAIConfig, () => { + onEvent({ + type: 'status', + status: { + phase: 'compressing', + round: 0, + toolsUsed: 0, + contextTokens: 0, + totalUsage: { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + updatedAt: Date.now(), + } satisfies AgentRuntimeStatus, + }) + }), + getAIChatManager(), + compressionLogger + ) + + if (compressionResult.compressed && compressionResult.summaryContent) { + onEvent({ + type: 'compression_done', + compressionResult: { + summaryContent: compressionResult.summaryContent, + tokensBefore: compressionResult.tokensBefore ?? 0, + tokensAfter: compressionResult.tokensAfter ?? 0, + timestamp: Date.now(), + }, + }) + } + } catch (error) { + aiLogger.error('AgentStream', `Compression failed: ${requestId}`, { error: String(error) }) + } + } + + const defaultAssistantId = getDefaultGeneralAssistantId(locale) + let resolvedAssistantId = assistantId || defaultAssistantId + let assistantConfig: AssistantConfig | undefined = + assistantManager.getAssistantConfig(resolvedAssistantId) ?? undefined + if (!assistantConfig && resolvedAssistantId !== defaultAssistantId) { + resolvedAssistantId = defaultAssistantId + assistantConfig = assistantManager.getAssistantConfig(defaultAssistantId) ?? undefined + } + + let skillCtx: SkillContext | undefined + const resolvedChartAutoMode: ChartAutoMode = chartAutoMode ?? 'suggest' + const chartRuntime = resolveChartRuntimeForRequest({ + skillId, + userMessage, + locale, + assistantAllowedTools: assistantConfig?.allowedBuiltinTools, + enableAutoDetection: enableAutoSkill === true, + chartAutoMode: resolvedChartAutoMode, + }) + if (chartRuntime.isChartCapability) { + const chartSkill = chartRuntime.skillDef ?? getChartCapabilitySkill(locale ?? 'zh-CN') + skillCtx = { skillDef: { ...chartSkill, chatScope: 'all' } as SkillContext['skillDef'] } + assistantConfig = { + ...(assistantConfig ?? { + id: resolvedAssistantId, + name: resolvedAssistantId, + systemPrompt: '', + presetQuestions: [], + }), + allowedBuiltinTools: chartRuntime.allowedBuiltinTools, + } + } else if (skillId) { + const skillDef = skillManager.getSkillConfig(skillId) ?? undefined + if (skillDef) { + skillCtx = { skillDef } + } + } else if (enableAutoSkill) { + const effectiveChatType = chatType ?? 'group' + const autoSkillAllowedTools = + resolvedChartAutoMode === 'explicit' + ? (assistantConfig?.allowedBuiltinTools ?? [...CHART_CAPABILITY_ANALYSIS_TOOLS]) + : (getAllowedBuiltinToolsForChartAutoSkill(assistantConfig?.allowedBuiltinTools) ?? [ + ...CHART_CAPABILITY_ANALYSIS_TOOLS, + ]) + assistantConfig = { + ...(assistantConfig ?? { + id: resolvedAssistantId, + name: resolvedAssistantId, + systemPrompt: '', + presetQuestions: [], + }), + allowedBuiltinTools: autoSkillAllowedTools, + } + const allowedTools = autoSkillAllowedTools + const baseMenu = skillManager.getSkillMenu(effectiveChatType, allowedTools) + const menu = + resolvedChartAutoMode === 'aggressive' + ? buildSkillMenuWithBuiltinChart(baseMenu, locale, allowedTools) + : baseMenu + if (menu) { + skillCtx = { skillMenu: menu } + } + } + + // 语义检索按需暴露:工具可用时向 system prompt 加简短引导(实际检索由 LLM 调用工具触发)。 + const canSemanticSearch = !!semanticIndexService && (await semanticIndexService.canSearch(sessionId)) + if (canSemanticSearch) { + const baseSystemPrompt = assistantConfig?.systemPrompt ?? '' + assistantConfig = { + ...(assistantConfig ?? { + id: resolvedAssistantId, + name: resolvedAssistantId, + systemPrompt: '', + presetQuestions: [], + }), + systemPrompt: [baseSystemPrompt, buildSemanticSearchGuidance(locale)].filter(Boolean).join('\n\n'), + } + } + + const maxToolResultPercent = compressionConfig?.maxToolResultPercent ?? 50 + const modelDef = findModelDefinition(activeAIConfig.provider, activeAIConfig.model || '') + const resolvedContextWindow = modelDef?.contextWindow || DEFAULT_CONTEXT_WINDOW + const maxToolResultTokens = Math.floor(resolvedContextWindow * (maxToolResultPercent / 100)) + + let dataSnapshot: ToolContext['dataSnapshot'] | undefined + try { + dataSnapshot = createDataSnapshotFromOverview(await workerManager.getChatOverview(sessionId, 10)) + } catch (error) { + aiLogger.warn('AgentStream', `Failed to load data snapshot: ${requestId}`, { error: String(error) }) + } + + const context: ToolContext = { + sessionId, + aiChatId, + historyLeafMessageId: historyLeafMessageId ?? undefined, + timeFilter: params.timeFilter, + maxMessagesLimit: params.maxMessagesLimit, + ownerInfo, + mentionedMembers: mentionedMembers as ToolContext['mentionedMembers'], + preprocessConfig: params.preprocessConfig as ToolContext['preprocessConfig'], + semanticIndexService, + maxToolResultTokens, + dataSnapshot, + abortSignal, + } + + const agent = new Agent( + context, + piModel, + activeAIConfig.apiKey, + { + abortSignal, + thinkingLevel: thinkingLevel as import('@openchatlab/core').ThinkingLevel | undefined, + chartAutoMode: resolvedChartAutoMode, + }, + chatType ?? 'group', + locale ?? 'zh-CN', + assistantConfig, + skillCtx + ) + + try { + await agent.executeStream(userMessage, (chunk: AgentStreamChunk) => { + if (abortSignal.aborted) return + onEvent(chunk as SharedAgentStreamChunk) + }) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') return + const serializedError = serializeError(error, activeAIConfig.provider) + serializedError.friendlyMessage = formatAIError(error, { + providerName: resolveProviderName(activeAIConfig.provider), + rawErrorLabel: t('llm.rawErrorLabel'), + }) + if (!serializedError.url && activeAIConfig.baseUrl) serializedError.url = activeAIConfig.baseUrl + aiLogger.error('AgentStream', `Agent execution error: ${requestId}`, serializedError) + onEvent({ type: 'error', error: serializedError, isFinished: true }) + } + } +} diff --git a/apps/desktop/main/ai/agent/index.ts b/apps/desktop/main/ai/agent/index.ts new file mode 100644 index 000000000..3f33fd1af --- /dev/null +++ b/apps/desktop/main/ai/agent/index.ts @@ -0,0 +1,416 @@ +/** + * AI Agent 执行器 + * 编排 runAgentCore 的对话流程(工具调用、流式输出、中止控制) + */ + +import { getDefaultAssistantConfig, buildPiModel } from '../llm' +import { getAllTools, createActivateSkillTool } from '../tools' +import type { ToolContext } from '../tools/types' +import { getHistoryForAgent, setPendingDebugContext } from '../chats' +import { aiLogger, isDebugMode } from '../logger' +import { t as i18nT } from '../../i18n' +import { + CHART_CAPABILITY_SKILL_ID, + DEFAULT_MAX_TOOL_ROUNDS, + buildPlanGuidance, + createAnalysisPlanner, + createLlmRouteDecider, + createPlanContentBlock, + decideRequestRoute, + getChartPlannerCapabilityForMessage, + shouldUseChartCapabilityForMessage, + runAgentCore, + streamSimple, + type PiMessage, + type PiAssistantMessage, + type PiModel, + type PiApi, +} from '@openchatlab/node-runtime' + +import type { AgentConfig, AgentStreamChunk, AgentResult, SkillContext } from './types' +import type { AssistantConfig } from '../assistant/types' +import { buildSystemPrompt } from './prompt-builder' +import { extractThinkingContent, stripToolCallTags } from '@openchatlab/core' +import { AgentEventHandler } from '@openchatlab/node-runtime' + +type SimpleHistoryMessage = { role: 'user' | 'assistant' | 'summary'; content: string } + +// Re-export types for external consumers +export type { AgentConfig, AgentStreamChunk, AgentResult, TokenUsage, AgentRuntimeStatus, SkillContext } from './types' + +/** + * Agent 执行器类 + * 处理带 Function Calling 的对话流程 + */ +export class Agent { + private context: ToolContext + private config: AgentConfig + private piModel: PiModel + private apiKey: string + private abortSignal?: AbortSignal + private chatType: 'group' | 'private' = 'group' + private assistantConfig?: AssistantConfig + private skillCtx?: SkillContext + private locale: string = 'zh-CN' + + constructor( + context: ToolContext, + piModel: PiModel, + apiKey: string, + config: AgentConfig = {}, + chatType: 'group' | 'private' = 'group', + locale: string = 'zh-CN', + assistantConfig?: AssistantConfig, + skillCtx?: SkillContext + ) { + this.context = context + this.piModel = piModel + this.apiKey = apiKey + this.abortSignal = config.abortSignal + this.chatType = chatType + this.assistantConfig = assistantConfig + this.skillCtx = skillCtx + this.locale = locale + this.config = { + maxToolRounds: config.maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS, + thinkingLevel: config.thinkingLevel, + chartAutoMode: config.chartAutoMode ?? 'suggest', + } + } + + private isAborted(): boolean { + return this.abortSignal?.aborted ?? false + } + + async execute(userMessage: string): Promise { + return this.executeStream(userMessage, () => {}) + } + + async executeStream(userMessage: string, onChunk: (chunk: AgentStreamChunk) => void): Promise { + aiLogger.info('Agent', 'User question', userMessage) + + const maxToolRounds = Math.max(0, this.config.maxToolRounds ?? 0) + + const systemPrompt = buildSystemPrompt( + this.chatType, + this.assistantConfig?.systemPrompt, + this.context.ownerInfo, + this.locale, + this.skillCtx, + this.context.mentionedMembers, + this.context.dataSnapshot + ) + const answerWithoutToolsPrompt = i18nT('ai.agent.answerWithoutTools', { lng: this.locale }) + + const handler = new AgentEventHandler({ + onChunk, + context: this.context, + systemPrompt, + }) + + if (this.isAborted()) { + handler.emitStatus('aborted', [], { force: true }) + onChunk({ type: 'done', isFinished: true, usage: handler.cloneUsage() }) + return { content: '', toolsUsed: [], toolRounds: 0, totalUsage: handler.cloneUsage() } + } + + let debugLastLoggedCount = 0 + let debugLlmRound = 1 + + let lastRequestPayload: unknown = null + const errorCapturingStreamFn: typeof streamSimple = (model, context, options) => { + return streamSimple(model, context, { + ...options, + onPayload: (payload, model) => { + lastRequestPayload = payload + return options?.onPayload?.(payload, model) + }, + }) + } + + const allowedTools = this.assistantConfig?.allowedBuiltinTools + const toolContext = { ...this.context, locale: this.locale } + let piTools = await getAllTools(toolContext, allowedTools) + const isExplicitChartSkill = this.skillCtx?.skillDef?.id === CHART_CAPABILITY_SKILL_ID + if ( + this.config.chartAutoMode === 'explicit' && + !isExplicitChartSkill && + !shouldUseChartCapabilityForMessage(userMessage) + ) { + piTools = piTools.filter((tool) => tool.name !== 'render_chart') + } + + if (this.skillCtx?.skillMenu && !this.skillCtx?.skillDef) { + piTools.push(createActivateSkillTool(this.chatType, allowedTools, this.locale)) + } + + let cachedMessages: PiMessage[] = [] + + handler.emitStatus('preparing', cachedMessages, { + pendingUserMessage: userMessage, + force: true, + }) + + const availableToolNames = piTools.map((tool) => tool.name) + const routeInput = { + userMessage, + chatType: this.chatType, + locale: this.locale, + dataSnapshot: this.context.dataSnapshot, + availableTools: availableToolNames, + availableCapabilities: [ + getChartPlannerCapabilityForMessage({ + userMessage, + locale: this.locale, + availableTools: availableToolNames, + chartAutoMode: this.config.chartAutoMode, + }), + ].filter((capability) => capability !== null), + assistantSummary: this.assistantConfig?.name, + skillSummary: this.skillCtx?.skillDef?.name ?? (this.skillCtx?.skillMenu ? 'auto_skill_menu' : undefined), + } + + const routeStartedAt = Date.now() + const routeDecision = await decideRequestRoute(routeInput, { + llmRouter: createLlmRouteDecider({ + piModel: this.piModel, + apiKey: this.apiKey, + abortSignal: this.abortSignal, + }), + }) + aiLogger.info('Router', 'Shadow route decision', { + ...routeDecision, + elapsedMs: Date.now() - routeStartedAt, + availableToolCount: piTools.length, + shadowOnly: true, + }) + onChunk({ type: 'route', routeDecision }) + + let effectiveSystemPrompt = systemPrompt + if (routeDecision.route === 'planned_execution') { + const planStartedAt = Date.now() + const planner = createAnalysisPlanner({ + piModel: this.piModel, + apiKey: this.apiKey, + onPlanDelta: (delta) => onChunk({ type: 'plan_delta', planDelta: delta }), + onThinkingDelta: (delta) => onChunk({ type: 'think', content: delta, thinkTag: 'thinking' }), + onThinkingEnd: (durationMs) => + onChunk({ type: 'think', content: '', thinkTag: 'thinking', thinkDurationMs: durationMs }), + onValidationDelta: (delta) => onChunk({ type: 'think', content: delta, thinkTag: 'plan_validation' }), + onValidationEnd: (durationMs) => + onChunk({ type: 'think', content: '', thinkTag: 'plan_validation', thinkDurationMs: durationMs }), + }) + const plan = await planner(routeInput, this.abortSignal) + if (plan) { + const planBlock = createPlanContentBlock(plan) + onChunk({ type: 'plan', plan: planBlock }) + effectiveSystemPrompt = `${systemPrompt}\n\n${buildPlanGuidance(plan)}` + aiLogger.info('Planner', 'Plan generated', { + title: plan.title, + steps: plan.steps.length, + successCriteria: plan.successCriteria.length, + elapsedMs: Date.now() - planStartedAt, + }) + } else { + aiLogger.warn('Planner', 'Plan generation skipped or failed', { + elapsedMs: Date.now() - planStartedAt, + route: routeDecision.route, + }) + onChunk({ type: 'plan_skipped' }) + } + } + + const historyMessages = this.loadHistory() + + try { + const result = await runAgentCore({ + piModel: this.piModel, + apiKey: this.apiKey, + systemPrompt: effectiveSystemPrompt, + tools: maxToolRounds > 0 ? piTools : [], + history: historyMessages, + userMessage, + maxToolRounds, + abortSignal: this.abortSignal, + steerMessage: answerWithoutToolsPrompt, + thinkingLevel: this.config.thinkingLevel, + streamFn: errorCapturingStreamFn, + onConvertToLlm: (filteredMessages) => { + cachedMessages = filteredMessages as PiMessage[] + if (isDebugMode()) { + const newMessages = filteredMessages.slice(debugLastLoggedCount) as PiMessage[] + if (newMessages.length > 0) { + const parts: string[] = [] + for (const m of newMessages) { + const msg = m as unknown as Record + parts.push(`--- ${msg.role} ---`) + const content = msg.content as + | Array<{ type: string; text?: string; name?: string; arguments?: unknown }> + | undefined + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text' && block.text) { + parts.push(block.text) + } else if (block.type === 'toolCall') { + parts.push(`[Tool Call] ${block.name}(${JSON.stringify(block.arguments)})`) + } + } + } + } + aiLogger.debug( + 'Agent', + `[DEBUG] LLM round ${debugLlmRound} - ${newMessages.length} new, total ${filteredMessages.length}\n${parts.join('\n')}` + ) + } + debugLastLoggedCount = filteredMessages.length + debugLlmRound++ + } + }, + onDebugContext: (messages) => { + if (isDebugMode() && this.context.aiChatId) { + try { + setPendingDebugContext(this.context.aiChatId, JSON.stringify(messages, null, 2)) + } catch { + // silent + } + } + }, + onEvent: (event) => handler.handleCoreEvent(event, cachedMessages), + }) + + if (this.isAborted()) { + handler.emitStatus('aborted', cachedMessages, { force: true }) + onChunk({ type: 'done', isFinished: true, usage: handler.cloneUsage() }) + return { + content: '', + toolsUsed: [...handler.toolsUsed], + toolRounds: handler.toolRounds, + totalUsage: handler.cloneUsage(), + } + } + + if (result.error) { + const agentError = new Error(result.error) as Error & { + agentContext?: { + provider?: string + model?: string + api?: string + url?: string + requestBody?: string + } + } + const lastMsg = [...result.finalMessages].reverse().find((m) => m.role === 'assistant') as + | (PiAssistantMessage & { provider?: string; model?: string; api?: string }) + | undefined + const ctx: NonNullable = {} + if (lastMsg) { + ctx.provider = lastMsg.provider + ctx.model = lastMsg.model + ctx.api = lastMsg.api + } + const baseUrl = (this.piModel as unknown as Record).baseUrl as string | undefined + if (baseUrl) { + const apiType = lastMsg?.api || (this.piModel as unknown as Record).api + const pathMap: Record = { + 'openai-completions': '/chat/completions', + 'openai-responses': '/responses', + 'anthropic-messages': '/messages', + } + const apiPath = typeof apiType === 'string' ? pathMap[apiType] : undefined + ctx.url = apiPath ? baseUrl.replace(/\/+$/, '') + apiPath : baseUrl + } + if (lastRequestPayload) { + try { + ctx.requestBody = JSON.stringify(lastRequestPayload, null, 2) + } catch { + // ignore + } + } + agentError.agentContext = ctx + throw agentError + } + + const finalAssistant = [...result.finalMessages] + .reverse() + .find((msg): msg is PiAssistantMessage => msg.role === 'assistant') + + const finalRawContent = + finalAssistant?.content + .filter((item) => item.type === 'text') + .map((item) => item.text) + .join('') || '' + + const finalContent = stripToolCallTags(extractThinkingContent(finalRawContent).cleanContent) + + if (isDebugMode() && finalContent) { + aiLogger.debug('Agent', `[DEBUG] Final response\n${finalContent}`) + } + + handler.emitStatus('completed', cachedMessages, { force: true }) + onChunk({ type: 'done', isFinished: true, usage: result.usage }) + + return { + content: finalContent, + toolsUsed: [...result.toolsUsed], + toolRounds: result.toolRounds, + totalUsage: result.usage, + } + } catch (error) { + const phase = this.isAborted() ? 'aborted' : 'error' + handler.emitStatus(phase, cachedMessages, { force: true }) + throw error + } + } + + private loadHistory(): SimpleHistoryMessage[] { + const { aiChatId } = this.context + if (!aiChatId) { + return [] + } + try { + return getHistoryForAgent(aiChatId, undefined, this.context.historyLeafMessageId) + } catch (error) { + aiLogger.warn('Agent', 'Failed to load history from DB, using empty history', { aiChatId, error }) + return [] + } + } +} + +/** + * 创建 Agent 并执行对话(便捷函数) + */ +export async function runAgent( + userMessage: string, + context: ToolContext, + config?: AgentConfig, + chatType?: 'group' | 'private', + locale?: string, + assistantConfig?: AssistantConfig, + skillCtx?: SkillContext +): Promise { + const activeConfig = getDefaultAssistantConfig() + if (!activeConfig) throw new Error('LLM service not configured') + const piModel = buildPiModel(activeConfig) + const agent = new Agent(context, piModel, activeConfig.apiKey, config, chatType, locale, assistantConfig, skillCtx) + return agent.execute(userMessage) +} + +/** + * 创建 Agent 并流式执行对话(便捷函数) + */ +export async function runAgentStream( + userMessage: string, + context: ToolContext, + onChunk: (chunk: AgentStreamChunk) => void, + config?: AgentConfig, + chatType?: 'group' | 'private', + locale?: string, + assistantConfig?: AssistantConfig, + skillCtx?: SkillContext +): Promise { + const activeConfig = getDefaultAssistantConfig() + if (!activeConfig) throw new Error('LLM service not configured') + const piModel = buildPiModel(activeConfig) + const agent = new Agent(context, piModel, activeConfig.apiKey, config, chatType, locale, assistantConfig, skillCtx) + return agent.executeStream(userMessage, onChunk) +} diff --git a/apps/desktop/main/ai/agent/prompt-builder.ts b/apps/desktop/main/ai/agent/prompt-builder.ts new file mode 100644 index 000000000..5d3474271 --- /dev/null +++ b/apps/desktop/main/ai/agent/prompt-builder.ts @@ -0,0 +1,31 @@ +/** + * Agent system prompt builder — Electron adapter. + * Injects the Electron i18n `t` function into the shared builder. + */ + +import { t as i18nT } from '../../i18n' +import { buildSystemPrompt as buildSystemPromptCore } from '@openchatlab/node-runtime' +import type { OwnerInfo, SkillContext, MentionedMember, DataSnapshot } from '@openchatlab/node-runtime' + +export type { OwnerInfo, SkillContext, MentionedMember, DataSnapshot } + +export function buildSystemPrompt( + chatType: 'group' | 'private' = 'group', + assistantSystemPrompt?: string, + ownerInfo?: OwnerInfo, + locale: string = 'zh-CN', + skillCtx?: SkillContext, + mentionedMembers?: MentionedMember[], + dataSnapshot?: DataSnapshot +): string { + return buildSystemPromptCore({ + t: i18nT, + chatType, + assistantSystemPrompt, + ownerInfo, + locale, + skillCtx, + mentionedMembers, + dataSnapshot, + }) +} diff --git a/apps/desktop/main/ai/agent/types.ts b/apps/desktop/main/ai/agent/types.ts new file mode 100644 index 000000000..1ea8e8c47 --- /dev/null +++ b/apps/desktop/main/ai/agent/types.ts @@ -0,0 +1,57 @@ +/** + * Agent 模块类型定义 + */ + +import type { TokenUsage } from '@openchatlab/node-runtime' +import type { SerializedErrorInfo } from '../../../shared/types' +import type { ThinkingLevel } from '@openchatlab/core' +import type { ChartAutoMode } from '@openchatlab/shared-types' + +export type { TokenUsage, AgentRuntimeStatus } from '@openchatlab/node-runtime' +export type { SerializedErrorInfo } + +/** + * Agent 配置 + */ +export interface AgentConfig { + /** 最大工具调用轮数(防止无限循环) */ + maxToolRounds?: number + /** 中止信号,用于取消执行 */ + abortSignal?: AbortSignal + /** Override thinking level for this request; clamped to what the model supports. */ + thinkingLevel?: ThinkingLevel + /** Controls how aggressively chart capability is exposed for automatic use. */ + chartAutoMode?: ChartAutoMode +} + +/** + * Agent stream chunk — re-exported from shared, used by Electron IPC layer. + */ +export type { AgentStreamChunk } from '@openchatlab/node-runtime' + +/** + * Agent 执行结果 + */ +export interface AgentResult { + /** 最终文本响应 */ + content: string + /** 使用的工具列表 */ + toolsUsed: string[] + /** 工具调用轮数 */ + toolRounds: number + /** 总 Token 使用量(累计所有 LLM 调用) */ + totalUsage?: TokenUsage + /** 结构化错误信息(请求失败时) */ + error?: SerializedErrorInfo +} + +/** + * 技能上下文(传递给 prompt-builder) + * 手动选择和 AI 自选两种模式互斥 + */ +export interface SkillContext { + /** 手动选择时传入完整 SkillDef,AI 自选时为 undefined */ + skillDef?: import('../skills/types').SkillDef + /** AI 自选时传入技能菜单文本,手动选择时为 undefined */ + skillMenu?: string +} diff --git a/apps/desktop/main/ai/assistant/builtinTools.ts b/apps/desktop/main/ai/assistant/builtinTools.ts new file mode 100644 index 000000000..acbe9e42f --- /dev/null +++ b/apps/desktop/main/ai/assistant/builtinTools.ts @@ -0,0 +1,14 @@ +/** + * 内置工具目录查询 — 从 @openchatlab/core 重导出 + */ + +import { BUILTIN_TOOL_CATALOG as _CATALOG } from '@openchatlab/core' + +export type { BuiltinToolCatalogEntry } from '@openchatlab/core' + +/** + * 获取所有内置工具的目录(含分类),供前端展示 + */ +export function getBuiltinToolCatalog() { + return _CATALOG +} diff --git a/apps/desktop/main/ai/assistant/builtins/general_cn.md b/apps/desktop/main/ai/assistant/builtins/general_cn.md new file mode 100644 index 000000000..c7de4d85d --- /dev/null +++ b/apps/desktop/main/ai/assistant/builtins/general_cn.md @@ -0,0 +1,24 @@ +--- +id: general_cn +name: 通用分析助手 +supportedLocales: + - zh +presetQuestions: + - 最近都在聊什么? + - 谁是最活跃的人? + - 聊天的活跃时间是什么时候? + - 帮我搜索关于「旅游」的聊天记录 + - 分析一下聊天活跃时间段 +--- + +你是一个专业但风格轻松的聊天记录分析助手。你的任务是帮助用户理解和分析他们的聊天记录数据,同时可以适度使用网络热梗和表情/颜文字活跃气氛,但不影响结论的准确性。 + +## 回答要求 + +1. 基于工具返回的数据回答,不要编造信息 +2. 如果数据不足以回答问题,请说明 +3. 回答要简洁明了,使用 Markdown 格式 +4. 可以引用具体的发言作为证据 +5. 对于统计数据,可以适当总结趋势和特点 +6. 可以适度加入网络热梗、表情/颜文字(强度适中) +7. 玩梗不得影响事实准确与结论清晰,避免低俗或冒犯性表达 diff --git a/apps/desktop/main/ai/assistant/builtins/general_en.md b/apps/desktop/main/ai/assistant/builtins/general_en.md new file mode 100644 index 000000000..9ef15139b --- /dev/null +++ b/apps/desktop/main/ai/assistant/builtins/general_en.md @@ -0,0 +1,23 @@ +--- +id: general_en +name: General Analysis Assistant +supportedLocales: + - en +presetQuestions: + - What have people been chatting about recently? + - Who are the most active members? + - Search chat records about "travel" + - Analyze the active hours of the chat +--- + +You are a professional yet friendly chat record analysis assistant. Your job is to help users understand and analyze their chat history data. You can occasionally use light humor to keep the conversation engaging, but never at the expense of accuracy. + +## Response Guidelines + +1. Base your answers on data returned by tools — never fabricate information +2. If the data is insufficient to answer a question, say so clearly +3. Keep responses concise and use Markdown formatting +4. Quote specific messages as evidence when relevant +5. For statistical data, summarize trends and highlight key patterns +6. Keep a friendly and approachable tone +7. Always prioritize factual accuracy over entertainment diff --git a/apps/desktop/main/ai/assistant/builtins/general_ja.md b/apps/desktop/main/ai/assistant/builtins/general_ja.md new file mode 100644 index 000000000..24576cc1e --- /dev/null +++ b/apps/desktop/main/ai/assistant/builtins/general_ja.md @@ -0,0 +1,23 @@ +--- +id: general_ja +name: 汎用分析アシスタント +supportedLocales: + - ja +presetQuestions: + - 最近みんな何を話してる? + - 一番アクティブなメンバーは誰? + - 「旅行」に関するチャット記録を検索して + - チャットの活発な時間帯を分析して +--- + +あなたはプロフェッショナルでありながら親しみやすいチャット記録分析アシスタントです。ユーザーのチャット履歴データを理解し分析する手助けをすることが主な役割です。適度にユーモアを交えても構いませんが、分析の正確性を最優先にしてください。 + +## 回答ガイドライン + +1. ツールから返されたデータに基づいて回答し、情報を捏造しないこと +2. データが不十分な場合はその旨を明確に伝えること +3. 簡潔で分かりやすい回答を心がけ、Markdown形式を使用すること +4. 関連する場合は具体的な発言を引用すること +5. 統計データについてはトレンドや特徴を適切にまとめること +6. 親しみやすく丁寧なトーンを維持すること +7. 事実の正確性を常に最優先すること diff --git a/apps/desktop/main/ai/assistant/defaultGeneral.ts b/apps/desktop/main/ai/assistant/defaultGeneral.ts new file mode 100644 index 000000000..3e273fdd7 --- /dev/null +++ b/apps/desktop/main/ai/assistant/defaultGeneral.ts @@ -0,0 +1,9 @@ +/** + * 根据 locale 选择默认通用助手。 + * 默认助手的选择应发生在知道用户语言的上层,而不是存储层。 + */ +export function getDefaultGeneralAssistantId(locale?: string): 'general_cn' | 'general_en' | 'general_ja' { + if (locale?.startsWith('en')) return 'general_en' + if (locale?.startsWith('ja')) return 'general_ja' + return 'general_cn' +} diff --git a/apps/desktop/main/ai/assistant/index.ts b/apps/desktop/main/ai/assistant/index.ts new file mode 100644 index 000000000..0dedb6d04 --- /dev/null +++ b/apps/desktop/main/ai/assistant/index.ts @@ -0,0 +1,22 @@ +/** + * 助手模块入口 + */ + +export * from './types' +export { + initAssistantManager, + getAllAssistants, + getAssistantConfig, + hasAssistant, + updateAssistant, + createAssistant, + deleteAssistant, + resetAssistant, + getBuiltinCatalog, + importAssistant, + reimportAssistant, + importAssistantFromMd, + isGeneralAssistant, +} from './manager' +export { parseAssistantFile, serializeAssistant } from '@openchatlab/node-runtime' +export { getBuiltinToolCatalog, type BuiltinToolCatalogEntry } from './builtinTools' diff --git a/apps/desktop/main/ai/assistant/manager.ts b/apps/desktop/main/ai/assistant/manager.ts new file mode 100644 index 000000000..e8f2f00e2 --- /dev/null +++ b/apps/desktop/main/ai/assistant/manager.ts @@ -0,0 +1,122 @@ +/** + * Assistant manager — thin Electron adapter. + * + * Delegates all logic to @openchatlab/node-runtime's AssistantManager class, + * injecting Electron-specific dependencies (paths, builtins, logger). + */ + +import * as fs from 'fs' +import * as path from 'path' +import { randomUUID } from 'crypto' +import { AssistantManager as SharedAssistantManager, type AssistantManagerFs } from '@openchatlab/node-runtime' +import { getPathProvider } from '../../path-context' +import { aiLogger } from '../logger' +import type { + AssistantConfig, + AssistantSummary, + AssistantInitResult, + AssistantSaveResult, + BuiltinAssistantInfo, +} from './types' + +import builtinGeneralZhRaw from './builtins/general_cn.md?raw' +import builtinGeneralEnRaw from './builtins/general_en.md?raw' +import builtinGeneralJaRaw from './builtins/general_ja.md?raw' + +const nodeFs: AssistantManagerFs = { + ensureDir(dir: string) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + }, + listFiles(dir: string, ext: string) { + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir).filter((f) => f.endsWith(ext)) + }, + readFile(filePath: string) { + return fs.readFileSync(filePath, 'utf-8') + }, + writeFile(filePath: string, content: string) { + fs.writeFileSync(filePath, content, 'utf-8') + }, + deleteFile(filePath: string) { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + }, + fileExists(filePath: string) { + return fs.existsSync(filePath) + }, + joinPath(...parts: string[]) { + return path.join(...parts) + }, +} + +let _manager: SharedAssistantManager | null = null + +export function getManager(): SharedAssistantManager { + if (!_manager) { + _manager = new SharedAssistantManager({ + fs: nodeFs, + assistantsDir: path.join(getPathProvider().getAiDataDir(), 'assistants'), + builtinRawConfigs: [ + { id: 'general_cn', content: builtinGeneralZhRaw }, + { id: 'general_en', content: builtinGeneralEnRaw }, + { id: 'general_ja', content: builtinGeneralJaRaw }, + ], + logger: aiLogger, + generateId: () => `custom_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + }) + } + return _manager +} + +// ==================== Public API (preserves existing function signatures) ==================== + +export function initAssistantManager(): AssistantInitResult { + return getManager().init() +} + +export function getAllAssistants(): AssistantSummary[] { + return getManager().getAllAssistants() +} + +export function getAssistantConfig(id: string): AssistantConfig | null { + return getManager().getAssistantConfig(id) +} + +export function hasAssistant(id: string): boolean { + return getManager().hasAssistant(id) +} + +export function getBuiltinCatalog(): BuiltinAssistantInfo[] { + return getManager().getBuiltinCatalog() +} + +export function importAssistant(builtinId: string): AssistantSaveResult { + return getManager().importAssistant(builtinId) +} + +export function reimportAssistant(id: string): AssistantSaveResult { + return getManager().reimportAssistant(id) +} + +export function updateAssistant(id: string, updates: Partial): AssistantSaveResult { + return getManager().updateAssistant(id, updates) +} + +export function createAssistant(config: Omit): AssistantSaveResult & { id?: string } { + return getManager().createAssistant(config) +} + +export function deleteAssistant(id: string): AssistantSaveResult { + return getManager().deleteAssistant(id) +} + +export function resetAssistant(id: string): AssistantSaveResult { + return getManager().resetAssistant(id) +} + +export function importAssistantFromMd(rawMd: string): AssistantSaveResult & { id?: string } { + return getManager().importAssistantFromMd(rawMd) +} + +export function isGeneralAssistant(id: string): boolean { + return getManager().isGeneralAssistant(id) +} diff --git a/apps/desktop/main/ai/assistant/types.ts b/apps/desktop/main/ai/assistant/types.ts new file mode 100644 index 000000000..e94fe13f1 --- /dev/null +++ b/apps/desktop/main/ai/assistant/types.ts @@ -0,0 +1,191 @@ +/** + * 助手系统类型定义 + * 定义助手配置(Markdown 格式)和声明式 SQL 工具等核心类型 + */ + +// ==================== 助手配置 ==================== + +/** + * 助手配置(Markdown 文件解析后的完整结构) + * + * 每个助手对应一个 .md 文件(YAML frontmatter + Markdown body)。 + * - 内置助手模板打包在 electron/main/ai/assistant/builtins/*.md + * - 用户助手存储在 {userData}/data/ai/assistants/*.md + * - YAML frontmatter → 结构化元数据字段 + * - Markdown body → systemPrompt + */ +export interface AssistantConfig { + /** 助手唯一标识 */ + id: string + /** 助手显示名称 */ + name: string + + /** 系统提示词(角色定义 + 回答要求,来自 Markdown body) */ + systemPrompt: string + + /** 预设问题列表(前端展示,用户可点击直接发送) */ + presetQuestions: string[] + + /** + * 允许使用的内置工具名称白名单 + * - undefined / 空数组 = 全部内置工具可用 + * - 非空数组 = 仅列出的工具可用 + */ + allowedBuiltinTools?: string[] + + /** + * 内置助手来源标识 + * 非空 = 该配置由某个内置助手导入而来(值为内置助手的 id) + */ + builtinId?: string + + /** + * 适用的聊天类型 + * - undefined / [] = 通用(群聊+私聊均适用) + * - ['group'] = 仅群聊 + * - ['private'] = 仅私聊 + */ + applicableChatTypes?: ('group' | 'private')[] + + /** + * 适用的语言/地区(前缀匹配,如 'zh' 匹配 'zh-CN'、'zh-TW') + * - undefined / [] = 全语言通用 + * - ['zh'] = 仅中文用户 + * - ['en'] = 仅英文用户 + */ + supportedLocales?: string[] +} + +/** + * 传递给前端的助手摘要信息 + */ +export interface AssistantSummary { + id: string + name: string + systemPrompt: string + presetQuestions: string[] + builtinId?: string + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +/** + * 助手市场中的内置助手信息(模板目录项) + */ +export interface BuiltinAssistantInfo { + id: string + name: string + systemPrompt: string + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] + /** 用户是否已导入该助手 */ + imported: boolean +} + +// ==================== 声明式 SQL 工具 ==================== + +/** + * 声明式 SQL 工具定义 + * + * 每个定义在 LLM 眼中是一个 Function Calling 工具, + * 执行时通过参数化 SQL 查询数据库,将结果格式化为文本返回给 LLM。 + */ +export interface CustomSqlToolDef { + /** 工具名称(作为 Function Calling 的 tool name) */ + name: string + /** 工具描述(作为 Function Calling 的 tool description) */ + description: string + /** + * 参数定义(标准 JSON Schema 格式) + * + * 示例: + * ```json + * { + * "type": "object", + * "properties": { + * "days": { "type": "number", "description": "查询天数" } + * }, + * "required": ["days"] + * } + * ``` + * + * 运行时会通过 jsonSchemaToTypeBox() 转换为 TypeBox 格式, + * 以满足 pi-agent-core AgentTool 的类型约束。 + */ + parameters: JsonSchemaObject + + /** 执行配置 */ + execution: SqlToolExecution +} + +/** + * JSON Schema 对象类型(简化版,覆盖技能参数定义的常见场景) + */ +export interface JsonSchemaObject { + type: 'object' + properties: Record + required?: string[] +} + +/** + * JSON Schema 属性定义 + */ +export interface JsonSchemaProperty { + type: 'string' | 'number' | 'integer' | 'boolean' + description?: string + default?: unknown + enum?: unknown[] +} + +/** + * SQL 工具执行配置 + */ +export interface SqlToolExecution { + /** 执行类型(目前仅支持 sqlite) */ + type: 'sqlite' + /** + * 参数化 SQL 查询语句 + * - 使用命名参数 @paramName(对应 parameters 中的属性名) + * - 必须是只读查询(better-sqlite3 的 stmt.readonly 会强制检查) + * + * 示例: + * ```sql + * SELECT sender_name, COUNT(*) as msg_count + * FROM message + * WHERE ts > unixepoch('now', '-' || @days || ' days') + * GROUP BY sender_name + * ORDER BY msg_count DESC + * LIMIT 10 + * ``` + */ + query: string + /** + * 行格式化模板,使用 {columnName} 占位符 + * 示例:'用户【{sender_name}】共发言 {msg_count} 次' + */ + rowTemplate: string + /** 可选的汇总模板,在所有行之前输出(支持 {rowCount} 占位符) */ + summaryTemplate?: string + /** 查询结果为空时返回的文本 */ + fallback: string +} + +// ==================== 助手管理器相关 ==================== + +/** + * AssistantManager 初始化结果 + */ +export interface AssistantInitResult { + /** 加载的助手总数 */ + total: number + /** general 助手是否为首次自动导入 */ + generalCreated: boolean +} + +/** + * 助手配置的保存/更新结果 + */ +export interface AssistantSaveResult { + success: boolean + error?: string +} diff --git a/apps/desktop/main/ai/chats.ts b/apps/desktop/main/ai/chats.ts new file mode 100644 index 000000000..2868c906d --- /dev/null +++ b/apps/desktop/main/ai/chats.ts @@ -0,0 +1,165 @@ +/** + * AI 聊天历史管理模块(Electron 薄包装层) + * + * 委托给 @openchatlab/node-runtime 的 AIChatManager, + * 保留原有的模块级函数签名以兼容现有 IPC 调用。 + */ + +import { AIChatManager } from '@openchatlab/node-runtime' +import { getPathProvider } from '../path-context' +import { aiLogger } from './logger' + +export type { AIChat, AIMessage, AIMessageRole, ContentBlock, TokenUsageData } from '@openchatlab/node-runtime' + +let manager: AIChatManager | null = null + +export function getManager(): AIChatManager { + if (!manager) { + manager = new AIChatManager(getPathProvider().getAiDataDir(), { + logger: { + warn(category, message, extra) { + aiLogger.warn(category, message, extra) + }, + }, + }) + } + return manager +} + +export function closeAiDatabase(): void { + if (manager) { + manager.close() + manager = null + } +} + +export function getAiSchema() { + return getManager().getAiSchema() +} + +export function executeAiSQL(sql: string) { + return getManager().executeAiSQL(sql) +} + +export function createAIChat(sessionId: string, title: string | undefined, assistantId: string) { + return getManager().createAIChat(sessionId, title, assistantId) +} + +export function getAIChatCountsBySession() { + return getManager().getAIChatCountsBySession() +} + +export function getAIChats(sessionId: string) { + return getManager().getAIChats(sessionId) +} + +export function getAIChat(aiChatId: string) { + return getManager().getAIChat(aiChatId) +} + +export function updateAIChatTitle(aiChatId: string, title: string) { + return getManager().updateAIChatTitle(aiChatId, title) +} + +export function deleteAIChat(aiChatId: string) { + return getManager().deleteAIChat(aiChatId) +} + +export function addMessage( + aiChatId: string, + role: import('@openchatlab/node-runtime').AIMessageRole, + content: string, + dataKeywords?: string[], + dataMessageCount?: number, + contentBlocks?: import('@openchatlab/node-runtime').ContentBlock[], + tokenUsage?: import('@openchatlab/node-runtime').TokenUsageData +) { + return getManager().addMessage( + aiChatId, + role, + content, + dataKeywords, + dataMessageCount, + contentBlocks, + tokenUsage + ) +} + +export function getMessages(aiChatId: string) { + return getManager().getMessages(aiChatId) +} + +export function deleteMessage(messageId: string) { + return getManager().deleteMessage(messageId) +} + +export function deleteMessagesFrom(aiChatId: string, messageId: string) { + return getManager().deleteMessagesFrom(aiChatId, messageId) +} + +export function forkAIChat(sourceAIChatId: string, upToMessageId: string, title?: string) { + return getManager().forkAIChat(sourceAIChatId, upToMessageId, title) +} + +export function updateMessageContent(messageId: string, newContent: string) { + return getManager().updateMessageContent(messageId, newContent) +} + +export function deleteAndRelinkMessage(aiChatId: string, messageId: string) { + return getManager().deleteAndRelinkMessage(aiChatId, messageId) +} + +export function insertMessageAfter( + aiChatId: string, + afterMessageId: string, + role: import('@openchatlab/node-runtime').AIMessageRole, + content: string, + contentBlocks?: import('@openchatlab/node-runtime').ContentBlock[], + tokenUsage?: import('@openchatlab/node-runtime').TokenUsageData +) { + return getManager().insertMessageAfter(aiChatId, afterMessageId, role, content, contentBlocks, tokenUsage) +} + +export function setPendingDebugContext(aiChatId: string, debugContext: string) { + return getManager().setPendingDebugContext(aiChatId, debugContext) +} + +export function setDebugContext(messageId: string, debugContext: string) { + return getManager().setDebugContext(messageId, debugContext) +} + +export function clearAllDebugContext() { + return getManager().clearAllDebugContext() +} + +export function getAIChatTokenUsage(aiChatId: string) { + return getManager().getAIChatTokenUsage(aiChatId) +} + +export function getHistoryForAgent(aiChatId: string, maxMessages?: number, leafMessageId?: string | null) { + return getManager().getHistoryForAgent(aiChatId, maxMessages, leafMessageId) +} + +export function addSummaryMessage( + aiChatId: string, + content: string, + meta: { bufferBoundaryTimestamp: number; compressedMessageCount: number } +) { + return getManager().addSummaryMessage(aiChatId, content, meta) +} + +export function getLatestSummary(aiChatId: string) { + return getManager().getLatestSummary(aiChatId) +} + +export function getMessagesAfterSummary(aiChatId: string, summaryTimestamp: number) { + return getManager().getMessagesAfterSummary(aiChatId, summaryTimestamp) +} + +export function getAllUserAssistantMessages(aiChatId: string) { + return getManager().getAllUserAssistantMessages(aiChatId) +} + +export function getMessageCountAfterSummary(aiChatId: string) { + return getManager().getMessageCountAfterSummary(aiChatId) +} diff --git a/apps/desktop/main/ai/llm/custom-model-store.ts b/apps/desktop/main/ai/llm/custom-model-store.ts new file mode 100644 index 000000000..473b22a22 --- /dev/null +++ b/apps/desktop/main/ai/llm/custom-model-store.ts @@ -0,0 +1,82 @@ +/** + * 自定义 Model 持久化存储 + * 文件位置: {aiDataDir}/custom-models.json + */ + +import * as fs from 'fs' +import * as path from 'path' +import { getPathProvider } from '../../path-context' +import { aiLogger } from '../logger' +import type { ModelDefinition } from './model-types' + +function getStorePath(): string { + return path.join(getPathProvider().getAiDataDir(), 'custom-models.json') +} + +function readStore(): ModelDefinition[] { + const storePath = getStorePath() + if (!fs.existsSync(storePath)) return [] + + try { + const content = fs.readFileSync(storePath, 'utf-8') + return JSON.parse(content) as ModelDefinition[] + } catch (error) { + aiLogger.error('CustomModelStore', 'Failed to read store', error) + return [] + } +} + +function writeStore(models: ModelDefinition[]): void { + const storePath = getStorePath() + const dir = path.dirname(storePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(storePath, JSON.stringify(models, null, 2), 'utf-8') +} + +export function loadCustomModels(): ModelDefinition[] { + return readStore() +} + +export function addCustomModel(input: Omit): ModelDefinition { + const models = readStore() + + const existing = models.find((m) => m.id === input.id && m.providerId === input.providerId) + if (existing) { + throw new Error(`Model "${input.id}" already exists under provider "${input.providerId}"`) + } + + const newModel: ModelDefinition = { + ...input, + builtin: false, + editable: true, + } + models.push(newModel) + writeStore(models) + return newModel +} + +export function updateCustomModel( + providerId: string, + modelId: string, + updates: Partial> +): { success: boolean; error?: string } { + const models = readStore() + const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) + if (index === -1) return { success: false, error: 'Custom model not found' } + + models[index] = { ...models[index], ...updates } + writeStore(models) + return { success: true } +} + +export function deleteCustomModel(providerId: string, modelId: string): { success: boolean; error?: string } { + const models = readStore() + const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) + if (index === -1) return { success: false, error: 'Custom model not found' } + + models.splice(index, 1) + writeStore(models) + return { success: true } +} diff --git a/apps/desktop/main/ai/llm/custom-provider-store.ts b/apps/desktop/main/ai/llm/custom-provider-store.ts new file mode 100644 index 000000000..d065593ad --- /dev/null +++ b/apps/desktop/main/ai/llm/custom-provider-store.ts @@ -0,0 +1,79 @@ +/** + * 自定义 Provider 持久化存储 + * 文件位置: {aiDataDir}/custom-providers.json + */ + +import * as fs from 'fs' +import * as path from 'path' +import { randomUUID } from 'crypto' +import { getPathProvider } from '../../path-context' +import { aiLogger } from '../logger' +import type { ProviderDefinition } from './model-types' + +function getStorePath(): string { + return path.join(getPathProvider().getAiDataDir(), 'custom-providers.json') +} + +function readStore(): ProviderDefinition[] { + const storePath = getStorePath() + if (!fs.existsSync(storePath)) return [] + + try { + const content = fs.readFileSync(storePath, 'utf-8') + return JSON.parse(content) as ProviderDefinition[] + } catch (error) { + aiLogger.error('CustomProviderStore', 'Failed to read store', error) + return [] + } +} + +function writeStore(providers: ProviderDefinition[]): void { + const storePath = getStorePath() + const dir = path.dirname(storePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(storePath, JSON.stringify(providers, null, 2), 'utf-8') +} + +export function loadCustomProviders(): ProviderDefinition[] { + return readStore() +} + +export function addCustomProvider( + input: Omit +): ProviderDefinition { + const providers = readStore() + const newProvider: ProviderDefinition = { + ...input, + id: `custom:${randomUUID()}`, + builtin: false, + enabledByDefault: false, + } + providers.push(newProvider) + writeStore(providers) + return newProvider +} + +export function updateCustomProvider( + id: string, + updates: Partial> +): { success: boolean; error?: string } { + const providers = readStore() + const index = providers.findIndex((p) => p.id === id) + if (index === -1) return { success: false, error: 'Custom provider not found' } + + providers[index] = { ...providers[index], ...updates } + writeStore(providers) + return { success: true } +} + +export function deleteCustomProvider(id: string): { success: boolean; error?: string } { + const providers = readStore() + const index = providers.findIndex((p) => p.id === id) + if (index === -1) return { success: false, error: 'Custom provider not found' } + + providers.splice(index, 1) + writeStore(providers) + return { success: true } +} diff --git a/apps/desktop/main/ai/llm/index.ts b/apps/desktop/main/ai/llm/index.ts new file mode 100644 index 000000000..e3857f3a5 --- /dev/null +++ b/apps/desktop/main/ai/llm/index.ts @@ -0,0 +1,472 @@ +/** + * LLM 服务模块入口 + * 提供统一的 LLM 服务管理(支持多配置) + */ + +import * as fs from 'fs' +import * as path from 'path' +import { randomUUID } from 'crypto' +import { getPathProvider } from '../../path-context' +import type { LLMProvider, ProviderInfo, AIServiceConfig, AIConfigStore } from './types' +import { MAX_CONFIG_COUNT } from './types' +import { aiLogger } from '../logger' +import { resolveApiKey, writeAuthProfile } from '@openchatlab/config' +import { buildChatLabUserAgentHeaders } from '../../utils/httpHeaders' +import { t } from '../../i18n' +import { + buildPiModel as buildPiModelCore, + fetchRemoteModels as fetchRemoteModelsCore, + validateApiKey as validateApiKeyCore, + type PiModel, + type PiApi, + type FetchRemoteModelsResult, +} from '@openchatlab/node-runtime' + +// 新模型系统导出 +export { BUILTIN_PROVIDERS, getBuiltinProviderById } from '@openchatlab/core' +export { BUILTIN_MODELS, getBuiltinModelsByProvider, getBuiltinModelById } from '@openchatlab/core' +export { + loadCustomProviders, + addCustomProvider, + updateCustomProvider, + deleteCustomProvider, +} from './custom-provider-store' +export { loadCustomModels, addCustomModel, updateCustomModel, deleteCustomModel } from './custom-model-store' +export * from './model-types' + +// 兼容类型导出 +export * from './types' + +// ==================== 合并 Registry / Catalog(内置 + 自定义)==================== + +import { BUILTIN_PROVIDERS, getBuiltinProviderById } from '@openchatlab/core' +import { BUILTIN_MODELS, getBuiltinModelsByProvider } from '@openchatlab/core' +import { loadCustomProviders } from './custom-provider-store' +import { loadCustomModels } from './custom-model-store' +import type { ProviderDefinition, ModelDefinition } from './model-types' + +/** 获取完整 provider registry(内置 + 自定义) */ +export function getProviderRegistry(): ProviderDefinition[] { + return [...BUILTIN_PROVIDERS, ...loadCustomProviders()] +} + +/** 获取完整 model catalog(内置 + 自定义) */ +export function getModelCatalog(): ModelDefinition[] { + return [...BUILTIN_MODELS, ...loadCustomModels()] +} + +/** 获取指定 provider 下的全部模型(内置 + 自定义) */ +export function getModelsByProvider(providerId: string): ModelDefinition[] { + return [...getBuiltinModelsByProvider(providerId), ...loadCustomModels().filter((m) => m.providerId === providerId)] +} + +/** 按 id 查找 provider(内置优先) */ +export function getProviderDefinitionById(id: string): ProviderDefinition | null { + return getBuiltinProviderById(id) || loadCustomProviders().find((p) => p.id === id) || null +} + +/** 按 providerId + modelId 查找模型定义(内置优先,再查自定义,最后跨 provider 兜底) */ +export function findModelDefinition(providerId: string, modelId: string): ModelDefinition | null { + return ( + getBuiltinModelById(providerId, modelId) || + loadCustomModels().find((m) => m.providerId === providerId && m.id === modelId) || + BUILTIN_MODELS.find((m) => m.id === modelId) || + loadCustomModels().find((m) => m.id === modelId) || + null + ) +} + +function providerDefinitionToInfo(def: ProviderDefinition): ProviderInfo { + const models = getBuiltinModelsByProvider(def.id) + return { + id: def.id, + name: def.name, + defaultBaseUrl: def.defaultBaseUrl, + models: models + .filter((m) => !m.capabilities.includes('embedding') && !m.capabilities.includes('ranking')) + .map((m) => ({ id: m.id, name: m.name, description: m.description })), + } +} + +export const PROVIDERS: ProviderInfo[] = BUILTIN_PROVIDERS.map(providerDefinitionToInfo) + +// 配置文件路径 +let CONFIG_PATH: string | null = null + +function getConfigPath(): string { + if (CONFIG_PATH) return CONFIG_PATH + CONFIG_PATH = path.join(getPathProvider().getAiDataDir(), 'llm-config.json') + return CONFIG_PATH +} + +// ==================== Electron-specific 增强迁移 ==================== +// Migration Runner (packages/config/src/migrations) 处理核心数据迁移。 +// 以下仅处理 Electron 特有的自定义 provider/model 注册(v2 迁移的补充步骤)。 + +import { addCustomProvider as _addCustomProviderDirect } from './custom-provider-store' +import { addCustomModel as _addCustomModelDirect } from './custom-model-store' +import { getBuiltinModelById } from '@openchatlab/core' + +const LEGACY_PROVIDER_FALLBACKS: Record = { + minimax: { name: 'MiniMax', defaultBaseUrl: 'https://api.minimaxi.com/v1' }, +} + +function ensureCustomProvidersAndModels(configs: AIServiceConfig[]): void { + for (const config of configs) { + const providerId = config.provider + + if (!getBuiltinProviderById(providerId)) { + const fallback = LEGACY_PROVIDER_FALLBACKS[providerId] + if (fallback) { + try { + _addCustomProviderDirect({ + name: fallback.name, + kind: 'openai-compatible', + defaultBaseUrl: fallback.defaultBaseUrl, + authMode: 'api-key', + supportsCustomModels: true, + modelIds: [], + }) + } catch { + // already exists + } + } + } + + if (config.model && getBuiltinProviderById(providerId)) { + if (!getBuiltinModelById(providerId, config.model)) { + try { + _addCustomModelDirect({ + id: config.model, + providerId, + name: config.model, + capabilities: ['chat'], + recommendedFor: ['chat'], + status: 'stable', + }) + } catch { + // already exists + } + } + } + } +} + +/** 解析 ModelSlot:如果 configId 无效,回退到 configs[0] */ +function resolveSlot( + slot: import('./model-types').ModelSlot | null | undefined, + configs: AIServiceConfig[] +): import('./model-types').ModelSlot | null { + if (slot && configs.some((c) => c.id === slot.configId)) return slot + const fallback = configs[0] + return fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null +} + +// ==================== 多配置管理 ==================== + +/** + * 加载配置存储 + * + * 数据迁移由 MigrationRunner 在应用启动时统一处理。 + * 此函数只读取最新格式,并从 auth-profiles.json 解析 API Key。 + */ +export function loadConfigStore(): AIConfigStore { + const configPath = getConfigPath() + + if (!fs.existsSync(configPath)) { + return { configs: [], defaultAssistant: null, fastModel: null } + } + + try { + const content = fs.readFileSync(configPath, 'utf-8') + const store = JSON.parse(content) as AIConfigStore + + ensureCustomProvidersAndModels(store.configs) + + const resolvedConfigs = store.configs.map((config) => { + const profileKey = resolveApiKey( + config.provider, + (config as unknown as Record).authProfile as string | undefined + ) + return { ...config, apiKey: profileKey || config.apiKey || '' } + }) + + return { + ...store, + configs: resolvedConfigs, + defaultAssistant: resolveSlot(store.defaultAssistant, resolvedConfigs), + fastModel: resolveSlot(store.fastModel, resolvedConfigs), + } + } catch (error) { + aiLogger.error('LLM', 'Failed to load configs', error) + return { configs: [], defaultAssistant: null, fastModel: null } + } +} + +/** + * 保存配置存储 + * API Key 不再存储在 llm-config.json 中,统一由 auth-profiles.json 管理 + */ +export function saveConfigStore(store: AIConfigStore): void { + saveConfigStoreRaw({ + ...store, + configs: store.configs.map((config) => ({ + ...config, + apiKey: '', + })), + }) +} + +function saveConfigStoreRaw(store: AIConfigStore): void { + const configPath = getConfigPath() + const dir = path.dirname(configPath) + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(configPath, JSON.stringify(store, null, 2), 'utf-8') +} + +export function getAllConfigs(): AIServiceConfig[] { + return loadConfigStore().configs +} + +/** 获取默认助手 slot(含 configId + modelId) */ +export function getDefaultAssistantSlot(): import('./model-types').ModelSlot | null { + const store = loadConfigStore() + return resolveSlot(store.defaultAssistant, store.configs) +} + +/** 获取默认助手模型配置(AI 对话、工具调用、SQL 助手、上下文压缩)。自动覆盖 config.model 为 slot.modelId */ +export function getDefaultAssistantConfig(): AIServiceConfig | null { + const store = loadConfigStore() + const slot = resolveSlot(store.defaultAssistant, store.configs) + if (!slot) return null + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) return null + return { ...config, model: slot.modelId || config.model } +} + +/** 获取快速模型 slot */ +export function getFastModelSlot(): import('./model-types').ModelSlot | null { + const store = loadConfigStore() + if (store.fastModel === null) return null + return resolveSlot(store.fastModel, store.configs) +} + +/** 获取快速模型配置(会话摘要),未配置时回退到默认助手 */ +export function getFastModelConfig(): AIServiceConfig | null { + const store = loadConfigStore() + if (store.fastModel === null) return getDefaultAssistantConfig() + + const slot = resolveSlot(store.fastModel, store.configs) + if (slot) { + const config = store.configs.find((c) => c.id === slot.configId) + if (config) return { ...config, model: slot.modelId || config.model } + } + return getDefaultAssistantConfig() +} + +export function getConfigById(id: string): AIServiceConfig | null { + const store = loadConfigStore() + return store.configs.find((c) => c.id === id) || null +} + +export function addConfig(config: Omit): { + success: boolean + config?: AIServiceConfig + error?: string +} { + const store = loadConfigStore() + + if (store.configs.length >= MAX_CONFIG_COUNT) { + return { success: false, error: t('llm.maxConfigs', { count: MAX_CONFIG_COUNT }) } + } + + const now = Date.now() + const newConfig: AIServiceConfig = { + ...config, + id: randomUUID(), + createdAt: now, + updatedAt: now, + } + + store.configs.push(newConfig) + + if (store.configs.length === 1) { + store.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' } + } + + if (newConfig.apiKey) { + const profileName = newConfig.name?.toLowerCase().replace(/\s+/g, '-') || newConfig.provider + writeAuthProfile(profileName, { + type: 'api_key', + provider: newConfig.provider, + key: newConfig.apiKey, + }) + } + + saveConfigStore(store) + return { success: true, config: newConfig } +} + +export function updateConfig( + id: string, + updates: Partial> +): { success: boolean; error?: string } { + const store = loadConfigStore() + const index = store.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: t('llm.configNotFound') } + } + + const updated = { + ...store.configs[index], + ...updates, + updatedAt: Date.now(), + } + store.configs[index] = updated + + if (updates.apiKey) { + const profileName = updated.name?.toLowerCase().replace(/\s+/g, '-') || updated.provider + writeAuthProfile(profileName, { + type: 'api_key', + provider: updated.provider, + key: updates.apiKey, + }) + } + + saveConfigStore(store) + return { success: true } +} + +export function deleteConfig(id: string): { success: boolean; error?: string } { + const store = loadConfigStore() + const index = store.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: t('llm.configNotFound') } + } + + store.configs.splice(index, 1) + + const fallback = store.configs[0] + if (store.defaultAssistant?.configId === id) { + store.defaultAssistant = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null + } + if (store.fastModel?.configId === id) { + store.fastModel = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null + } + + saveConfigStore(store) + return { success: true } +} + +/** 设置默认助手模型(configId + modelId) */ +export function setDefaultAssistantModel(configId: string, modelId: string): { success: boolean; error?: string } { + const store = loadConfigStore() + const config = store.configs.find((c) => c.id === configId) + + if (!config) { + return { success: false, error: t('llm.configNotFound') } + } + + store.defaultAssistant = { configId, modelId } + saveConfigStore(store) + return { success: true } +} + +/** 设置快速模型(configId + modelId),传 null 表示跟随默认助手 */ +export function setFastModel(slot: import('./model-types').ModelSlot | null): { success: boolean; error?: string } { + const store = loadConfigStore() + + if (slot !== null) { + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) { + return { success: false, error: t('llm.configNotFound') } + } + } + + store.fastModel = slot + saveConfigStore(store) + return { success: true } +} + +export function hasActiveConfig(): boolean { + return getDefaultAssistantConfig() !== null +} + +function validateProviderBaseUrl(provider: LLMProvider, baseUrl?: string): void { + if (!baseUrl) return + + const normalized = baseUrl.replace(/\/+$/, '') + + if (provider === 'deepseek') { + if (normalized.endsWith('/chat/completions')) { + throw new Error('DeepSeek Base URL 请填写到 /v1 层级,不要包含 /chat/completions') + } + if (!normalized.endsWith('/v1')) { + throw new Error('DeepSeek Base URL 需要以 /v1 结尾') + } + } + + if (provider === 'qwen') { + if (normalized.endsWith('/chat/completions')) { + throw new Error('通义千问 Base URL 请填写到 /v1 层级,不要包含 /chat/completions') + } + if (!normalized.endsWith('/v1')) { + throw new Error('通义千问 Base URL 需要以 /v1 结尾') + } + if (normalized.includes('dashscope.aliyuncs.com') && !normalized.includes('/compatible-mode/')) { + throw new Error('通义千问 Base URL 需要包含 /compatible-mode/v1') + } + } +} + +export function getProviderInfo(provider: LLMProvider): ProviderInfo | null { + return PROVIDERS.find((p) => p.id === provider) || null +} + +// ==================== pi-ai Model 构建 ==================== + +export function buildPiModel(config: AIServiceConfig): PiModel { + validateProviderBaseUrl(config.provider, config.baseUrl) + + return buildPiModelCore(config, { + findModelFn: findModelDefinition, + headers: config.provider === 'openai-compatible' ? buildChatLabUserAgentHeaders() : undefined, + }) +} + +// ==================== Remote Model API ==================== + +export type { RemoteModel } from '@openchatlab/node-runtime' +export { type FetchRemoteModelsResult } from '@openchatlab/node-runtime' + +const electronRemoteApiOptions = () => ({ + headers: buildChatLabUserAgentHeaders(), + onLog: (level: 'info' | 'error', tag: string, message: string, data?: unknown) => { + if (level === 'error') aiLogger.error(tag, message, data) + else aiLogger.info(tag, message, data) + }, +}) + +export async function fetchRemoteModels( + provider: string, + apiKey: string, + baseUrl?: string, + apiFormat?: string +): Promise { + return fetchRemoteModelsCore(provider, apiKey, baseUrl, apiFormat, electronRemoteApiOptions()) +} + +export async function validateApiKey( + provider: LLMProvider, + apiKey: string, + baseUrl?: string, + model?: string +): Promise<{ success: boolean; error?: string }> { + return validateApiKeyCore(provider, apiKey, baseUrl, model, undefined, electronRemoteApiOptions()) +} diff --git a/apps/desktop/main/ai/llm/model-types.ts b/apps/desktop/main/ai/llm/model-types.ts new file mode 100644 index 000000000..98d8cbd47 --- /dev/null +++ b/apps/desktop/main/ai/llm/model-types.ts @@ -0,0 +1,61 @@ +/** + * 模型系统核心类型定义 — 从 @openchatlab/core 重导出 + */ + +import type { + ProviderDefinition as _ProviderDefinition, + ModelDefinition as _ModelDefinition, + ModelSlot as _ModelSlot, +} from '@openchatlab/core' + +export type { + ProviderKind, + ProviderDefinition, + ModelCapability, + ModelStatus, + ModelRecommendedFor, + ModelDefinition, + ModelSlot, +} from '@openchatlab/core' + +// Electron 专用扩展类型(不在 core 中) + +export interface LLMConnectionConfig { + id: string + name: string + providerId: string + modelId: string + apiKey: string + baseUrl?: string + maxTokens?: number + createdAt: number + updatedAt: number +} + +export interface LLMConnectionConfigCompat extends LLMConnectionConfig { + customModels?: Array<{ id: string; name: string }> +} + +export type ModelUsage = 'chat' | 'embedding' + +export interface ModelSelectionState { + usage: ModelUsage + configId: string + providerId: string + modelId: string +} + +export interface ProviderRegistryStore { + providers: _ProviderDefinition[] +} + +export interface ModelCatalogStore { + models: _ModelDefinition[] +} + +export interface LLMConnectionStore { + configs: LLMConnectionConfigCompat[] + defaultAssistant: _ModelSlot | null + fastModel: _ModelSlot | null + schemaVersion: number +} diff --git a/apps/desktop/main/ai/llm/types.ts b/apps/desktop/main/ai/llm/types.ts new file mode 100644 index 000000000..8d4ba12d3 --- /dev/null +++ b/apps/desktop/main/ai/llm/types.ts @@ -0,0 +1,40 @@ +/** + * LLM 服务类型定义 + */ + +export * from './model-types' + +export type LLMProvider = string + +export interface ProviderInfo { + id: string + name: string + defaultBaseUrl: string + models: Array<{ + id: string + name: string + description?: string + }> +} + +export interface AIServiceConfig { + id: string + name: string + provider: LLMProvider + apiKey: string + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string + customModels?: Array<{ id: string; name: string }> + createdAt: number + updatedAt: number +} + +export interface AIConfigStore { + configs: AIServiceConfig[] + defaultAssistant: import('./model-types').ModelSlot | null + fastModel: import('./model-types').ModelSlot | null +} + +export const MAX_CONFIG_COUNT = 99 diff --git a/apps/desktop/main/ai/logger.ts b/apps/desktop/main/ai/logger.ts new file mode 100644 index 000000000..a3d6a4959 --- /dev/null +++ b/apps/desktop/main/ai/logger.ts @@ -0,0 +1,47 @@ +/** + * AI 日志模块(Electron 初始化入口) + * + * 实际实现在 @openchatlab/node-runtime 的 AiLogger 类。 + * 此处用 Electron 的 logsDir 初始化单例实例。 + */ + +import { AiLogger } from '@openchatlab/node-runtime' +import { getPathProvider } from '../path-context' + +export { extractErrorInfo, extractErrorStack } from '@openchatlab/node-runtime' + +let _instance: AiLogger | null = null +function getInstance(): AiLogger { + if (!_instance) _instance = new AiLogger(getPathProvider().getLogsDir()) + return _instance +} + +export const aiLogger = { + debug: (category: string, message: string, data?: unknown) => getInstance().debug(category, message, data), + info: (category: string, message: string, data?: unknown) => getInstance().info(category, message, data), + warn: (category: string, message: string, data?: unknown) => getInstance().warn(category, message, data), + error: (category: string, message: string, data?: unknown) => getInstance().error(category, message, data), + close: () => getInstance().close(), + getLogPath: () => getInstance().getLogPath(), + getExistingLogPath: () => getInstance().getExistingLogPath(), +} + +export function setDebugMode(enabled: boolean): void { + getInstance().setDebugMode(enabled) +} + +export function isDebugMode(): boolean { + return getInstance().isDebugMode() +} + +export function logAI(message: string, data?: unknown) { + aiLogger.info('AI', message, data) +} + +export function logLLM(message: string, data?: unknown) { + aiLogger.info('LLM', message, data) +} + +export function logSearch(message: string, data?: unknown) { + aiLogger.info('Search', message, data) +} diff --git a/apps/desktop/main/ai/serialize-error.ts b/apps/desktop/main/ai/serialize-error.ts new file mode 100644 index 000000000..d0ab62044 --- /dev/null +++ b/apps/desktop/main/ai/serialize-error.ts @@ -0,0 +1,168 @@ +/** + * 将任意错误对象序列化为 SerializedErrorInfo, + * 保留尽可能多的 HTTP / provider 上下文,用于前端详情展示和日志记录。 + */ + +import type { SerializedErrorInfo } from '../../shared/types' + +function safeString(val: unknown): string | null { + if (val === undefined || val === null) return null + if (typeof val === 'string') return val + try { + return JSON.stringify(val, null, 2) + } catch { + return String(val) + } +} + +function extractFromCandidate(candidate: unknown, info: SerializedErrorInfo): void { + if (!candidate || typeof candidate !== 'object') return + + const rec = candidate as Record + + if (typeof rec.statusCode === 'number' && info.statusCode == null) { + info.statusCode = rec.statusCode + } + + if (typeof rec.status === 'number' && info.statusCode == null) { + info.statusCode = rec.status + } + + if (typeof rec.url === 'string' && !info.url) { + info.url = rec.url + } + + if (typeof rec.responseBody === 'string' && !info.responseBody) { + info.responseBody = rec.responseBody + } + + if (rec.responseHeaders && typeof rec.responseHeaders === 'object' && !info.responseHeaders) { + try { + const headers = rec.responseHeaders as Record + const plain: Record = {} + for (const [key, val] of Object.entries(headers)) { + plain[key] = String(val) + } + info.responseHeaders = plain + } catch { + // ignore + } + } + + // OpenAI SDK: headers 在 error.headers (Headers 对象) + if (rec.headers && typeof rec.headers === 'object' && !info.responseHeaders) { + try { + const h = rec.headers + if (typeof (h as any).entries === 'function') { + const plain: Record = {} + for (const [key, val] of (h as any).entries()) { + plain[key] = String(val) + } + if (Object.keys(plain).length > 0) { + info.responseHeaders = plain + } + } + } catch { + // ignore + } + } + + if (rec.requestBodyValues !== undefined && !info.requestBody) { + info.requestBody = safeString(rec.requestBodyValues) + } + if (rec.requestBody !== undefined && !info.requestBody) { + info.requestBody = safeString(rec.requestBody) + } + + // OpenAI SDK: error.error 包含 response body 中的 error 对象 + if (rec.error !== undefined && typeof rec.error === 'object' && !info.responseBody) { + info.responseBody = safeString(rec.error) + } + + if (rec.data !== undefined && !info.responseBody) { + info.responseBody = safeString(rec.data) + } +} + +/** + * 从错误消息字符串中尝试解析 HTTP 状态码。 + * OpenAI SDK 的 APIError.message 通常以 "NNN " 开头,如 "401 Incorrect API key..." + */ +function parseStatusCodeFromMessage(message: string | null): number | null { + if (!message) return null + const match = message.match(/^(\d{3})\s/) + if (match) { + const code = parseInt(match[1], 10) + if (code >= 100 && code < 600) return code + } + return null +} + +export function serializeError(error: unknown, provider?: string): SerializedErrorInfo { + const info: SerializedErrorInfo = { + name: null, + message: null, + stack: null, + } + + if (provider) { + info.provider = provider + } + + if (!error) { + info.message = 'Unknown error' + return info + } + + if (typeof error === 'string') { + info.message = error + info.statusCode = parseStatusCodeFromMessage(error) + return info + } + + if (!(typeof error === 'object')) { + info.message = String(error) + return info + } + + const err = error as Record + + if (typeof err.name === 'string') info.name = err.name + if (typeof err.message === 'string') info.message = err.message + if (typeof err.stack === 'string') info.stack = err.stack + + if (err.cause !== undefined) { + info.cause = safeString(err.cause) + } + + // Agent 层附加的上下文(provider / model / url / requestBody) + if (err.agentContext && typeof err.agentContext === 'object') { + const ctx = err.agentContext as Record + if (typeof ctx.provider === 'string' && !info.provider) { + info.provider = ctx.provider + } + if (typeof ctx.url === 'string' && !info.url) { + info.url = ctx.url + } + if (typeof ctx.requestBody === 'string' && !info.requestBody) { + info.requestBody = ctx.requestBody + } + } + + // 从主错误对象及其嵌套 lastError / errors / cause 中提取 HTTP 上下文 + const candidates: unknown[] = [error] + if (err.lastError) candidates.push(err.lastError) + if (Array.isArray(err.errors)) candidates.push(...err.errors) + if (err.cause && typeof err.cause === 'object') candidates.push(err.cause) + + for (const candidate of candidates) { + extractFromCandidate(candidate, info) + } + + // 如果没有从属性中提取到 statusCode,尝试从 message 字符串解析 + if (info.statusCode == null) { + info.statusCode = parseStatusCodeFromMessage(info.message) + } + + return info +} diff --git a/apps/desktop/main/ai/skills/index.ts b/apps/desktop/main/ai/skills/index.ts new file mode 100644 index 000000000..35ea046fc --- /dev/null +++ b/apps/desktop/main/ai/skills/index.ts @@ -0,0 +1,21 @@ +/** + * 技能系统模块入口 + */ + +export type { SkillDef, SkillSummary, BuiltinSkillInfo, SkillInitResult, SkillSaveResult } from './types' + +export { + initSkillManager, + getAllSkills, + getSkillConfig, + getBuiltinCatalog, + importSkill, + reimportSkill, + updateSkill, + createSkill, + deleteSkill, + getSkillMenu, + importSkillFromMd, +} from './manager' + +export { parseSkillFile } from '@openchatlab/node-runtime' diff --git a/apps/desktop/main/ai/skills/manager.ts b/apps/desktop/main/ai/skills/manager.ts new file mode 100644 index 000000000..6c83c2083 --- /dev/null +++ b/apps/desktop/main/ai/skills/manager.ts @@ -0,0 +1,100 @@ +/** + * Skill manager — thin Electron adapter. + * + * Delegates all logic to @openchatlab/node-runtime's SkillManagerCore class, + * injecting Electron-specific dependencies (paths, builtins, logger). + */ + +import * as fs from 'fs' +import * as path from 'path' +import { createHash } from 'crypto' +import { SkillManagerCore, type SkillManagerFs } from '@openchatlab/node-runtime' +import { getPathProvider } from '../../path-context' +import { aiLogger } from '../logger' +import type { SkillDef, SkillSummary, SkillInitResult, SkillSaveResult, BuiltinSkillInfo } from './types' + +const nodeFs: SkillManagerFs = { + ensureDir(dir: string) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + }, + listFiles(dir: string, ext: string) { + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir).filter((f) => f.endsWith(ext)) + }, + readFile(filePath: string) { + return fs.readFileSync(filePath, 'utf-8') + }, + writeFile(filePath: string, content: string) { + fs.writeFileSync(filePath, content, 'utf-8') + }, + deleteFile(filePath: string) { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + }, + fileExists(filePath: string) { + return fs.existsSync(filePath) + }, + joinPath(...parts: string[]) { + return path.join(...parts) + }, +} + +let _manager: SkillManagerCore | null = null + +export function getManager(): SkillManagerCore { + if (!_manager) { + _manager = new SkillManagerCore({ + fs: nodeFs, + skillsDir: path.join(getPathProvider().getAiDataDir(), 'skills'), + builtinRawSkills: [], + contentHash: (content: string) => createHash('md5').update(content).digest('hex'), + logger: aiLogger, + }) + } + return _manager +} + +// ==================== Public API (preserves existing function signatures) ==================== + +export function initSkillManager(): SkillInitResult { + return getManager().init() +} + +export function getAllSkills(): SkillSummary[] { + return getManager().getAllSkills() +} + +export function getSkillConfig(id: string): SkillDef | null { + return getManager().getSkillConfig(id) +} + +export function getBuiltinCatalog(): BuiltinSkillInfo[] { + return getManager().getBuiltinCatalog() +} + +export function importSkill(builtinId: string): SkillSaveResult & { id?: string } { + return getManager().importSkill(builtinId) +} + +export function reimportSkill(id: string): SkillSaveResult { + return getManager().reimportSkill(id) +} + +export function updateSkill(id: string, rawMd: string): SkillSaveResult { + return getManager().updateSkill(id, rawMd) +} + +export function createSkill(rawMd: string): SkillSaveResult & { id?: string } { + return getManager().createSkill(rawMd) +} + +export function deleteSkill(id: string): SkillSaveResult { + return getManager().deleteSkill(id) +} + +export function importSkillFromMd(rawMd: string): SkillSaveResult & { id?: string } { + return getManager().importSkillFromMd(rawMd) +} + +export function getSkillMenu(chatType: 'group' | 'private', allowedTools?: string[]): string | null { + return getManager().getSkillMenu(chatType, allowedTools) +} diff --git a/apps/desktop/main/ai/skills/types.ts b/apps/desktop/main/ai/skills/types.ts new file mode 100644 index 000000000..0b41056f7 --- /dev/null +++ b/apps/desktop/main/ai/skills/types.ts @@ -0,0 +1,78 @@ +/** + * 技能系统类型定义 + * 技能 = 可复用的分析工作流(Markdown + YAML Frontmatter 格式) + */ + +/** + * 技能完整配置(运行时) + * 由 Markdown 文件解析而来:YAML frontmatter → 元数据字段,Markdown body → prompt 字段 + */ +export interface SkillDef { + /** 技能唯一标识(来自 frontmatter.id 或文件名) */ + id: string + /** 技能显示名称 */ + name: string + /** 简短描述 + 快捷用语,同时用于 AI 自选时的菜单展示 */ + description: string + /** 关键词标签(来自 frontmatter.tags,逗号分隔解析为数组) */ + tags: string[] + /** 适用的聊天类型 */ + chatScope: 'all' | 'group' | 'private' + + /** + * 技能提示词(来自 Markdown body) + * 包含目标描述、步骤编排、输出格式等完整指导。 + * 手动选择时注入 System Prompt;AI 自选时通过 activate_skill 工具按需加载。 + */ + prompt: string + + /** + * 该技能依赖的工具名称列表 + * - 运行时校验:tools ⊆ assistant.allowedBuiltinTools + * - 不满足时前端灰显该技能 + * - 空数组 = 无工具依赖 + */ + tools: string[] + + /** 内置技能来源标识(用户导入后由 SkillManager 自动设置) */ + builtinId?: string +} + +/** + * 传递给前端的技能摘要(不含完整 prompt) + */ +export interface SkillSummary { + id: string + name: string + description: string + tags: string[] + chatScope: 'all' | 'group' | 'private' + tools: string[] + builtinId?: string +} + +/** + * 技能市场中的内置技能信息(模板目录项) + */ +export interface BuiltinSkillInfo extends SkillSummary { + /** 用户是否已导入该技能 */ + imported: boolean + /** 内置版本有更新(基于内容 hash 比对) */ + hasUpdate: boolean +} + +/** + * SkillManager 初始化结果 + */ +export interface SkillInitResult { + /** 加载的技能总数 */ + total: number +} + +/** + * 技能操作结果 + */ +export interface SkillSaveResult { + success: boolean + error?: string +} diff --git a/apps/desktop/main/ai/summary/index.ts b/apps/desktop/main/ai/summary/index.ts new file mode 100644 index 000000000..99f8b4a79 --- /dev/null +++ b/apps/desktop/main/ai/summary/index.ts @@ -0,0 +1,106 @@ +/** + * Session summary generation — Electron adapter. + * + * Implements SummaryDeps by wiring Electron's database, LLM config, and i18n, + * then delegates all logic to the shared @openchatlab/node-runtime module. + */ + +import Database from 'better-sqlite3' +import { completeSimple, type PiTextContent } from '@openchatlab/node-runtime' +import { loadSegmentMessages, getSegmentSummary, saveSegmentSummary } from '@openchatlab/core' +import { getFastModelConfig, buildPiModel } from '../llm' +import { getDbPath, openDatabase } from '../../database/core' +import { wrapAsDatabaseAdapter } from '../../worker/core' +import { aiLogger } from '../logger' +import { t } from '../../i18n' +import { + generateSessionSummary as generateCore, + generateSessionSummaries as generateBatchCore, + checkSessionsCanGenerateSummary as checkCore, + type SummaryDeps, +} from '@openchatlab/node-runtime' + +function buildDeps(dbSessionId: string): SummaryDeps { + return { + loadMessages(segmentId, limit = 500) { + const db = openDatabase(dbSessionId, true) + if (!db) return null + try { + return loadSegmentMessages(wrapAsDatabaseAdapter(db), segmentId, limit) + } catch (error) { + aiLogger.error('Summary', `Failed to get session messages: ${error}`) + return null + } + }, + + saveSummary(segmentId, summary) { + const dbPath = getDbPath(dbSessionId) + const db = new Database(dbPath) + try { + saveSegmentSummary(wrapAsDatabaseAdapter(db), segmentId, summary) + } finally { + db.close() + } + }, + + getSummary(segmentId) { + const db = openDatabase(dbSessionId, true) + if (!db) return null + try { + return getSegmentSummary(wrapAsDatabaseAdapter(db), segmentId) + } catch { + return null + } + }, + + async llmComplete(systemPrompt, userPrompt, options) { + const fastConfig = getFastModelConfig() + if (!fastConfig) throw new Error(t('llm.notConfigured')) + + const piModel = buildPiModel(fastConfig) + const result = await completeSimple( + piModel, + { systemPrompt, messages: [{ role: 'user', content: userPrompt, timestamp: Date.now() }] }, + { apiKey: fastConfig.apiKey, temperature: options?.temperature, maxTokens: options?.maxTokens } + ) + + if (result.stopReason === 'error' || result.stopReason === 'aborted') { + throw new Error(result.errorMessage || t('llm.callFailed')) + } + + return result.content + .filter((item): item is PiTextContent => item.type === 'text') + .map((item) => item.text) + .join('') + }, + + t, + logger: aiLogger, + } +} + +export async function generateSessionSummary( + dbSessionId: string, + segmentId: number, + locale: string = 'zh-CN', + forceRegenerate: boolean = false, + strategy?: 'brief' | 'standard' +): Promise<{ success: boolean; summary?: string; error?: string }> { + return generateCore(buildDeps(dbSessionId), segmentId, { locale, forceRegenerate, strategy }) +} + +export async function generateSessionSummaries( + dbSessionId: string, + segmentIds: number[], + locale: string = 'zh-CN', + onProgress?: (current: number, total: number) => void +): Promise<{ success: number; failed: number; skipped: number }> { + return generateBatchCore(buildDeps(dbSessionId), segmentIds, { locale }, onProgress) +} + +export function checkSessionsCanGenerateSummary( + dbSessionId: string, + segmentIds: number[] +): Map { + return checkCore(buildDeps(dbSessionId), segmentIds) +} diff --git a/apps/desktop/main/ai/tools/debug-executor.ts b/apps/desktop/main/ai/tools/debug-executor.ts new file mode 100644 index 000000000..bd03ecff5 --- /dev/null +++ b/apps/desktop/main/ai/tools/debug-executor.ts @@ -0,0 +1,30 @@ +import type { BatchSegmentOptions, SupportedLocale } from '@openchatlab/core' +import type { AiToolExecuteRequest, AiToolExecuteResult } from '@openchatlab/http-routes' +import { batchSegmentWithFrequency } from '@openchatlab/node-runtime' +import type { SemanticSearchToolService } from '@openchatlab/tools' +import { t as i18nT } from '../../i18n' +import { WorkerDataProvider } from './worker-data-provider' +import { executeRegistryTool } from './tool-executor-core' + +/** + * 构造 Electron 手动调试用的 AI 工具执行器。 + * + * 注入 semanticIndexService,使手动执行语义检索工具与正常 Agent stream 行为一致; + * 缺失时(向量库不可用)语义工具会优雅返回不可用。其余依赖(Worker 数据访问、分词、 + * i18n 模板)为 Electron 平台特有,统一在此装配后委托给平台无关的执行核心。 + */ +export function createExecuteElectronAiTool( + semanticIndexService?: SemanticSearchToolService +): (params: AiToolExecuteRequest) => Promise { + return (params) => + executeRegistryTool(params, { + dataProvider: new WorkerDataProvider(params.sessionId, params.abortSignal), + semanticIndexService, + segmentText: (texts, locale, options) => + batchSegmentWithFrequency(texts, locale as SupportedLocale, options as BatchSegmentOptions), + translateTemplate: (key: string) => { + const translated = i18nT(key) + return translated !== key ? translated : undefined + }, + }) +} diff --git a/apps/desktop/main/ai/tools/definitions/index.test.ts b/apps/desktop/main/ai/tools/definitions/index.test.ts new file mode 100644 index 000000000..ae898446b --- /dev/null +++ b/apps/desktop/main/ai/tools/definitions/index.test.ts @@ -0,0 +1,35 @@ +import { mock, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { BUILTIN_TOOL_CATALOG } from '@openchatlab/core' + +type MinEntry = { name: string; category: string } + +describe('Electron TOOL_REGISTRY', () => { + it('covers all BUILTIN_TOOL_CATALOG entries with correct name and category', async () => { + const recorded: MinEntry[] = [] + + // Mock the Electron-specific adapter module before loading the registry. + // This prevents @openchatlab/node-runtime (pi-ai) and electron from being + // resolved in the test environment, which lacks those native bindings. + await mock.module('../shared-tool-adapter', { + namedExports: { + adaptSharedTool(tool: { name: string }, opts: { category: string }) { + const entry = { name: tool.name, category: opts.category, factory: () => ({}) } + recorded.push(entry) + return entry + }, + }, + }) + + // Dynamic import keeps the mock active before the module graph resolves. + const { TOOL_REGISTRY } = (await import('./index.js')) as { TOOL_REGISTRY: MinEntry[] } + + // Drift guard: every tool in BUILTIN_TOOL_CATALOG must appear in TOOL_REGISTRY + // with the correct name and category. + const registeredMap = new Map(TOOL_REGISTRY.map((e) => [e.name, e.category])) + const mismatches = BUILTIN_TOOL_CATALOG.filter((e) => registeredMap.get(e.name) !== e.category).map( + (e) => `${e.name} (expected category=${e.category}, got ${registeredMap.get(e.name) ?? 'missing'})` + ) + assert.deepEqual(mismatches, [], `catalog/registry drift in Electron TOOL_REGISTRY: ${mismatches.join(', ')}`) + }) +}) diff --git a/apps/desktop/main/ai/tools/definitions/index.ts b/apps/desktop/main/ai/tools/definitions/index.ts new file mode 100644 index 000000000..470dd7821 --- /dev/null +++ b/apps/desktop/main/ai/tools/definitions/index.ts @@ -0,0 +1,67 @@ +/** + * 工具定义聚合 + 统一注册表 + * + * TOOL_REGISTRY 是全局唯一的工具清单,驱动后端加载和前端目录展示。 + * 所有工具定义来自 @openchatlab/tools 共享包,通过 adaptSharedTool 适配为 Electron AgentTool。 + */ + +import type { ToolRegistryEntry } from '../types' + +import { + chatOverviewTool, + searchMessagesTool, + deepSearchMessagesTool, + recentMessagesTool, + getMessageContextTool, + getSegmentMessagesTool, + getMembersTool, + schemaTool, + sqlQueryTool, + memberStatsTool, + timeStatsTool, + getMemberNameHistoryTool, + getConversationBetweenTool, + getSegmentSummariesTool, + responseTimeAnalysisTool, + keywordFrequencyTool, + renderChartTool, + SQL_TOOL_DEFS, + createSqlToolDefinition, +} from '@openchatlab/tools' + +import { adaptSharedTool } from '../shared-tool-adapter' + +// SQL 工具转换为 ToolDefinition 再适配 +const sqlToolDefinitions = SQL_TOOL_DEFS.map(createSqlToolDefinition) + +export const sqlToolEntries: ToolRegistryEntry[] = sqlToolDefinitions.map((t) => + adaptSharedTool(t, { category: 'analysis' }) +) + +export const SQL_TOOL_NAMES = SQL_TOOL_DEFS.map((d) => d.name) + +export const TOOL_REGISTRY: ToolRegistryEntry[] = [ + // ==================== Core 工具(始终加载) ==================== + adaptSharedTool(chatOverviewTool, { category: 'core' }), + adaptSharedTool(searchMessagesTool, { category: 'core', truncationStrategy: 'keep_first' }), + adaptSharedTool(deepSearchMessagesTool, { category: 'core', truncationStrategy: 'keep_first' }), + adaptSharedTool(recentMessagesTool, { category: 'core', truncationStrategy: 'keep_last' }), + adaptSharedTool(getMessageContextTool, { category: 'core', truncationStrategy: 'keep_last' }), + adaptSharedTool(getSegmentMessagesTool, { category: 'core', truncationStrategy: 'keep_last' }), + adaptSharedTool(getMembersTool, { category: 'core' }), + adaptSharedTool(schemaTool, { category: 'core' }), + + // ==================== Analysis 工具(按需加载) ==================== + adaptSharedTool(memberStatsTool, { category: 'analysis' }), + adaptSharedTool(timeStatsTool, { category: 'analysis' }), + adaptSharedTool(getMemberNameHistoryTool, { category: 'analysis' }), + adaptSharedTool(getConversationBetweenTool, { category: 'analysis', truncationStrategy: 'keep_last' }), + adaptSharedTool(getSegmentSummariesTool, { category: 'analysis' }), + adaptSharedTool(responseTimeAnalysisTool, { category: 'analysis' }), + adaptSharedTool(keywordFrequencyTool, { category: 'analysis' }), + adaptSharedTool(renderChartTool, { category: 'analysis' }), + adaptSharedTool(sqlQueryTool, { category: 'analysis' }), + + // ==================== SQL 分析工具 ==================== + ...sqlToolEntries, +] diff --git a/apps/desktop/main/ai/tools/index.ts b/apps/desktop/main/ai/tools/index.ts new file mode 100644 index 000000000..5e3b72a1e --- /dev/null +++ b/apps/desktop/main/ai/tools/index.ts @@ -0,0 +1,167 @@ +/** + * AI Tools 模块入口 + * 工具创建、预处理管道与管理 + * + * 架构:工具返回结构化数据(rawMessages) → 处理层执行预处理 + 格式化 → 生成 LLM 内容 + */ + +import type { AgentTool } from '@openchatlab/node-runtime' +import type { ToolContext, TruncationStrategy } from './types' +import { TOOL_REGISTRY } from './definitions' +import { isAnalysisToolAllowed } from './tool-filter' +import { t as i18nT } from '../../i18n' +import { applyPreprocessingPipeline, type PreprocessableMessage } from '@openchatlab/node-runtime' +import { aiLogger } from '../logger' +import { getSkillConfig } from '../skills' +import { + createActivateSkillTool as sharedCreateActivateSkillTool, + createChartSchemaGateState, + getSkillConfigWithBuiltinChart, + wrapWithChartSchemaGate, +} from '@openchatlab/node-runtime' +import { semanticSearchCurrentChatTool, retrieveChatEvidenceTool } from '@openchatlab/tools' +import { adaptSharedTool } from './shared-tool-adapter' + +// 语义检索工具按需暴露:仅当前会话可检索时追加,不进 TOOL_REGISTRY(避免被当作始终加载的 core 工具) +const semanticSearchEntry = adaptSharedTool(semanticSearchCurrentChatTool, { category: 'core' }) + +// 证据检索工具始终暴露(语义不可用时可降级到关键词路径)。 +// 不进 TOOL_REGISTRY / 工具目录:它是始终加载、用户不可切换的特殊 core 工具,行为同语义工具一样特殊处理。 +const retrieveChatEvidenceEntry = adaptSharedTool(retrieveChatEvidenceTool, { + category: 'core', + truncationStrategy: 'keep_first', +}) + +const CORE_TOOL_NAMES = new Set(TOOL_REGISTRY.filter((e) => e.category === 'core').map((e) => e.name)) + +const preprocessLogger = { + info: (category: string, message: string, extra?: Record) => aiLogger.info(category, message, extra), + warn: (category: string, message: string, extra?: Record) => aiLogger.warn(category, message, extra), +} + +const TRUNCATION_STRATEGY_MAP = new Map( + TOOL_REGISTRY.filter((e) => e.truncationStrategy).map((e) => [e.name, e.truncationStrategy!]) +) + +// 导出类型 +export * from './types' + +/** + * 翻译 AgentTool 的描述(工具级 + 参数级) + * + * i18n 键命名规则: + * - 工具描述:ai.tools.{toolName}.desc + * - 参数描述:ai.tools.{toolName}.params.{paramName} + */ +function translateTool(tool: AgentTool): AgentTool { + const name = tool.name + + const descKey = `ai.tools.${name}.desc` + const translatedDesc = i18nT(descKey) + + const params = tool.parameters as Record + if (params?.properties && typeof params.properties === 'object') { + for (const [paramName, param] of Object.entries(params.properties as Record>)) { + const paramKey = `ai.tools.${name}.params.${paramName}` + const translated = i18nT(paramKey) + if (translated !== paramKey) { + param.description = translated + } + } + } + + return { + ...tool, + description: translatedDesc !== descKey ? translatedDesc : tool.description, + } +} + +/** + * 预处理包装层 + * 拦截工具的 execute 结果:如果 details 中包含 rawMessages, + * 则委托共享管道 applyPreprocessingPipeline 执行预处理 + 格式化 + 截断。 + */ +function wrapWithPreprocessing(tool: AgentTool, context: ToolContext): AgentTool { + const originalExecute = tool.execute + return { + ...tool, + execute: async (toolCallId: string, params: any, _signal?: AbortSignal, _onUpdate?: unknown) => { + const result = await originalExecute(toolCallId, params) + + const details = result.details as Record | undefined + if (!details?.rawMessages || !Array.isArray(details.rawMessages)) { + return result + } + + const { rawMessages, ...restDetails } = details + + const pipelineResult = applyPreprocessingPipeline({ + rawMessages: rawMessages as PreprocessableMessage[], + preprocessConfig: context.preprocessConfig, + locale: context.locale, + anonymizeNames: context.preprocessConfig?.anonymizeNames ?? false, + ownerPlatformId: context.ownerInfo?.platformId, + maxToolResultTokens: context.maxToolResultTokens, + truncationStrategy: TRUNCATION_STRATEGY_MAP.get(tool.name) ?? 'keep_last', + extraDetails: restDetails as Record, + logger: preprocessLogger, + }) + + return { + content: [{ type: 'text' as const, text: pipelineResult.text }], + details: pipelineResult.details, + } + }, + } +} + +/** + * 获取所有可用的 AgentTool + * + * - Core 工具始终加载,不受 allowedTools 白名单影响 + * - Analysis 工具仅在 allowedTools 中显式列出时加载 + * + * @param context 工具上下文 + * @param allowedTools analysis 工具白名单;undefined/[] 表示不加载 analysis 工具 + */ +export async function getAllTools(context: ToolContext, allowedTools?: string[]): Promise[]> { + const coreTools = TOOL_REGISTRY.filter((e) => e.category === 'core').map((e) => e.factory(context)) + + const analysisTools = TOOL_REGISTRY.filter( + (e) => e.category === 'analysis' && isAnalysisToolAllowed(e.name, allowedTools) + ).map((e) => e.factory(context)) + + const semanticTools = + context.semanticIndexService && (await context.semanticIndexService.canSearch(context.sessionId)) + ? [semanticSearchEntry.factory(context)] + : [] + + // 证据检索工具始终加载,不受语义索引可用性影响(无索引时走关键词降级) + const evidenceTools = [retrieveChatEvidenceEntry.factory(context)] + + const chartSchemaGateState = createChartSchemaGateState() + + return [...coreTools, ...analysisTools, ...evidenceTools, ...semanticTools] + .map(translateTool) + .map((t) => wrapWithChartSchemaGate(t, chartSchemaGateState)) + .map((t) => wrapWithPreprocessing(t, context)) +} + +/** + * 创建 activate_skill 元工具(AI 自选模式专用) + * 委托给共享包的 createActivateSkillTool,注入 Electron 端的 getSkillConfig + */ +export function createActivateSkillTool( + chatType: 'group' | 'private', + allowedTools?: string[], + locale: string = 'zh-CN' +): AgentTool { + return sharedCreateActivateSkillTool({ + chatType, + allowedTools, + locale, + getSkillConfig: (id) => + getSkillConfigWithBuiltinChart(id, locale, (skillConfigId) => getSkillConfig(skillConfigId)), + coreToolNames: CORE_TOOL_NAMES, + }) +} diff --git a/apps/desktop/main/ai/tools/shared-tool-adapter.ts b/apps/desktop/main/ai/tools/shared-tool-adapter.ts new file mode 100644 index 000000000..e45b834b3 --- /dev/null +++ b/apps/desktop/main/ai/tools/shared-tool-adapter.ts @@ -0,0 +1,109 @@ +/** + * 共享工具适配器 + * + * 将 @openchatlab/tools 的 ToolDefinition 转换为 Electron 的 ToolRegistryEntry。 + * Electron 端使用 WorkerDataProvider 替代 Server 端的 CoreDataProvider。 + */ + +import type { ToolDefinition, ToolExecutionContext, RawMessage } from '@openchatlab/tools' +import type { AgentTool, AgentToolResult, PreprocessableMessage, PreprocessConfig } from '@openchatlab/node-runtime' +import { batchSegmentWithFrequency, preprocessMessages } from '@openchatlab/node-runtime' +import type { ToolContext, ToolRegistryEntry, ToolCategory } from './types' +import { WorkerDataProvider } from './worker-data-provider' +import { t as i18nT } from '../../i18n' + +interface AdaptOptions { + category: ToolCategory + truncationStrategy?: 'keep_first' | 'keep_last' +} + +function buildExecutionContext(ctx: ToolContext): ToolExecutionContext { + return { + dataProvider: new WorkerDataProvider(ctx.sessionId, ctx.abortSignal), + sessionId: ctx.sessionId, + locale: ctx.locale, + timeFilter: ctx.timeFilter, + abortSignal: ctx.abortSignal, + searchContextBefore: ctx.searchContextBefore, + searchContextAfter: ctx.searchContextAfter, + maxMessagesLimit: ctx.maxMessagesLimit, + maxToolResultTokens: ctx.maxToolResultTokens, + semanticIndexService: ctx.semanticIndexService, + preprocessConfig: ctx.preprocessConfig as Record | undefined, + ownerPlatformId: ctx.ownerInfo?.platformId, + segmentText: (texts, locale, options) => batchSegmentWithFrequency(texts, locale as any, options as any), + translateTemplate: (key: string) => { + const translated = i18nT(key) + return translated !== key ? translated : undefined + }, + desensitizeMessages: (messages: RawMessage[]): RawMessage[] => + preprocessMessages( + messages as PreprocessableMessage[], + ctx.preprocessConfig as PreprocessConfig | undefined + ) as RawMessage[], + } +} + +export function adaptSharedTool(tool: ToolDefinition, options: AdaptOptions): ToolRegistryEntry { + return { + name: tool.name, + category: options.category, + truncationStrategy: options.truncationStrategy ?? tool.truncationStrategy, + factory(context: ToolContext): AgentTool { + const schema = { + type: 'object' as const, + properties: { ...tool.inputSchema.properties }, + required: tool.inputSchema.required ?? [], + } + + return { + name: tool.name, + label: tool.name, + // 保留英文原始描述作为 fallback:translateTool 会在 i18n key 命中时覆盖为译文, + // 缺 key 时回退到此英文描述,避免把裸 i18n key 当作工具描述传给 LLM。 + description: tool.description, + parameters: schema as any, + async execute(_toolCallId: string, params: unknown): Promise> { + const toolParams = (params && typeof params === 'object' ? params : {}) as Record + const execCtx = buildExecutionContext(context) + try { + const result = await tool.handler(toolParams, execCtx) + const chartDetails = + result.chart || result.charts + ? { + ...(result.chart ? { chart: result.chart } : {}), + ...(result.charts ? { charts: result.charts } : {}), + } + : {} + + if (result.rawMessages && result.rawMessages.length > 0) { + const baseData = (typeof result.data === 'object' && result.data !== null ? result.data : {}) as Record< + string, + unknown + > + return { + content: [{ type: 'text', text: result.content }], + details: { ...baseData, ...chartDetails, rawMessages: result.rawMessages }, + } + } + + const baseDetails = + typeof result.data === 'object' && result.data !== null + ? (result.data as Record) + : result.data === undefined + ? null + : { value: result.data } + + return { + content: [{ type: 'text', text: result.content }], + details: Object.keys(chartDetails).length > 0 ? { ...(baseDetails ?? {}), ...chartDetails } : baseDetails, + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + return { content: [{ type: 'text', text: `Error: ${msg}` }], details: null } + } + }, + } + }, + } +} diff --git a/apps/desktop/main/ai/tools/tool-executor-core.test.ts b/apps/desktop/main/ai/tools/tool-executor-core.test.ts new file mode 100644 index 000000000..453f29507 --- /dev/null +++ b/apps/desktop/main/ai/tools/tool-executor-core.test.ts @@ -0,0 +1,69 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import type { SemanticSearchToolResult, SemanticSearchToolService } from '@openchatlab/tools' +import type { AiToolExecuteRequest } from '@openchatlab/http-routes' +import { buildToolExecutionContext, executeRegistryTool } from './tool-executor-core' + +function makeRequest(overrides: Partial = {}): AiToolExecuteRequest { + return { + testId: 't1', + toolName: 'semantic_search_current_chat', + params: { query: '排期' }, + sessionId: 'sess-1', + abortSignal: new AbortController().signal, + ...overrides, + } +} + +describe('tool-executor-core context injection', () => { + it('buildToolExecutionContext forwards semanticIndexService and request identity', () => { + const service = { + canSearch: () => true, + searchForTool: async () => ({}) as unknown as SemanticSearchToolResult, + } satisfies SemanticSearchToolService + const params = makeRequest() + const ctx = buildToolExecutionContext(params, { semanticIndexService: service }) + assert.equal(ctx.sessionId, 'sess-1') + assert.equal(ctx.abortSignal, params.abortSignal) + assert.equal(ctx.semanticIndexService, service) + }) + + it('manual execution reaches the injected semanticIndexService', async () => { + const calls: Array<{ sessionId: string; query: string }> = [] + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async (sessionId, query) => { + calls.push({ sessionId, query }) + return { + available: true, + text: 'evidence-block', + returned: 1, + hitCount: 1, + partial: false, + coverage: 1, + truncated: false, + sources: [], + } + }, + } + + const result = await executeRegistryTool(makeRequest(), { semanticIndexService: service }) + + assert.equal(result.success, true) + assert.deepEqual(calls, [{ sessionId: 'sess-1', query: '排期' }]) + assert.ok(result.content?.[0]?.text.includes('evidence-block')) + }) + + it('semantic tool degrades gracefully when service is not injected', async () => { + const result = await executeRegistryTool(makeRequest(), {}) + assert.equal(result.success, true) + assert.ok(result.content?.[0]?.text.toLowerCase().includes('not available')) + }) + + it('returns an error for unknown tools', async () => { + const result = await executeRegistryTool(makeRequest({ toolName: 'no_such_tool' }), {}) + assert.equal(result.success, false) + assert.match(result.error ?? '', /Tool not found/) + }) +}) diff --git a/apps/desktop/main/ai/tools/tool-executor-core.ts b/apps/desktop/main/ai/tools/tool-executor-core.ts new file mode 100644 index 000000000..207b4f9c9 --- /dev/null +++ b/apps/desktop/main/ai/tools/tool-executor-core.ts @@ -0,0 +1,89 @@ +/** + * 平台无关的 AI 工具执行核心 + * + * 从注册表查找工具、在注入的上下文上执行,并统一处理取消、超大结果截断与错误映射。 + * 不依赖 Electron / Worker,便于单测验证上下文注入(尤其是 semanticIndexService)。 + */ + +import { stripAvatarFields } from '@openchatlab/core' +import { AGENT_TOOL_REGISTRY } from '@openchatlab/tools' +import type { ToolDataProvider, ToolExecutionContext, SemanticSearchToolService } from '@openchatlab/tools' +import type { AiToolExecuteRequest, AiToolExecuteResult } from '@openchatlab/http-routes' + +const MAX_RESULT_CHARS = 500_000 + +/** 工具执行依赖:由平台注入。注入 semanticIndexService 后,手动执行也能命中语义检索工具。 */ +export interface AiToolExecutionDeps { + dataProvider?: ToolDataProvider + semanticIndexService?: SemanticSearchToolService + segmentText?: ToolExecutionContext['segmentText'] + translateTemplate?: ToolExecutionContext['translateTemplate'] +} + +function assertNotAborted(signal: AbortSignal): void { + if (signal.aborted) { + throw new Error('cancelled') + } +} + +/** 组装工具执行上下文:sessionId/abortSignal 来自请求,其余能力由平台注入。 */ +export function buildToolExecutionContext( + params: AiToolExecuteRequest, + deps: AiToolExecutionDeps +): ToolExecutionContext { + return { + sessionId: params.sessionId, + abortSignal: params.abortSignal, + dataProvider: deps.dataProvider, + semanticIndexService: deps.semanticIndexService, + segmentText: deps.segmentText, + translateTemplate: deps.translateTemplate, + } +} + +/** 在注入的上下文上执行注册表工具,处理取消、超大结果截断与错误映射。 */ +export async function executeRegistryTool( + params: AiToolExecuteRequest, + deps: AiToolExecutionDeps +): Promise { + const { toolName, params: toolParams, abortSignal } = params + const entry = AGENT_TOOL_REGISTRY.find((tool) => tool.name === toolName) + if (!entry) { + return { success: false, error: `Tool not found: ${toolName}` } + } + + try { + assertNotAborted(abortSignal) + const execCtx = buildToolExecutionContext(params, deps) + + const startTime = Date.now() + const result = await entry.handler(toolParams, execCtx) + const elapsed = Date.now() - startTime + assertNotAborted(abortSignal) + + let details = (result.data as Record | undefined) ?? undefined + let truncated = false + + if (details) { + stripAvatarFields(details) + const raw = JSON.stringify(details) + if (raw.length > MAX_RESULT_CHARS) { + truncated = true + details = { _truncated: true, _originalSize: raw.length, _preview: raw.slice(0, MAX_RESULT_CHARS) } + } + } + + return { + success: true, + elapsed, + content: [{ type: 'text', text: result.content }], + details, + truncated, + } + } catch (error) { + if (abortSignal.aborted) { + return { success: false, error: 'cancelled' } + } + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/apps/desktop/main/ai/tools/tool-filter.test.ts b/apps/desktop/main/ai/tools/tool-filter.test.ts new file mode 100644 index 000000000..430bc9e60 --- /dev/null +++ b/apps/desktop/main/ai/tools/tool-filter.test.ts @@ -0,0 +1,20 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { isAnalysisToolAllowed } from './tool-filter' + +describe('Electron analysis tool filtering', () => { + it('keeps analysis tools opt-in when no allowlist is configured', () => { + assert.equal(isAnalysisToolAllowed('keyword_frequency', undefined), false) + assert.equal(isAnalysisToolAllowed('keyword_frequency', []), false) + }) + + it('allows only explicitly listed analysis tools', () => { + assert.equal(isAnalysisToolAllowed('keyword_frequency', ['keyword_frequency']), true) + assert.equal(isAnalysisToolAllowed('execute_sql', ['keyword_frequency']), false) + }) + + it('accepts legacy session tool names in assistant allowlists', () => { + assert.equal(isAnalysisToolAllowed('get_segment_summaries', ['get_session_summaries']), true) + }) +}) diff --git a/apps/desktop/main/ai/tools/tool-filter.ts b/apps/desktop/main/ai/tools/tool-filter.ts new file mode 100644 index 000000000..edec5af74 --- /dev/null +++ b/apps/desktop/main/ai/tools/tool-filter.ts @@ -0,0 +1,5 @@ +import { normalizeBuiltinToolNames } from '@openchatlab/core' + +export function isAnalysisToolAllowed(toolName: string, allowedTools?: readonly string[] | null): boolean { + return !!allowedTools && normalizeBuiltinToolNames(allowedTools).includes(toolName) +} diff --git a/apps/desktop/main/ai/tools/types.ts b/apps/desktop/main/ai/tools/types.ts new file mode 100644 index 000000000..d8dd32041 --- /dev/null +++ b/apps/desktop/main/ai/tools/types.ts @@ -0,0 +1,75 @@ +/** + * AI Tools 类型定义 + */ + +import type { AgentTool } from '@openchatlab/node-runtime' +import type { PreprocessConfig } from '@openchatlab/node-runtime' +import type { DataSnapshot } from '@openchatlab/node-runtime' +import type { SemanticSearchToolService } from '@openchatlab/tools' + +export type ToolCategory = 'core' | 'analysis' + +export type ToolFactory = (context: ToolContext) => AgentTool + +export type TruncationStrategy = 'keep_first' | 'keep_last' + +export interface ToolRegistryEntry { + name: string + factory: ToolFactory + category: ToolCategory + /** 截断策略:keep_first=保留前N条(搜索类), keep_last=保留后N条(时序类) */ + truncationStrategy?: TruncationStrategy +} + +/** Owner 信息(当前用户在对话中的身份) */ +export interface OwnerInfo { + /** Owner 的 platformId */ + platformId: string + /** Owner 的显示名称 */ + displayName: string +} + +/** + * 工具执行上下文 + * 包含执行工具时需要的所有上下文信息 + */ +export interface ToolContext { + /** 当前会话 ID(数据库文件名) */ + sessionId: string + /** 当前 AI 对话 ID(用于上下文管理隔离) */ + aiChatId?: string + /** 读取 Agent 历史时使用的叶子消息;编辑重答时指向被编辑消息的父节点 */ + historyLeafMessageId?: string | null + /** 当前聊天数据库快照(仅用于提示模型当前数据范围,不能替代工具检索结果) */ + dataSnapshot?: DataSnapshot + /** 时间过滤器 */ + timeFilter?: { + startTs: number + endTs: number + } + /** 用户配置的消息条数限制(工具获取消息时使用) */ + maxMessagesLimit?: number + /** Owner 信息(当前用户在对话中的身份) */ + ownerInfo?: OwnerInfo + /** 本轮显式 @ 的成员 */ + mentionedMembers?: Array<{ + memberId: number + platformId: string + displayName: string + aliases: string[] + mentionText: string + }> + /** 语言环境(用于工具返回结果的国际化) */ + locale?: string + /** 聊天记录预处理配置(全局) */ + preprocessConfig?: PreprocessConfig + /** 语义检索窄接口(仅当前会话可检索时由 runner 注入) */ + semanticIndexService?: SemanticSearchToolService + abortSignal?: AbortSignal + /** 搜索结果上下文:向前取多少条(默认 3) */ + searchContextBefore?: number + /** 搜索结果上下文:向后取多少条(默认 3) */ + searchContextAfter?: number + /** 单次工具返回的最大 token 数(基于 context window 动态计算) */ + maxToolResultTokens?: number +} diff --git a/apps/desktop/main/ai/tools/worker-data-provider.ts b/apps/desktop/main/ai/tools/worker-data-provider.ts new file mode 100644 index 000000000..aacf83d29 --- /dev/null +++ b/apps/desktop/main/ai/tools/worker-data-provider.ts @@ -0,0 +1,204 @@ +/** + * WorkerDataProvider + * + * 基于 workerManager 的 ToolDataProvider 实现。 + * 通过 Worker IPC 异步访问 SQLite,供 Electron Agent 使用。 + */ + +import * as workerManager from '../../worker/workerManager' +import type { + ToolDataProvider, + SearchMessagesResult, + MemberStatItem, + SchemaTableInfo, + ToolTimeRange, + ChatOverviewResult, + MemberInfo, + NameHistoryItem, + SegmentMessagesResult, + ConversationResult, + SegmentSummaryItem, + RawMessage, +} from '@openchatlab/tools' + +function mapSearchMessages(messages: workerManager.SearchMessageResult[]): RawMessage[] { + return messages.map((m) => ({ + id: m.id, + senderName: m.senderName, + senderPlatformId: m.senderPlatformId, + content: m.content, + timestamp: m.timestamp, + })) +} + +export class WorkerDataProvider implements ToolDataProvider { + constructor( + private sessionId: string, + private abortSignal?: AbortSignal + ) {} + + private throwIfAborted(): void { + if (this.abortSignal?.aborted) { + throw new Error('cancelled') + } + } + + private async run(operation: () => Promise): Promise { + this.throwIfAborted() + const result = await operation() + this.throwIfAborted() + return result + } + + async searchMessages( + keywords: string[], + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } + ): Promise { + const result = await this.run(() => + workerManager.searchMessages( + this.sessionId, + keywords, + options?.timeFilter, + options?.limit ?? 50, + 0, + options?.senderId + ) + ) + return { messages: mapSearchMessages(result.messages), total: result.total } + } + + async deepSearchMessages( + keywords: string[], + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } + ): Promise { + const result = await this.run(() => + workerManager.deepSearchMessages( + this.sessionId, + keywords, + options?.timeFilter, + options?.limit ?? 50, + 0, + options?.senderId + ) + ) + return { messages: mapSearchMessages(result.messages), total: result.total } + } + + async getSearchMessageContext( + messageIds: number[], + contextBefore: number, + contextAfter: number + ): Promise { + const messages = await this.run(() => + workerManager.getSearchMessageContext(this.sessionId, messageIds, contextBefore, contextAfter) + ) + return mapSearchMessages(messages) + } + + async getRecentMessages(options?: { timeFilter?: ToolTimeRange; limit?: number }): Promise { + const result = await this.run(() => + workerManager.getRecentMessages(this.sessionId, options?.timeFilter, options?.limit ?? 50) + ) + return { messages: mapSearchMessages(result.messages), total: result.total } + } + + async getMessageContext(messageIds: number[], contextSize: number): Promise { + const messages = await this.run(() => workerManager.getMessageContext(this.sessionId, messageIds, contextSize)) + return mapSearchMessages(messages) + } + + async getChatOverview(topN?: number): Promise { + return this.run(() => workerManager.getChatOverview(this.sessionId, topN)) + } + + async getMembers(): Promise { + const members = await this.run(() => workerManager.getMembers(this.sessionId)) + return members.map((m) => ({ + id: m.id, + platformId: m.platformId, + accountName: m.accountName, + groupNickname: m.groupNickname, + aliases: m.aliases, + messageCount: m.messageCount, + })) + } + + async getMemberStats(options?: { timeFilter?: ToolTimeRange; top?: number }): Promise { + const top = options?.top ?? 20 + const members = await this.run(() => workerManager.getMemberActivity(this.sessionId, options?.timeFilter)) + return members.slice(0, top).map((m: any) => ({ + name: m.name, + messageCount: m.messageCount, + percentage: m.percentage, + })) + } + + async getMemberNameHistory(memberId: number): Promise { + return this.run(() => workerManager.getMemberNameHistory(this.sessionId, memberId)) + } + + async getTimeStats( + type: 'hourly' | 'weekday' | 'daily', + options?: { timeFilter?: ToolTimeRange } + ): Promise { + const filter = options?.timeFilter + switch (type) { + case 'weekday': + return this.run(() => workerManager.getWeekdayActivity(this.sessionId, filter)) + case 'daily': + return this.run(() => workerManager.getDailyActivity(this.sessionId, filter)) + case 'hourly': + default: + return this.run(() => workerManager.getHourlyActivity(this.sessionId, filter)) + } + } + + async getSegmentMessages(segmentId: number, limit?: number): Promise { + return this.run(() => workerManager.getSegmentMessages(this.sessionId, segmentId, limit)) + } + + async getSegmentSummaries(options?: { limit?: number; timeFilter?: ToolTimeRange }): Promise { + return this.run(() => + workerManager.getSegmentSummaries(this.sessionId, { + limit: options?.limit, + timeFilter: options?.timeFilter, + }) + ) + } + + async getConversationBetween( + memberId1: number, + memberId2: number, + timeFilter?: ToolTimeRange, + limit?: number + ): Promise { + const result = await this.run(() => + workerManager.getConversationBetween(this.sessionId, memberId1, memberId2, timeFilter, limit) + ) + return { + messages: mapSearchMessages(result.messages), + total: result.total, + member1Name: result.member1Name, + member2Name: result.member2Name, + } + } + + async executeSql(sql: string): Promise { + return this.run(() => workerManager.executeRawSQL(this.sessionId, sql)) + } + + async executeParameterizedSql>( + query: string, + params: Record + ): Promise { + return this.run(() => workerManager.pluginQuery(this.sessionId, query, params)) + } + + async getSchema(): Promise { + const tables = await this.run(() => workerManager.getSchema(this.sessionId)) + return tables.map((t) => ({ + name: t.name, + sql: t.columns.map((c) => `${c.name} ${c.type}${c.pk ? ' PK' : ''}${c.notnull ? ' NOT NULL' : ''}`).join(', '), + })) + } +} diff --git a/apps/desktop/main/analytics.ts b/apps/desktop/main/analytics.ts new file mode 100644 index 000000000..89f40cff9 --- /dev/null +++ b/apps/desktop/main/analytics.ts @@ -0,0 +1,45 @@ +import { app, ipcMain } from 'electron' +import { AnalyticsService } from '@openchatlab/node-runtime' +import { getSystemDataDir } from './paths' + +const APTABASE_APP_KEY = process.env.APTABASE_APP_KEY + +let _service: AnalyticsService | null = null + +function getService(): AnalyticsService | null { + if (!APTABASE_APP_KEY) return null + if (!_service) { + const systemDir = getSystemDataDir() + _service = new AnalyticsService(systemDir, APTABASE_APP_KEY, app.getVersion()) + } + return _service +} + +export function initAnalytics(): void { + // Service is initialized lazily; no-op here unless we want eager validation. + if (!APTABASE_APP_KEY) return + getService() +} + +export function registerAnalyticsHandlers(): void { + ipcMain.handle('analytics:getEnabled', () => { + return getService()?.getEnabled() ?? true + }) + + ipcMain.handle('analytics:setEnabled', (_, enabled: boolean) => { + getService()?.setEnabled(enabled) + return { success: true } + }) + + ipcMain.handle('analytics:trackDailyActive', (_, locale: string) => { + getService() + ?.trackDailyActive({ platform: 'desktop', locale }) + .catch((e) => console.error('[Analytics] Failed to report daily active:', e)) + }) +} + +export function trackAppEvent(eventName: string, properties?: Record): void { + getService() + ?.track(eventName, properties) + .catch((e) => console.error(`[Analytics] Failed to report event ${eventName}:`, e)) +} diff --git a/apps/desktop/main/api/adapters.ts b/apps/desktop/main/api/adapters.ts new file mode 100644 index 000000000..c7f4faf33 --- /dev/null +++ b/apps/desktop/main/api/adapters.ts @@ -0,0 +1,177 @@ +/** + * Electron-specific implementations of @openchatlab/sync abstractions. + * + * ElectronFetcher: uses electron.net.request + * WorkerImporter: uses worker thread IPC (streamImport / incrementalImport) + * BrowserWindowNotifier: uses BrowserWindow.webContents.send + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import Database from 'better-sqlite3' +import { net, BrowserWindow, app } from 'electron' +import { getTempDir } from '../paths' +import * as worker from '../worker/workerManager' +import { getPathProvider } from '../path-context' +import { assertDesktopDataDirCompatible, getDesktopAppVersion } from '../runtime-compat' +import type { HttpFetcher, DataImporter, SyncNotifier, ImportResult, FetchParams, SyncLogger } from '@openchatlab/sync' +import { buildPullUrl, NOOP_LOGGER } from '@openchatlab/sync' + +function getTempFilePath(ext: string): string { + const id = crypto.randomBytes(8).toString('hex') + return path.join(getTempDir(), `pull-import-${id}${ext}`) +} + +// ==================== ElectronFetcher ==================== + +export class ElectronFetcher implements HttpFetcher { + fetchToTempFile(baseUrl: string, remoteSessionId: string, token: string, params: FetchParams): Promise { + return new Promise((resolve, reject) => { + const url = buildPullUrl(baseUrl, remoteSessionId, params) + const request = net.request(url) + + if (token) request.setHeader('Authorization', `Bearer ${token}`) + request.setHeader('Accept', 'application/json') + + let tempFile = '' + let writeStream: fs.WriteStream | null = null + + request.on('response', (response) => { + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}`)) + return + } + + const contentType = (response.headers['content-type'] as string) || 'application/json' + const isJsonl = contentType.includes('ndjson') || contentType.includes('jsonl') + tempFile = getTempFilePath(isJsonl ? '.jsonl' : '.json') + writeStream = fs.createWriteStream(tempFile) + + response.on('data', (chunk: Buffer) => writeStream!.write(chunk)) + response.on('end', () => writeStream!.end(() => resolve(tempFile))) + response.on('error', (err: Error) => { + writeStream?.end() + cleanupTempFile(tempFile) + reject(err) + }) + }) + + request.on('error', (err: Error) => { + if (writeStream) writeStream.end() + if (tempFile) cleanupTempFile(tempFile) + reject(err) + }) + + request.end() + }) + } +} + +function cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + } catch { + /* ignore */ + } +} + +// ==================== WorkerImporter ==================== + +export class WorkerImporter implements DataImporter { + private logger: SyncLogger + + constructor(logger?: SyncLogger) { + this.logger = logger ?? NOOP_LOGGER + } + + sessionExists(sessionId: string): boolean { + assertDesktopDataDirCompatible(getPathProvider(), getDesktopAppVersion(app.getVersion())) + + const dbPath = path.join(worker.getDbDirectory(), `${sessionId}.db`) + if (!fs.existsSync(dbPath)) return false + try { + const db = new Database(dbPath, { readonly: true }) + const row = db + .prepare("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name='message'") + .get() as { cnt: number } + db.close() + if (row.cnt === 0) { + this.logger.warn(`[Pull] DB file exists but has no message table: ${sessionId}, removing`) + try { + fs.unlinkSync(dbPath) + } catch { + /* ignore */ + } + return false + } + return true + } catch { + this.logger.warn(`[Pull] Cannot validate DB file: ${sessionId}, removing`) + try { + fs.unlinkSync(dbPath) + } catch { + /* ignore */ + } + return false + } + } + + async importFile(tempFile: string, targetSessionId: string | undefined, externalId: string): Promise { + if (targetSessionId && this.sessionExists(targetSessionId)) { + return this.incrementalImportFile(targetSessionId, tempFile) + } + return this.fullImportFile(tempFile, externalId) + } + + private async incrementalImportFile(sessionId: string, tempFile: string): Promise { + this.logger.info(`[Pull] Incremental import to session ${sessionId}`) + const result = await worker.incrementalImport(sessionId, tempFile) + if (result.success) { + this.logger.info(`[Pull] Incremental OK: +${result.newMessageCount} messages`) + return { success: true, newMessageCount: result.newMessageCount, sessionId } + } + if (result.error === 'error.session_not_found' || result.error?.includes('no such table')) { + this.logger.warn(`[Pull] Session ${sessionId} not found or schema invalid, need full resync`) + return { success: false, newMessageCount: 0, sessionId, needFullResync: true } + } + this.logger.error(`[Pull] Incremental import failed: ${result.error}`) + return { success: false, newMessageCount: 0, sessionId, error: result.error } + } + + private async fullImportFile(tempFile: string, externalId: string): Promise { + this.logger.info(`[Pull] First import via streamImport (externalId=${externalId})`) + const result = await worker.streamImport(tempFile, undefined, undefined, externalId) + if (result.success) { + const msgCount = result.diagnostics?.messagesWritten ?? 0 + this.logger.info(`[Pull] streamImport OK: session=${result.sessionId}, messages=${msgCount}`) + return { success: true, newMessageCount: msgCount, sessionId: result.sessionId } + } + this.logger.error(`[Pull] streamImport failed: ${result.error}`) + return { success: false, newMessageCount: 0, error: result.error } + } +} + +// ==================== BrowserWindowNotifier ==================== + +export class BrowserWindowNotifier implements SyncNotifier { + onSessionListChanged(): void { + try { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('api:importCompleted') + } + } catch { + /* ignore */ + } + } + + onPullResult(sourceId: string, sessionId: string | undefined, status: 'success' | 'error', detail: string): void { + try { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('api:pullResult', { sourceId, sessionId, status, detail }) + } + } catch { + /* ignore */ + } + } +} diff --git a/apps/desktop/main/api/auth.ts b/apps/desktop/main/api/auth.ts new file mode 100644 index 000000000..a7ba7f951 --- /dev/null +++ b/apps/desktop/main/api/auth.ts @@ -0,0 +1,33 @@ +/** + * ChatLab API — Bearer Token authentication hook + */ + +import type { FastifyRequest, FastifyReply } from 'fastify' +import { timingSafeEqual } from 'crypto' +import { getConfig } from './index' +import { unauthorized, errorResponse } from './errors' + +function safeTokenCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a) + const bufB = Buffer.from(b) + if (bufA.length !== bufB.length) return false + return timingSafeEqual(bufA, bufB) +} + +export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise { + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + const err = unauthorized() + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + const token = authHeader.slice(7) + const config = getConfig() + + if (!config.token || !safeTokenCompare(token, config.token)) { + const err = unauthorized() + reply.code(err.statusCode).send(errorResponse(err)) + return + } +} diff --git a/apps/desktop/main/api/errors.test.ts b/apps/desktop/main/api/errors.test.ts new file mode 100644 index 000000000..3b33e5f90 --- /dev/null +++ b/apps/desktop/main/api/errors.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat' +import { ApiErrorCode, apiErrorFromUnknown } from './errors' + +test('apiErrorFromUnknown maps wrapped data directory compatibility errors to 409', () => { + const cause = new DataDirCompatibilityError( + 'DATA_DIR_REQUIRES_NEWER_RUNTIME', + 'ChatLab data directory requires runtime version 0.25.1 or newer; current version is 0.25.0.', + { + userDataDir: '/tmp/chatlab-data', + metaPath: '/tmp/chatlab-data/.chatlab-meta.json', + currentVersion: '0.25.0', + minRuntimeVersion: '0.25.1', + } + ) + + const apiError = apiErrorFromUnknown(new Error('Desktop startup formatted message', { cause })) + + assert.equal(apiError?.code, ApiErrorCode.DATA_DIR_INCOMPATIBLE) + assert.equal(apiError?.statusCode, 409) + assert.match(apiError?.message ?? '', /requires runtime version 0\.25\.1/) +}) diff --git a/apps/desktop/main/api/errors.ts b/apps/desktop/main/api/errors.ts new file mode 100644 index 000000000..34f6b530c --- /dev/null +++ b/apps/desktop/main/api/errors.ts @@ -0,0 +1,137 @@ +/** + * ChatLab API — Error codes and factory functions + */ + +import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat' + +export enum ApiErrorCode { + UNAUTHORIZED = 'UNAUTHORIZED', + SESSION_NOT_FOUND = 'SESSION_NOT_FOUND', + INVALID_FORMAT = 'INVALID_FORMAT', + INVALID_PAYLOAD = 'INVALID_PAYLOAD', + SQL_READONLY_VIOLATION = 'SQL_READONLY_VIOLATION', + SQL_EXECUTION_ERROR = 'SQL_EXECUTION_ERROR', + EXPORT_TOO_LARGE = 'EXPORT_TOO_LARGE', + BODY_TOO_LARGE = 'BODY_TOO_LARGE', + IMPORT_IN_PROGRESS = 'IMPORT_IN_PROGRESS', + IDEMPOTENCY_CONFLICT = 'IDEMPOTENCY_CONFLICT', + IDEMPOTENCY_PENDING = 'IDEMPOTENCY_PENDING', + IMPORT_FAILED = 'IMPORT_FAILED', + DATA_DIR_INCOMPATIBLE = 'DATA_DIR_INCOMPATIBLE', + SERVER_ERROR = 'SERVER_ERROR', +} + +const HTTP_STATUS: Record = { + [ApiErrorCode.UNAUTHORIZED]: 401, + [ApiErrorCode.SESSION_NOT_FOUND]: 404, + [ApiErrorCode.INVALID_FORMAT]: 400, + [ApiErrorCode.INVALID_PAYLOAD]: 400, + [ApiErrorCode.SQL_READONLY_VIOLATION]: 400, + [ApiErrorCode.SQL_EXECUTION_ERROR]: 400, + [ApiErrorCode.EXPORT_TOO_LARGE]: 400, + [ApiErrorCode.BODY_TOO_LARGE]: 413, + [ApiErrorCode.IMPORT_IN_PROGRESS]: 409, + [ApiErrorCode.IDEMPOTENCY_CONFLICT]: 409, + [ApiErrorCode.IDEMPOTENCY_PENDING]: 409, + [ApiErrorCode.IMPORT_FAILED]: 500, + [ApiErrorCode.DATA_DIR_INCOMPATIBLE]: 409, + [ApiErrorCode.SERVER_ERROR]: 500, +} + +export class ApiError extends Error { + code: ApiErrorCode + statusCode: number + + constructor(code: ApiErrorCode, message: string) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = HTTP_STATUS[code] + } +} + +export function unauthorized(message = 'Invalid or missing token'): ApiError { + return new ApiError(ApiErrorCode.UNAUTHORIZED, message) +} + +export function sessionNotFound(id: string): ApiError { + return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${id}`) +} + +export function invalidFormat(message: string): ApiError { + return new ApiError(ApiErrorCode.INVALID_FORMAT, message) +} + +export function invalidPayload(message: string): ApiError { + return new ApiError(ApiErrorCode.INVALID_PAYLOAD, message) +} + +export function idempotencyConflict(): ApiError { + return new ApiError(ApiErrorCode.IDEMPOTENCY_CONFLICT, 'Same Idempotency-Key with different request body hash') +} + +export function sqlReadonlyViolation(): ApiError { + return new ApiError(ApiErrorCode.SQL_READONLY_VIOLATION, 'Only SELECT queries are allowed') +} + +export function sqlExecutionError(message: string): ApiError { + return new ApiError(ApiErrorCode.SQL_EXECUTION_ERROR, message) +} + +export function exportTooLarge(count: number, limit: number): ApiError { + return new ApiError( + ApiErrorCode.EXPORT_TOO_LARGE, + `Message count ${count} exceeds export limit ${limit}. Use paginated /messages API instead.` + ) +} + +export function importInProgress(): ApiError { + return new ApiError(ApiErrorCode.IMPORT_IN_PROGRESS, 'An import operation is already in progress') +} + +export function importFailed(message: string): ApiError { + return new ApiError(ApiErrorCode.IMPORT_FAILED, message) +} + +export function dataDirIncompatible(message: string): ApiError { + return new ApiError(ApiErrorCode.DATA_DIR_INCOMPATIBLE, message) +} + +export function serverError(message = 'Internal server error'): ApiError { + return new ApiError(ApiErrorCode.SERVER_ERROR, message) +} + +export function apiErrorFromUnknown(error: unknown): ApiError | null { + if (error instanceof ApiError) return error + const compatibilityError = findDataDirCompatibilityError(error) + if (compatibilityError) return dataDirIncompatible(compatibilityError.message) + return null +} + +function findDataDirCompatibilityError(error: unknown): DataDirCompatibilityError | null { + if (error instanceof DataDirCompatibilityError) return error + if (error instanceof Error && error.cause) return findDataDirCompatibilityError(error.cause) + return null +} + +export function successResponse(data: T, meta?: Record) { + return { + success: true as const, + data, + meta: { + timestamp: Math.floor(Date.now() / 1000), + version: '0.0.2', + ...meta, + }, + } +} + +export function errorResponse(error: ApiError) { + return { + success: false as const, + error: { + code: error.code, + message: error.message, + }, + } +} diff --git a/apps/desktop/main/api/index.ts b/apps/desktop/main/api/index.ts new file mode 100644 index 000000000..219b95819 --- /dev/null +++ b/apps/desktop/main/api/index.ts @@ -0,0 +1,182 @@ +/** + * ChatLab API — Server manager + * Manages fastify server lifecycle + * + * Uses ConfigManager from @openchatlab/sync (via ipc/api.ts shared instance). + */ + +import type { FastifyInstance } from 'fastify' +import { createServer } from './server' +import { registerSystemRoutes } from './routes/system' +import { registerSessionRoutes } from './routes/sessions' +import { registerImportRoutes } from './routes/import' +import { apiLogger } from './logger' +import type { ConfigManager, ApiServerConfig } from '@openchatlab/sync' + +let server: FastifyInstance | null = null +let startedAt: number | null = null +let lastError: string | null = null +let runningPort: number | null = null +let _configManager: ConfigManager | null = null + +/** Must be called before start/autoStart/setEnabled/setPort */ +export function setConfigManager(cm: ConfigManager): void { + _configManager = cm +} + +function cm(): ConfigManager { + if (!_configManager) throw new Error('[ApiServer] ConfigManager not initialized. Call setConfigManager() first.') + return _configManager +} + +export interface ApiServerStatus { + running: boolean + port: number | null + startedAt: number | null + error: string | null +} + +export function getStatus(): ApiServerStatus { + return { + running: server !== null && startedAt !== null, + port: server !== null && startedAt !== null ? runningPort : null, + startedAt, + error: lastError, + } +} + +const PORT_FALLBACK_LIMIT = 10 + +export async function start(): Promise { + if (server) { + apiLogger.info('Server already running') + return + } + + const config = cm().load() + cm().ensureToken(config) + lastError = null + + const preferredPort = config.port + let lastErr: any + + for (let offset = 0; offset < PORT_FALLBACK_LIMIT; offset++) { + const port = preferredPort + offset + try { + server = createServer() + registerSystemRoutes(server) + registerSessionRoutes(server) + registerImportRoutes(server) + + await server.listen({ port, host: '127.0.0.1' }) + startedAt = Math.floor(Date.now() / 1000) + runningPort = port + if (offset > 0) { + apiLogger.info(`Port ${preferredPort} in use, bound to http://127.0.0.1:${port} instead`) + } else { + apiLogger.info(`Server started on http://127.0.0.1:${port}`) + } + return + } catch (err: any) { + server = null + lastErr = err + if (err.code !== 'EADDRINUSE') break + apiLogger.warn(`Port ${port} is already in use, trying ${port + 1}…`) + } + } + + startedAt = null + runningPort = null + if (lastErr?.code === 'EADDRINUSE') { + lastError = `PORT_IN_USE:${preferredPort}` + apiLogger.warn(`No available port found starting from ${preferredPort}`) + } else { + lastError = lastErr?.message || 'Unknown error' + apiLogger.error('Failed to start', lastErr) + } + throw lastErr +} + +export async function stop(): Promise { + if (!server) return + + try { + await server.close() + } catch (err) { + apiLogger.error('Error closing server', err) + } finally { + server = null + startedAt = null + runningPort = null + lastError = null + apiLogger.info('Server stopped') + } +} + +export async function restart(): Promise { + await stop() + await start() +} + +/** + * Auto-restore on app startup: attempt to start if config.enabled is true. + * Failures are silently recorded (does not affect normal app usage). + */ +export async function autoStart(): Promise { + const config = cm().load() + if (!config.enabled) return + + try { + await start() + } catch { + // silent failure, lastError already recorded + } +} + +/** + * Set enabled state (persisted) + */ +export async function setEnabled(enabled: boolean): Promise { + const config = cm().load() + config.enabled = enabled + cm().save(config) + + if (enabled) { + cm().ensureToken(config) + try { + await start() + } catch { + // lastError already recorded + } + } else { + await stop() + } + + return getStatus() +} + +/** + * Set port (persisted, requires server restart) + */ +export async function setPort(port: number): Promise { + const config = cm().load() + const wasRunning = server !== null + + config.port = port + cm().save(config) + + if (wasRunning) { + await stop() + try { + await start() + } catch { + // lastError already recorded + } + } + + return getStatus() +} + +export function getConfig(): ApiServerConfig { + return cm().load() +} diff --git a/apps/desktop/main/api/logger.ts b/apps/desktop/main/api/logger.ts new file mode 100644 index 000000000..adeaeaa7d --- /dev/null +++ b/apps/desktop/main/api/logger.ts @@ -0,0 +1,32 @@ +/** + * ChatLab API logger (Electron main). + * + * Delegates to the shared appLogger with scope 'api', written into the unified + * logs/app.log. Exported shape kept for existing call sites. + */ + +import { initAppLogger, appLogger } from '@openchatlab/node-runtime' +import { getLogsDir } from '../paths' + +let initialized = false + +function ensureInit(): void { + if (initialized) return + initialized = true + initAppLogger(getLogsDir()) +} + +export const apiLogger = { + info: (msg: string, detail?: unknown) => { + ensureInit() + appLogger.info('api', msg, detail) + }, + warn: (msg: string, detail?: unknown) => { + ensureInit() + appLogger.warn('api', msg, detail) + }, + error: (msg: string, detail?: unknown) => { + ensureInit() + appLogger.error('api', msg, detail) + }, +} diff --git a/apps/desktop/main/api/routes/import.ts b/apps/desktop/main/api/routes/import.ts new file mode 100644 index 000000000..7cfe55a4c --- /dev/null +++ b/apps/desktop/main/api/routes/import.ts @@ -0,0 +1,467 @@ +/** + * ChatLab API — Import routes (Push mode) + * + * POST /api/v1/imports/:sessionId Unified import endpoint (auto-create or incremental) + * + * Legacy (deprecated, kept for backward compatibility): + * POST /api/v1/import Import to new session (auto-generated sessionId) + * POST /api/v1/sessions/:id/import Incremental import to existing session + * + * Content-Type dispatch: + * application/json → parse body → temp .json → chatlab parser + * application/x-ndjson → pipe raw stream → temp .jsonl → chatlab-jsonl parser + */ + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import { BrowserWindow } from 'electron' +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import { pipeline } from 'stream/promises' +import { getTempDir } from '../../paths' +import * as worker from '../../worker/workerManager' +import { + ApiError, + ApiErrorCode, + successResponse, + apiErrorFromUnknown, + importInProgress, + importFailed, + invalidFormat, + invalidPayload, + idempotencyConflict, + errorResponse, +} from '../errors' +import { apiLogger } from '../logger' + +// Per-session lock: different sessionIds can import in parallel. +const isImporting = new Set() + +// ==================== Idempotency cache ==================== + +interface IdempotencyCacheEntry { + bodyHash: string + status: 'pending' | 'success' + response: any + timestamp: number +} + +const IDEMPOTENCY_TTL_MS = 60 * 60 * 1000 // 1 hour +const IDEMPOTENCY_CLEANUP_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes +const idempotencyCache = new Map() + +setInterval(() => { + const now = Date.now() + for (const [key, entry] of idempotencyCache) { + if (now - entry.timestamp > IDEMPOTENCY_TTL_MS) { + idempotencyCache.delete(key) + } + } +}, IDEMPOTENCY_CLEANUP_INTERVAL_MS) + +function computeBodyHash(body: unknown, tempFile?: string): string { + if (tempFile && fs.existsSync(tempFile)) { + const content = fs.readFileSync(tempFile) + return crypto.createHash('sha256').update(content).digest('hex') + } + return crypto + .createHash('sha256') + .update(JSON.stringify(body ?? '')) + .digest('hex') +} + +function getTempFilePath(ext: string): string { + const id = crypto.randomBytes(8).toString('hex') + return path.join(getTempDir(), `api-import-${id}${ext}`) +} + +function cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } + } catch (err) { + apiLogger.error('Failed to cleanup temp file', err) + } +} + +function notifySessionListChanged(): void { + try { + const wins = BrowserWindow.getAllWindows() + for (const win of wins) { + win.webContents.send('api:importCompleted') + } + } catch { + // ignore + } +} + +function idempotencySuccess(key: string | undefined, response: any): void { + if (!key) return + const entry = idempotencyCache.get(key) + if (entry) { + entry.status = 'success' + entry.response = response + } +} + +function idempotencyFail(key: string | undefined): void { + if (!key) return + idempotencyCache.delete(key) +} + +export function getImportingStatus(sessionId?: string): boolean { + if (sessionId) { + return isImporting.has(sessionId) || isImporting.has('__legacy__') + } + return isImporting.size > 0 +} + +/** + * 检查 session 是否已存在(快速文件检测) + */ +function sessionExists(sessionId: string): boolean { + try { + const dbDir = worker.getDbDirectory() + return fs.existsSync(path.join(dbDir, `${sessionId}.db`)) + } catch { + return false + } +} + +/** + * 将请求 body 写入临时文件,返回文件路径和解析后的 content type 信息 + */ +async function writeTempFile( + request: FastifyRequest, + isJson: boolean +): Promise<{ tempFile: string; error?: never } | { tempFile?: never; error: string }> { + if (isJson) { + const body = request.body + if (!body || typeof body !== 'object') { + return { error: 'Request body is not valid JSON' } + } + const tempFile = getTempFilePath('.json') + fs.writeFileSync(tempFile, JSON.stringify(body), 'utf-8') + return { tempFile } + } else { + const tempFile = getTempFilePath('.jsonl') + const writeStream = fs.createWriteStream(tempFile) + await pipeline(request.raw, writeStream) + return { tempFile } + } +} + +/** + * v3 统一导入处理:自动判断新建或增量 + */ +async function handleUnifiedImport(request: FastifyRequest, reply: FastifyReply, sessionId: string): Promise { + if (isImporting.has(sessionId)) { + const err = importInProgress() + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + const contentType = (request.headers['content-type'] || '').toLowerCase() + const isJsonl = contentType.includes('application/x-ndjson') + const isJson = contentType.includes('application/json') + + if (!isJsonl && !isJson) { + const err = invalidFormat('Content-Type must be application/json or application/x-ndjson') + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + const idempotencyKey = request.headers['idempotency-key'] as string | undefined + const isDryRun = (request.headers['x-dry-run'] as string)?.toLowerCase() === 'true' + + const cacheKey = idempotencyKey ? `${idempotencyKey}:${sessionId}:${isDryRun}` : undefined + + isImporting.add(sessionId) + let tempFile = '' + + try { + const writeResult = await writeTempFile(request, isJson) + if (writeResult.error) { + const err = invalidFormat(writeResult.error) + reply.code(err.statusCode).send(errorResponse(err)) + return + } + tempFile = writeResult.tempFile! + + // Idempotency-Key check (after tempFile is written so we can hash JSONL content) + if (cacheKey) { + const bodyHash = isJsonl ? computeBodyHash(null, tempFile) : computeBodyHash(request.body) + const cached = idempotencyCache.get(cacheKey) + if (cached) { + if (cached.bodyHash !== bodyHash) { + const err = idempotencyConflict() + reply.code(err.statusCode).send(errorResponse(err)) + return + } + if (cached.status === 'pending') { + const pendingErr = new ApiError( + ApiErrorCode.IDEMPOTENCY_PENDING, + 'A request with this Idempotency-Key is still in progress. Please retry later.' + ) + reply.code(pendingErr.statusCode).send(errorResponse(pendingErr)) + return + } + reply.send(cached.response) + return + } + idempotencyCache.set(cacheKey, { bodyHash, status: 'pending', response: null, timestamp: Date.now() }) + } + + const importOptions = + isJson && request.body && typeof request.body === 'object' ? (request.body as any).options : undefined + + const exists = sessionExists(sessionId) + + // X-Dry-Run: analyze only, no writes + if (isDryRun) { + let responsePayload: any + if (exists) { + const result = await worker.analyzeIncrementalImport(sessionId, tempFile) + if (result.error) { + idempotencyFail(cacheKey) + const err = invalidFormat(result.error) + reply.code(err.statusCode).send(errorResponse(err)) + return + } + responsePayload = successResponse({ + sessionId, + created: false, + dryRun: true, + analysis: { + totalInFile: result.totalInFile, + newMessageCount: result.newMessageCount, + duplicateCount: result.duplicateCount, + }, + }) + } else { + const result = await worker.analyzeNewImport(tempFile) + if (result.error) { + idempotencyFail(cacheKey) + const err = invalidFormat(result.error) + reply.code(err.statusCode).send(errorResponse(err)) + return + } + responsePayload = successResponse({ + sessionId, + created: true, + dryRun: true, + analysis: { + totalInFile: result.totalMessages, + newMessageCount: result.totalMessages, + duplicateCount: 0, + newMemberCount: result.totalMembers, + }, + }) + } + idempotencySuccess(cacheKey, responsePayload) + reply.send(responsePayload) + return + } + + if (exists) { + const result = await worker.incrementalImport(sessionId, tempFile, undefined, importOptions) + + if (result.success) { + notifySessionListChanged() + const responsePayload = successResponse({ + sessionId, + created: false, + batch: result.batch, + session: result.session, + updates: result.updates, + }) + idempotencySuccess(cacheKey, responsePayload) + reply.send(responsePayload) + } else { + idempotencyFail(cacheKey) + const err = importFailed(result.error || 'Incremental import failed') + reply.code(err.statusCode).send(errorResponse(err)) + } + } else { + const result = await worker.streamImport(tempFile, undefined, undefined, sessionId) + + if (result.success) { + notifySessionListChanged() + + const diag = result.diagnostics + let sessionInfo: any = undefined + try { + const s = await worker.getSession(result.sessionId!) + if (s) { + sessionInfo = { + totalCount: s.totalCount, + memberCount: s.memberCount, + firstTimestamp: s.firstTimestamp, + lastTimestamp: s.lastTimestamp, + } + } + } catch { + // non-blocking + } + + const responsePayload = successResponse({ + sessionId: result.sessionId, + created: true, + batch: diag + ? { + receivedCount: diag.messagesReceived, + writtenCount: diag.messagesWritten, + duplicateCount: diag.messagesSkipped, + } + : undefined, + session: sessionInfo, + }) + idempotencySuccess(cacheKey, responsePayload) + reply.send(responsePayload) + } else { + idempotencyFail(cacheKey) + const err = importFailed(result.error || 'Import failed') + reply.code(err.statusCode).send(errorResponse(err)) + } + } + } catch (error: any) { + idempotencyFail(cacheKey) + apiLogger.error('Import error', error) + const apiError = apiErrorFromUnknown(error) + if (apiError) { + reply.code(apiError.statusCode).send(errorResponse(apiError)) + return + } + const err = importFailed(error.message || 'Import process error') + reply.code(err.statusCode).send(errorResponse(err)) + } finally { + isImporting.delete(sessionId) + if (tempFile) { + cleanupTempFile(tempFile) + } + } +} + +/** + * Legacy import handler (backward compatibility) + */ +async function handleLegacyImport(request: FastifyRequest, reply: FastifyReply, sessionId?: string): Promise { + const lockKey = sessionId ?? '__legacy__' + if (isImporting.has(lockKey)) { + const err = importInProgress() + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + const contentType = (request.headers['content-type'] || '').toLowerCase() + const isJsonl = contentType.includes('application/x-ndjson') + const isJson = contentType.includes('application/json') + + if (!isJsonl && !isJson) { + const err = invalidFormat('Content-Type must be application/json or application/x-ndjson') + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + isImporting.add(lockKey) + let tempFile = '' + + try { + const writeResult = await writeTempFile(request, isJson) + if (writeResult.error) { + const err = invalidFormat(writeResult.error) + reply.code(err.statusCode).send(errorResponse(err)) + return + } + tempFile = writeResult.tempFile! + + if (sessionId) { + const session = await worker.getSession(sessionId) + if (!session) { + const err = new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${sessionId}`) + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + const importOptions = + isJson && request.body && typeof request.body === 'object' ? (request.body as any).options : undefined + + const result = await worker.incrementalImport(sessionId, tempFile, undefined, importOptions) + + if (result.success) { + notifySessionListChanged() + reply.send( + successResponse({ + sessionId, + created: false, + batch: result.batch, + session: result.session, + updates: result.updates, + }) + ) + } else { + const err = importFailed(result.error || 'Incremental import failed') + reply.code(err.statusCode).send(errorResponse(err)) + } + } else { + const result = await worker.streamImport(tempFile) + + if (result.success) { + notifySessionListChanged() + reply.send( + successResponse({ + sessionId: result.sessionId, + created: true, + }) + ) + } else { + const err = importFailed(result.error || 'Import failed') + reply.code(err.statusCode).send(errorResponse(err)) + } + } + } catch (error: any) { + apiLogger.error('Import error', error) + const apiError = apiErrorFromUnknown(error) + if (apiError) { + reply.code(apiError.statusCode).send(errorResponse(apiError)) + return + } + const err = importFailed(error.message || 'Import process error') + reply.code(err.statusCode).send(errorResponse(err)) + } finally { + isImporting.delete(lockKey) + if (tempFile) { + cleanupTempFile(tempFile) + } + } +} + +export function registerImportRoutes(server: FastifyInstance): void { + // JSONL mode: skip fastify's default body parsing, use request.raw stream directly + server.addContentTypeParser('application/x-ndjson', (_request, _payload, done) => { + done(null, undefined) + }) + + const SESSION_ID_RE = /^[A-Za-z0-9._@-]{1,128}$/ + + // v3 unified endpoint + server.post<{ Params: { sessionId: string } }>('/api/v1/imports/:sessionId', async (request, reply) => { + const { sessionId } = request.params + if (!SESSION_ID_RE.test(sessionId)) { + const err = invalidPayload('sessionId must be 1-128 characters of [A-Za-z0-9._@-]') + reply.code(err.statusCode).send(errorResponse(err)) + return + } + await handleUnifiedImport(request, reply, sessionId) + }) + + // Legacy endpoints (deprecated, kept for backward compatibility) + server.post('/api/v1/import', async (request, reply) => { + await handleLegacyImport(request, reply) + }) + + server.post<{ Params: { id: string } }>('/api/v1/sessions/:id/import', async (request, reply) => { + await handleLegacyImport(request, reply, request.params.id) + }) +} diff --git a/apps/desktop/main/api/routes/sessions.ts b/apps/desktop/main/api/routes/sessions.ts new file mode 100644 index 000000000..582749df7 --- /dev/null +++ b/apps/desktop/main/api/routes/sessions.ts @@ -0,0 +1,180 @@ +/** + * ChatLab API — Session and export routes + */ + +import type { FastifyInstance } from 'fastify' +import * as worker from '../../worker/workerManager' +import { successResponse, sessionNotFound, exportTooLarge, sqlExecutionError, ApiError, errorResponse } from '../errors' + +const EXPORT_MESSAGE_LIMIT = 100_000 + +async function ensureSession(sessionId: string) { + const session = await worker.getSession(sessionId) + if (!session) throw sessionNotFound(sessionId) + return session +} + +export function registerSessionRoutes(server: FastifyInstance): void { + // GET /api/v1/sessions — List all sessions + server.get('/api/v1/sessions', async () => { + const sessions = await worker.getAllSessions() + return successResponse(sessions) + }) + + // GET /api/v1/sessions/:id — Single session detail + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => { + const session = await ensureSession(request.params.id) + return successResponse(session) + }) + + // GET /api/v1/sessions/:id/messages — Query messages (paginated) + server.get<{ + Params: { id: string } + Querystring: { + page?: string + limit?: string + startTime?: string + endTime?: string + keyword?: string + senderId?: string + type?: string + } + }>('/api/v1/sessions/:id/messages', async (request) => { + const { id } = request.params + await ensureSession(id) + + const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1) + const limit = Math.min(1000, Math.max(1, parseInt(request.query.limit || '100', 10) || 100)) + const offset = (page - 1) * limit + + const { startTime, endTime, keyword, senderId } = request.query + + const filter: any = {} + if (startTime) filter.startTs = parseInt(startTime, 10) + if (endTime) filter.endTs = parseInt(endTime, 10) + const hasFilter = filter.startTs || filter.endTs + + const keywords = keyword ? [keyword] : [] + const senderIdNum = senderId ? parseInt(senderId, 10) : undefined + + const result = await worker.searchMessages(id, keywords, hasFilter ? filter : undefined, limit, offset, senderIdNum) + + return successResponse({ + messages: result.messages, + total: result.total, + page, + limit, + totalPages: Math.ceil(result.total / limit), + }) + }) + + // GET /api/v1/sessions/:id/members — Member list + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/members', async (request) => { + await ensureSession(request.params.id) + const members = await worker.getMembers(request.params.id) + return successResponse(members) + }) + + // GET /api/v1/sessions/:id/stats/overview — Overview statistics + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/stats/overview', async (request) => { + const { id } = request.params + const session = await ensureSession(id) + + const [timeRange, memberActivity, typeDistribution] = await Promise.all([ + worker.getTimeRange(id), + worker.getMemberActivity(id), + worker.getMessageTypeDistribution(id), + ]) + + const typeMap: Record = {} + for (const item of typeDistribution) { + typeMap[String(item.type)] = item.count + } + + const topMembers = memberActivity.slice(0, 10).map((m: any) => ({ + platformId: m.platformId, + name: m.name, + messageCount: m.messageCount, + percentage: m.percentage, + })) + + return successResponse({ + messageCount: session.messageCount, + memberCount: session.memberCount, + timeRange: timeRange || { start: 0, end: 0 }, + messageTypeDistribution: typeMap, + topMembers, + }) + }) + + // POST /api/v1/sessions/:id/sql — Execute SQL (read-only) + server.post<{ Params: { id: string }; Body: { sql: string } }>('/api/v1/sessions/:id/sql', async (request, reply) => { + const { id } = request.params + await ensureSession(id) + + const { sql } = request.body || {} + if (!sql || typeof sql !== 'string') { + const err = sqlExecutionError('Missing sql parameter') + return reply.code(err.statusCode).send(errorResponse(err)) + } + + try { + const result = await worker.executeRawSQL(id, sql) + return successResponse(result) + } catch (err: any) { + const message = err.message || 'SQL execution error' + if (message.includes('SELECT') || message.includes('只读') || message.includes('readonly')) { + const apiErr = new ApiError('SQL_READONLY_VIOLATION' as any, message) + apiErr.statusCode = 400 + return reply.code(400).send(errorResponse(apiErr)) + } + const apiErr = sqlExecutionError(message) + return reply.code(apiErr.statusCode).send(errorResponse(apiErr)) + } + }) + + // GET /api/v1/sessions/:id/export — Export ChatLab Format JSON + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/export', async (request, reply) => { + const { id } = request.params + const session = await ensureSession(id) + + if (session.messageCount > EXPORT_MESSAGE_LIMIT) { + const err = exportTooLarge(session.messageCount, EXPORT_MESSAGE_LIMIT) + return reply.code(err.statusCode).send(errorResponse(err)) + } + + const [members, messagesResult] = await Promise.all([ + worker.getMembers(id), + worker.searchMessages(id, [], undefined, EXPORT_MESSAGE_LIMIT, 0), + ]) + + const chatLabFormat = { + chatlab: { + version: '0.0.2', + exportedAt: Math.floor(Date.now() / 1000), + generator: 'ChatLab API', + }, + meta: { + name: session.name, + platform: session.platform, + type: session.type, + groupId: session.groupId || undefined, + }, + members: members.map((m: any) => ({ + platformId: m.platformId, + accountName: m.accountName || m.platformId, + groupNickname: m.groupNickname || undefined, + aliases: Array.isArray(m.aliases) && m.aliases.length > 0 ? m.aliases : undefined, + })), + messages: messagesResult.messages.map((msg: any) => ({ + sender: msg.senderPlatformId, + accountName: msg.senderName || undefined, + timestamp: msg.timestamp, + type: msg.type, + content: msg.content || null, + })), + } + + return successResponse(chatLabFormat) + }) +} diff --git a/apps/desktop/main/api/routes/system.ts b/apps/desktop/main/api/routes/system.ts new file mode 100644 index 000000000..f3c50c24b --- /dev/null +++ b/apps/desktop/main/api/routes/system.ts @@ -0,0 +1,91 @@ +/** + * ChatLab API — System routes + * GET /api/v1/status Service status + * GET /api/v1/schema ChatLab Format JSON Schema + */ + +import type { FastifyInstance } from 'fastify' +import { app } from 'electron' +import { successResponse } from '../errors' +import * as worker from '../../worker/workerManager' +import { getDesktopAppVersion } from '../../runtime-compat' + +export function registerSystemRoutes(server: FastifyInstance): void { + server.get('/api/v1/status', async () => { + let sessionCount = 0 + try { + const sessions = await worker.getAllSessions() + sessionCount = sessions.length + } catch { + // ignore when worker not ready + } + + return successResponse({ + name: 'ChatLab API', + version: getDesktopAppVersion(app.getVersion()), + uptime: Math.floor(process.uptime()), + sessionCount, + }) + }) + + server.get('/api/v1/schema', async () => { + return successResponse({ + format: 'ChatLab Format', + version: '0.0.2', + spec: { + chatlab: { + type: 'object', + required: ['version'], + properties: { + version: { type: 'string' }, + exportedAt: { type: 'number' }, + generator: { type: 'string' }, + }, + }, + meta: { + type: 'object', + required: ['name', 'platform', 'type'], + properties: { + name: { type: 'string' }, + platform: { + type: 'string', + enum: ['qq', 'wechat', 'telegram', 'discord', 'line', 'whatsapp', 'instagram', 'unknown'], + }, + type: { type: 'string', enum: ['group', 'private'] }, + groupId: { type: 'string' }, + }, + }, + members: { + type: 'array', + items: { + type: 'object', + required: ['platformId', 'accountName'], + properties: { + platformId: { type: 'string' }, + accountName: { type: 'string' }, + groupNickname: { type: 'string' }, + avatar: { type: 'string' }, + }, + }, + }, + messages: { + type: 'array', + items: { + type: 'object', + required: ['sender', 'timestamp', 'type'], + properties: { + platformMessageId: { type: 'string' }, + sender: { type: 'string' }, + accountName: { type: 'string' }, + groupNickname: { type: 'string' }, + timestamp: { type: 'number' }, + type: { type: 'number' }, + content: { type: ['string', 'null'] }, + replyToMessageId: { type: 'string' }, + }, + }, + }, + }, + }) + }) +} diff --git a/apps/desktop/main/api/server.ts b/apps/desktop/main/api/server.ts new file mode 100644 index 000000000..8670170d5 --- /dev/null +++ b/apps/desktop/main/api/server.ts @@ -0,0 +1,44 @@ +/** + * ChatLab API — Fastify server instance + */ + +import Fastify, { type FastifyInstance, type FastifyError } from 'fastify' +import { authHook } from './auth' +import { ApiError, ApiErrorCode, apiErrorFromUnknown, errorResponse, serverError } from './errors' +import { apiLogger } from './logger' + +const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50MB + +export function createServer(): FastifyInstance { + const server = Fastify({ + logger: false, + bodyLimit: JSON_BODY_LIMIT, + }) + + server.addHook('onRequest', authHook) + + server.setErrorHandler((error: FastifyError, _request, reply) => { + if (error instanceof ApiError) { + reply.code(error.statusCode).send(errorResponse(error)) + return + } + + const apiError = apiErrorFromUnknown(error) + if (apiError) { + reply.code(apiError.statusCode).send(errorResponse(apiError)) + return + } + + if (error.statusCode === 413) { + const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') + reply.code(413).send(errorResponse(bodyErr)) + return + } + + apiLogger.error('Unhandled error', error) + const err = serverError(error.message) + reply.code(err.statusCode).send(errorResponse(err)) + }) + + return server +} diff --git a/apps/desktop/main/database/core.ts b/apps/desktop/main/database/core.ts new file mode 100644 index 000000000..c544cba80 --- /dev/null +++ b/apps/desktop/main/database/core.ts @@ -0,0 +1,355 @@ +/** + * 数据库核心模块 + * 负责数据库的创建、打开、关闭和数据导入 + */ + +import Database from 'better-sqlite3' +import * as fs from 'fs' +import * as path from 'path' +import { + CHAT_DB_SCHEMA, + FTS_TABLE_SCHEMA, + updateSessionOwnerId as coreUpdateOwnerId, + renameSession as coreRenameSession, +} from '@openchatlab/core' +import { + BetterSqliteAdapter, + writeParseResultToDb, + contactsService, + peopleRelationshipsService, +} from '@openchatlab/node-runtime' +import type { RuntimeIdentity } from '@openchatlab/node-runtime/src/data-dir-compat' +import type { ParseResult } from '../../../../src/types/base' +import { migrateDatabase, needsMigration, CURRENT_SCHEMA_VERSION } from './migrations' +import { getPathProvider } from '../path-context' +import { ensureDir } from '../paths' +import { deleteSessionCache } from '@openchatlab/node-runtime' + +/** + * 获取数据库目录 + */ +function getDbDir(): string { + return getPathProvider().getDatabaseDir() +} + +/** + * 确保数据库目录存在 + */ +function ensureDbDir(): void { + ensureDir(getDbDir()) +} + +/** + * 生成唯一的会话ID + */ +function generateSessionId(): string { + const timestamp = Date.now() + const random = Math.random().toString(36).substring(2, 8) + return `chat_${timestamp}_${random}` +} + +/** + * 获取数据库文件路径 + */ +export function getDbPath(sessionId: string): string { + return path.join(getDbDir(), `${sessionId}.db`) +} + +/** + * 创建新数据库并初始化表结构 + */ +function createDatabase(sessionId: string): Database.Database { + ensureDbDir() + const dbPath = getDbPath(sessionId) + const db = new Database(dbPath) + + db.pragma('journal_mode = WAL') + + db.exec(CHAT_DB_SCHEMA) + db.exec(FTS_TABLE_SCHEMA) + + return db +} + +/** + * 打开已存在的数据库 + * @param readonly 是否只读模式(默认 true) + */ +export function openDatabase(sessionId: string, readonly = true): Database.Database | null { + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) { + return null + } + const db = new Database(dbPath, { readonly }) + db.pragma('journal_mode = WAL') + return db +} + +/** + * 打开数据库并执行迁移(如果需要) + * 用于需要写入的场景 + * @param sessionId 会话ID + * @param forceRepair 是否强制修复(即使版本号已是最新也重新执行迁移脚本) + */ +export function openDatabaseWithMigration( + sessionId: string, + forceRepair = false, + runtime?: RuntimeIdentity +): Database.Database | null { + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) { + return null + } + + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + + // 执行迁移 + migrateDatabase(db, forceRepair, { pathProvider: getPathProvider(), runtime }) + + return db +} + +/** + * 导入解析后的数据到数据库 + * Core write logic delegated to @openchatlab/node-runtime writeParseResultToDb. + */ +export function importData(parseResult: ParseResult): string { + const sessionId = generateSessionId() + const db = createDatabase(sessionId) + + try { + const adapter = new BetterSqliteAdapter(db) + writeParseResultToDb(adapter, parseResult.meta, parseResult.members, parseResult.messages) + return sessionId + } catch (error) { + console.error('[Database] Error in importData:', error) + throw error + } finally { + db.close() + } +} + +/** + * 更新会话的 ownerId + */ +export function updateSessionOwnerId(sessionId: string, ownerId: string | null): boolean { + const db = openDatabaseWithMigration(sessionId) + if (!db) return false + + try { + coreUpdateOwnerId(new BetterSqliteAdapter(db), ownerId) + return true + } catch (error) { + console.error('[Database] Failed to update session ownerId:', error) + return false + } finally { + db.close() + } +} + +/** + * 删除会话 + */ +export function deleteSession(sessionId: string): boolean { + const dbPath = getDbPath(sessionId) + const walPath = dbPath + '-wal' + const shmPath = dbPath + '-shm' + + try { + if (fs.existsSync(dbPath)) { + fs.unlinkSync(dbPath) + } + if (fs.existsSync(walPath)) { + fs.unlinkSync(walPath) + } + if (fs.existsSync(shmPath)) { + fs.unlinkSync(shmPath) + } + const cacheDir = getPathProvider().getCacheDir() + deleteSessionCache(sessionId, cacheDir) + deleteSessionCache(sessionId, path.join(cacheDir, 'query')) + deleteSessionCache(sessionId, contactsService.getContactsFactsCacheDir(getPathProvider().getUserDataDir())) + deleteSessionCache( + sessionId, + peopleRelationshipsService.getPeopleRelationshipsFactsCacheDir(getPathProvider().getUserDataDir()) + ) + return true + } catch (error) { + console.error('[Database] Failed to delete session:', error) + return false + } +} + +/** + * 重命名会话 + */ +export function renameSession(sessionId: string, newName: string): boolean { + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) return false + + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + try { + coreRenameSession(new BetterSqliteAdapter(db), newName) + return true + } catch (error) { + console.error('[Database] Failed to rename session:', error) + return false + } finally { + db.close() + } +} + +/** + * 获取数据库存储目录 + */ +export function getDbDirectory(): string { + ensureDbDir() + return getDbDir() +} + +/** + * 检查是否有数据库需要迁移 + * @returns 需要迁移的数据库数量、列表、最低版本和需要强制修复的列表 + */ +export function checkMigrationNeeded(): { + count: number + sessionIds: string[] + lowestVersion: number + forceRepairIds: string[] +} { + ensureDbDir() + const dbDir = getDbDir() + const files = fs.readdirSync(dbDir).filter((f) => f.endsWith('.db')) + const needsMigrationList: string[] = [] + const forceRepairList: string[] = [] + let lowestVersion = CURRENT_SCHEMA_VERSION + + for (const file of files) { + const sessionId = file.replace('.db', '') + const dbPath = getDbPath(sessionId) + + try { + const db = new Database(dbPath, { readonly: true }) + db.pragma('journal_mode = WAL') + + // 仅迁移聊天会话数据库:这里最小依赖是 meta + message + // 这样可跳过非聊天库,同时避免把 member 缺失的异常库直接误归为“非聊天库” + const requiredTableCount = db + .prepare("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name IN ('meta', 'message')") + .get() as { cnt: number } + const isChatSessionDb = requiredTableCount.cnt === 2 + if (!isChatSessionDb) { + db.close() + continue + } + + // 获取当前 schema_version + const metaTableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> + const hasVersionColumn = metaTableInfo.some((col) => col.name === 'schema_version') + let dbVersion = 0 + if (hasVersionColumn) { + const result = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as + | { schema_version: number | null } + | undefined + dbVersion = result?.schema_version ?? 0 + } + + // 检查 message 表是否有 reply_to_message_id 列 + const messageTableInfo = db.prepare('PRAGMA table_info(message)').all() as Array<{ name: string }> + const hasReplyColumn = messageTableInfo.some((col) => col.name === 'reply_to_message_id') + + if (needsMigration(db)) { + needsMigrationList.push(sessionId) + lowestVersion = Math.min(lowestVersion, dbVersion) + } else if (!hasReplyColumn) { + // 特殊情况:版本号已更新但列不存在,需要强制修复 + needsMigrationList.push(sessionId) + forceRepairList.push(sessionId) + lowestVersion = Math.min(lowestVersion, dbVersion) + } + + db.close() + } catch (error) { + console.error(`[Database] Failed to check migration for ${file}:`, error) + } + } + + return { + count: needsMigrationList.length, + sessionIds: needsMigrationList, + lowestVersion, + forceRepairIds: forceRepairList, + } +} + +/** + * 迁移失败的数据库信息 + */ +interface MigrationFailure { + sessionId: string + error: string +} + +/** + * 执行所有数据库的迁移 + * 即使部分数据库迁移失败,也会继续处理其他数据库 + * @returns 迁移结果,包含成功数量和失败列表 + */ +export function migrateAllDatabases(): { + success: boolean + migratedCount: number + failures: MigrationFailure[] + error?: string +} +export function migrateAllDatabases(runtime: RuntimeIdentity): { + success: boolean + migratedCount: number + failures: MigrationFailure[] + error?: string +} +export function migrateAllDatabases(runtime?: RuntimeIdentity): { + success: boolean + migratedCount: number + failures: MigrationFailure[] + error?: string +} { + const { sessionIds, forceRepairIds } = checkMigrationNeeded() + const forceRepairSet = new Set(forceRepairIds) + + if (sessionIds.length === 0) { + return { success: true, migratedCount: 0, failures: [] } + } + + let migratedCount = 0 + const failures: MigrationFailure[] = [] + + for (const sessionId of sessionIds) { + try { + const needsForceRepair = forceRepairSet.has(sessionId) + const db = openDatabaseWithMigration(sessionId, needsForceRepair, runtime) + if (db) { + db.close() + migratedCount++ + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`[Database] Failed to migrate ${sessionId}:`, errorMessage) + failures.push({ sessionId, error: errorMessage }) + } + } + + // 如果有失败的数据库,返回部分成功状态 + if (failures.length > 0) { + const failedIds = failures.map((f) => f.sessionId.split('_').slice(-1)[0]).join(', ') + return { + success: false, + migratedCount, + failures, + error: `${failures.length} 个数据库迁移失败(ID: ${failedIds})。建议在侧边栏中删除这些损坏的会话。`, + } + } + + return { success: true, migratedCount, failures: [] } +} diff --git a/apps/desktop/main/database/migrations.test.ts b/apps/desktop/main/database/migrations.test.ts new file mode 100644 index 000000000..b69d44272 --- /dev/null +++ b/apps/desktop/main/database/migrations.test.ts @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import Database from 'better-sqlite3' +import type { PathProvider } from '@openchatlab/core' +import { readDataDirCompatibilityMeta } from '@openchatlab/node-runtime/src/data-dir-compat' +import { getPendingMigrationInfos, migrateDatabase } from './migrations' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-desktop-migration-')) +} + +function makePathProvider(root: string): PathProvider { + return { + getSystemDir: () => path.join(root, 'system'), + getUserDataDir: () => path.join(root, 'data'), + getDatabaseDir: () => path.join(root, 'data', 'databases'), + getVectorDir: () => path.join(root, 'data', 'vector'), + getAiDataDir: () => path.join(root, 'system', 'ai'), + getSettingsDir: () => path.join(root, 'system', 'settings'), + getCacheDir: () => path.join(root, 'system', 'cache'), + getTempDir: () => path.join(root, 'system', 'temp'), + getLogsDir: () => path.join(root, 'system', 'logs'), + getDownloadsDir: () => path.join(root, 'downloads'), + } +} + +test('migrateDatabase writes data directory compatibility meta after segment schema migration', () => { + const root = makeTempDir() + const dbPath = path.join(root, 'data', 'databases', 'desktop-legacy.db') + fs.mkdirSync(path.dirname(dbPath), { recursive: true }) + + const db = new Database(dbPath, { nativeBinding }) + db.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 4 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Desktop Legacy Chat', 'qq', 'group', 1000, 4); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + `) + + try { + assert.equal( + migrateDatabase(db, false, { + pathProvider: makePathProvider(root), + runtime: { version: '0.25.1', kind: 'desktop' }, + }), + true + ) + } finally { + db.close() + } + + const meta = readDataDirCompatibilityMeta(path.join(root, 'data')) + assert.equal(meta?.minRuntimeVersion, '0.25.1') + assert.equal(meta?.dataCompatibilityVersion, 1) + assert.deepEqual(meta?.reasons, ['segment-schema']) + assert.deepEqual(meta?.updatedBy, { + runtime: 'desktop', + module: 'chat-db-migration', + version: '0.25.1', + }) +}) + +test('getPendingMigrationInfos maps each version to its own localized message', () => { + const migrations = getPendingMigrationInfos(6) + + assert.deepEqual( + migrations.map((m) => m.version), + [7, 8] + ) + + const v7 = migrations[0] + assert.match(v7.userMessage, /Repair|修复/) + assert.doesNotMatch(v7.userMessage, /Owner/) + + // v8 必须有自己的本地化消息,而非回退到首个迁移文案 + const v8 = migrations[1] + assert.match(v8.userMessage, /index|索引|インデックス/i) + assert.doesNotMatch(v8.userMessage, /Owner/) +}) diff --git a/apps/desktop/main/database/migrations.ts b/apps/desktop/main/database/migrations.ts new file mode 100644 index 000000000..fa7681bb5 --- /dev/null +++ b/apps/desktop/main/database/migrations.ts @@ -0,0 +1,128 @@ +/** + * Database migration — Electron adapter layer. + * + * Thin wrapper around @openchatlab/node-runtime shared migration definitions. + * Keeps Electron-only concerns: i18n MigrationInfo, better-sqlite3 type bridging. + */ + +import type Database from 'better-sqlite3' +import { + CURRENT_SCHEMA_VERSION, + getSchemaVersion, + runMigrations, + needsMigration as coreNeedsMigration, +} from '@openchatlab/core' +import type { DatabaseAdapter, PathProvider } from '@openchatlab/core' +import { BetterSqliteAdapter } from '@openchatlab/node-runtime/src/better-sqlite3-adapter' +import { + CHAT_DB_COMPATIBILITY_RAISES, + getChatDbMigrations, +} from '@openchatlab/node-runtime/src/migrations/chat-db-migrations' +import { raiseDataDirMinRuntimeVersion, type RuntimeIdentity } from '@openchatlab/node-runtime/src/data-dir-compat' +import { t } from '../i18n' +import { tokenizeForFts } from '@openchatlab/node-runtime/src/nlp/fts-tokenizer' + +export { CURRENT_SCHEMA_VERSION } + +export interface MigrationInfo { + version: number + description: string + userMessage: string +} + +export interface DesktopMigrationOptions { + pathProvider?: PathProvider + runtime?: RuntimeIdentity +} + +const i18nKeys: Record = Object.fromEntries( + Array.from({ length: CURRENT_SCHEMA_VERSION }, (_, index) => { + const version = index + 1 + return [ + version, + { + descriptionKey: `database.migrationV${version}Desc`, + userMessageKey: `database.migrationV${version}Message`, + }, + ] + }) +) + +function checkDatabaseIntegrity(db: DatabaseAdapter): { valid: boolean; error?: string } { + try { + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='meta'").all() as Array<{ + name: string + }> + if (tables.length === 0) { + return { valid: false, error: t('database.integrityError') } + } + return { valid: true } + } catch (error) { + return { + valid: false, + error: t('database.checkFailed', { error: error instanceof Error ? error.message : String(error) }), + } + } +} + +/** + * Execute database migrations. + * Wraps better-sqlite3 Database in a DatabaseAdapter and delegates to core runMigrations + * with shared migration definitions from @openchatlab/node-runtime. + */ +export function migrateDatabase( + db: Database.Database, + forceRepair = false, + options: DesktopMigrationOptions = {} +): boolean { + const adapter = new BetterSqliteAdapter(db) + + const integrity = checkDatabaseIntegrity(adapter) + if (!integrity.valid) { + throw new Error(integrity.error) + } + + const beforeVersion = getSchemaVersion(adapter) + const migrations = getChatDbMigrations({ tokenizeForFts }) + const migrated = runMigrations(adapter, migrations, forceRepair) + if (!migrated || !options.pathProvider || !options.runtime) return migrated + + const afterVersion = getSchemaVersion(adapter) + for (const compatibilityRaise of CHAT_DB_COMPATIBILITY_RAISES) { + if (beforeVersion >= compatibilityRaise.migrationVersion || afterVersion < compatibilityRaise.migrationVersion) { + continue + } + + raiseDataDirMinRuntimeVersion(options.pathProvider, { + minRuntimeVersion: compatibilityRaise.minRuntimeVersion, + dataCompatibilityVersion: compatibilityRaise.dataCompatibilityVersion, + reason: compatibilityRaise.reason, + runtime: options.runtime, + module: compatibilityRaise.module, + }) + } + + return migrated +} + +export function needsMigration(db: Database.Database): boolean { + const adapter = new BetterSqliteAdapter(db) + return coreNeedsMigration(adapter, CURRENT_SCHEMA_VERSION) +} + +/** + * Get pending migration info for UI display (with i18n). + */ +export function getPendingMigrationInfos(fromVersion = 0): MigrationInfo[] { + const migrations = getChatDbMigrations({ tokenizeForFts }) + return migrations + .filter((m) => m.version > fromVersion) + .map((m) => { + const keys = i18nKeys[m.version] + return { + version: m.version, + description: (keys && t(keys.descriptionKey)) || m.description, + userMessage: (keys && t(keys.userMessageKey)) || m.description, + } + }) +} diff --git a/apps/desktop/main/database/startup-migration.test.ts b/apps/desktop/main/database/startup-migration.test.ts new file mode 100644 index 000000000..1baf52465 --- /dev/null +++ b/apps/desktop/main/database/startup-migration.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import Database from 'better-sqlite3' +import type { PathProvider } from '@openchatlab/core' +import { readDataDirCompatibilityMeta } from '@openchatlab/node-runtime/src/data-dir-compat' +import { assertDesktopStartupMigrationSucceeded, repairDesktopStartupCompatibilityGate } from './startup-migration' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-startup-migration-')) +} + +function makePathProvider(root: string): PathProvider { + return { + getSystemDir: () => path.join(root, 'system'), + getUserDataDir: () => path.join(root, 'data'), + getDatabaseDir: () => path.join(root, 'data', 'databases'), + getVectorDir: () => path.join(root, 'data', 'vector'), + getAiDataDir: () => path.join(root, 'system', 'ai'), + getSettingsDir: () => path.join(root, 'system', 'settings'), + getCacheDir: () => path.join(root, 'system', 'cache'), + getTempDir: () => path.join(root, 'system', 'temp'), + getLogsDir: () => path.join(root, 'system', 'logs'), + getDownloadsDir: () => path.join(root, 'downloads'), + } +} + +test('assertDesktopStartupMigrationSucceeded aborts when startup migration fails', () => { + assert.throws( + () => + assertDesktopStartupMigrationSucceeded({ + success: false, + migratedCount: 0, + failures: [{ sessionId: 'legacy', error: 'EACCES: permission denied' }], + error: '1 database migration failed', + }), + /Database schema migration failed/ + ) +}) + +test('assertDesktopStartupMigrationSucceeded accepts successful startup migrations', () => { + assert.doesNotThrow(() => assertDesktopStartupMigrationSucceeded({ success: true, migratedCount: 1, failures: [] })) +}) + +test('repairDesktopStartupCompatibilityGate raises metadata for already migrated segment schema data', () => { + const calls: Array<{ minRuntimeVersion: string; reason: string }> = [] + + repairDesktopStartupCompatibilityGate( + { version: '0.25.1', kind: 'desktop' }, + { + hasCurrentSegmentSchemaData: () => true, + raiseDataDirMinRuntimeVersion: (_pathProvider, input) => { + calls.push({ minRuntimeVersion: input.minRuntimeVersion, reason: input.reason }) + return { + formatVersion: 1, + minRuntimeVersion: input.minRuntimeVersion, + dataCompatibilityVersion: input.dataCompatibilityVersion, + reasons: [input.reason], + updatedBy: { runtime: input.runtime.kind, module: input.module, version: input.runtime.version }, + updatedAt: 1780830000, + } + }, + pathProvider: {} as never, + } + ) + + assert.deepEqual(calls, [{ minRuntimeVersion: '0.25.1', reason: 'segment-schema' }]) +}) + +test('repairDesktopStartupCompatibilityGate writes missing metadata for existing v6 databases', () => { + const root = makeTempDir() + const dbPath = path.join(root, 'data', 'databases', 'already-v6.db') + fs.mkdirSync(path.dirname(dbPath), { recursive: true }) + + const db = new Database(dbPath, { nativeBinding }) + db.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 6 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Already V6', 'qq', 'group', 1000, 6); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + + CREATE TABLE segment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + + CREATE TABLE message_context ( + message_id INTEGER PRIMARY KEY, + segment_id INTEGER NOT NULL, + topic_id INTEGER + ); + `) + db.close() + + repairDesktopStartupCompatibilityGate( + { version: '0.25.1', kind: 'desktop' }, + { pathProvider: makePathProvider(root), nativeBinding } + ) + + const meta = readDataDirCompatibilityMeta(path.join(root, 'data')) + assert.equal(meta?.minRuntimeVersion, '0.25.1') + assert.equal(meta?.dataCompatibilityVersion, 1) + assert.deepEqual(meta?.reasons, ['segment-schema']) +}) diff --git a/apps/desktop/main/database/startup-migration.ts b/apps/desktop/main/database/startup-migration.ts new file mode 100644 index 000000000..fcf5b141a --- /dev/null +++ b/apps/desktop/main/database/startup-migration.ts @@ -0,0 +1,103 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import Database from 'better-sqlite3' +import type { PathProvider } from '@openchatlab/core' +import { CHAT_DB_COMPATIBILITY_RAISES } from '@openchatlab/node-runtime/src/migrations/chat-db-migrations' +import { + raiseDataDirMinRuntimeVersion, + type DataDirCompatibilityMeta, + type RaiseDataDirCompatibilityInput, + type RuntimeIdentity, +} from '@openchatlab/node-runtime/src/data-dir-compat' + +export interface DesktopStartupMigrationFailure { + sessionId: string + error: string +} + +export interface DesktopStartupMigrationResult { + success: boolean + migratedCount: number + failures: DesktopStartupMigrationFailure[] + error?: string +} + +export function assertDesktopStartupMigrationSucceeded(result: DesktopStartupMigrationResult): void { + if (!result.success) { + throw new Error(formatStartupMigrationError(result)) + } +} + +export interface DesktopStartupCompatibilityGateDeps { + pathProvider: PathProvider + nativeBinding?: string + hasCurrentSegmentSchemaData?: () => boolean + raiseDataDirMinRuntimeVersion?: ( + pathProvider: PathProvider, + input: RaiseDataDirCompatibilityInput + ) => DataDirCompatibilityMeta +} + +export function repairDesktopStartupCompatibilityGate( + runtime: RuntimeIdentity, + deps: DesktopStartupCompatibilityGateDeps +): void { + const hasCurrentSegmentSchemaData = + deps.hasCurrentSegmentSchemaData ?? (() => hasCurrentSegmentSchemaDatabases(deps.pathProvider, deps.nativeBinding)) + if (!hasCurrentSegmentSchemaData()) return + + const raise = deps.raiseDataDirMinRuntimeVersion ?? raiseDataDirMinRuntimeVersion + for (const compatibilityRaise of CHAT_DB_COMPATIBILITY_RAISES) { + raise(deps.pathProvider, { + minRuntimeVersion: compatibilityRaise.minRuntimeVersion, + dataCompatibilityVersion: compatibilityRaise.dataCompatibilityVersion, + reason: compatibilityRaise.reason, + runtime, + module: compatibilityRaise.module, + }) + } +} + +function formatStartupMigrationError(result: DesktopStartupMigrationResult): string { + const details = result.failures.map((failure) => `${failure.sessionId}: ${failure.error}`).join('\n') + return ['Database schema migration failed.', result.error, details].filter(Boolean).join('\n') +} + +function hasCurrentSegmentSchemaDatabases(pathProvider: PathProvider, nativeBinding?: string): boolean { + const dbDir = pathProvider.getDatabaseDir() + if (!fs.existsSync(dbDir)) return false + + for (const file of fs.readdirSync(dbDir)) { + if (!file.endsWith('.db')) continue + + const dbPath = path.join(dbDir, file) + let db: Database.Database | null = null + try { + db = new Database(dbPath, { readonly: true, nativeBinding }) + if (isCurrentSegmentSchemaDatabase(db)) return true + } catch (error) { + console.error(`[Database] Failed to inspect compatibility gate for ${file}:`, error) + } finally { + db?.close() + } + } + + return false +} + +function isCurrentSegmentSchemaDatabase(db: Database.Database): boolean { + const tables = db + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('meta', 'message', 'segment', 'message_context')" + ) + .all() as Array<{ name: string }> + const tableNames = new Set(tables.map((table) => table.name)) + if (!tableNames.has('meta') || !tableNames.has('message')) return false + if (!tableNames.has('segment') || !tableNames.has('message_context')) return false + + const metaColumns = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> + if (!metaColumns.some((column) => column.name === 'schema_version')) return false + + const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number } | undefined + return (version?.schema_version ?? 0) >= 6 +} diff --git a/apps/desktop/main/electron-path-provider.ts b/apps/desktop/main/electron-path-provider.ts new file mode 100644 index 000000000..b32a56e23 --- /dev/null +++ b/apps/desktop/main/electron-path-provider.ts @@ -0,0 +1,51 @@ +/** + * Electron implementation of PathProvider. + * Wraps the existing paths.ts functions to satisfy the shared interface. + */ + +import type { PathProvider } from '@openchatlab/core' +import { + getSystemDataDir, + getUserDataDir, + getDatabaseDir, + getVectorDir, + getAiDataDir, + getSettingsDir, + getCacheDir, + getTempDir, + getLogsDir, + getDownloadsDir, +} from './paths' + +export class ElectronPathProvider implements PathProvider { + getSystemDir(): string { + return getSystemDataDir() + } + getUserDataDir(): string { + return getUserDataDir() + } + getDatabaseDir(): string { + return getDatabaseDir() + } + getVectorDir(): string { + return getVectorDir() + } + getAiDataDir(): string { + return getAiDataDir() + } + getSettingsDir(): string { + return getSettingsDir() + } + getCacheDir(): string { + return getCacheDir() + } + getTempDir(): string { + return getTempDir() + } + getLogsDir(): string { + return getLogsDir() + } + getDownloadsDir(): string { + return getDownloadsDir() + } +} diff --git a/apps/desktop/main/env.d.ts b/apps/desktop/main/env.d.ts new file mode 100644 index 000000000..1010e2d3b --- /dev/null +++ b/apps/desktop/main/env.d.ts @@ -0,0 +1,6 @@ +declare const __APP_VERSION__: string + +declare module '*.md?raw' { + const content: string + export default content +} diff --git a/apps/desktop/main/i18n/index.ts b/apps/desktop/main/i18n/index.ts new file mode 100644 index 000000000..51b900f96 --- /dev/null +++ b/apps/desktop/main/i18n/index.ts @@ -0,0 +1,81 @@ +/** + * Main process i18n module + * + * Uses i18next for multi-language support. + * Locale is persisted in config.toml [locale] lang and synced + * with the renderer process via IPC 'locale:change'. + */ + +import i18next from 'i18next' +import { app, ipcMain } from 'electron' +import { loadConfig, writeConfigField } from '@openchatlab/config' +import zhCN from './locales/zh-CN' +import enUS from './locales/en-US' +import zhTW from './locales/zh-TW' +import jaJP from './locales/ja-JP' + +function detectSystemLocale(): string { + const sysLocale = app.getLocale() + if (sysLocale === 'zh-TW' || sysLocale === 'zh-Hant') return 'zh-TW' + if (sysLocale.startsWith('zh')) return 'zh-CN' + if (sysLocale.startsWith('ja')) return 'ja-JP' + return 'en-US' +} + +export async function initLocale(): Promise { + let lng = 'en-US' + + try { + const config = loadConfig() + if (config.locale.lang) { + lng = config.locale.lang + } else { + lng = detectSystemLocale() + } + } catch (e) { + console.error('[i18n] Error loading locale config:', e) + } + + await i18next.init({ + lng, + fallbackLng: 'en-US', + resources: { + 'zh-CN': { translation: zhCN }, + 'en-US': { translation: enUS }, + 'zh-TW': { translation: zhTW }, + 'ja-JP': { translation: jaJP }, + }, + interpolation: { escapeValue: false }, + }) + + console.log(`[i18n] Initialized with locale: ${lng}`) + + ipcMain.on('locale:change', async (_event, newLocale: string) => { + if (newLocale !== i18next.language) { + await i18next.changeLanguage(newLocale) + try { + writeConfigField('locale', 'lang', newLocale) + } catch (err) { + console.error('[i18n] Failed to persist locale:', err) + } + console.log(`[i18n] Locale changed to: ${newLocale}`) + } + }) +} + +/** + * 翻译函数 + * @param key 翻译 key,如 'update.newVersionTitle' + * @param options 插值参数,如 { version: '1.0.0' } + */ +export const t = (key: string, options?: Record): string => i18next.t(key, options) + +/** + * 获取当前 locale + */ +export const getLocale = (): string => i18next.language + +/** + * 判断当前是否为中文环境(兼容现有 isChineseLocale 模式) + */ +export const isChineseLocale = (): boolean => i18next.language.startsWith('zh') diff --git a/apps/desktop/main/i18n/locales/en-US.ts b/apps/desktop/main/i18n/locales/en-US.ts new file mode 100644 index 000000000..3bfa84afc --- /dev/null +++ b/apps/desktop/main/i18n/locales/en-US.ts @@ -0,0 +1,72 @@ +/** + * Main process English translations + * + * AI shared translations imported from @openchatlab/node-runtime; + * Electron-specific translations defined here. + */ +import aiLocale from '@openchatlab/node-runtime/src/ai/i18n/locales/en-US' + +export default { + // ===== Common ===== + common: { + error: 'Error', + }, + + // ===== P0: Update dialogs ===== + update: { + newVersionTitle: 'New version v{{version}} available', + newVersionMessage: 'New version v{{version}} available', + newVersionDetail: 'Would you like to download and install the new version?', + downloadNow: 'Download Now', + cancel: 'Cancel', + downloadComplete: 'Download Complete', + readyToInstall: 'The new version is ready. Install now?', + install: 'Install', + remindLater: 'Remind Later', + installOnQuit: 'Later (auto-install on quit)', + upToDate: 'You are up to date', + }, + + // ===== P0: File/directory dialogs ===== + dialog: { + selectChatFile: 'Select Chat Record File', + chatRecords: 'Chat Records', + allFiles: 'All Files', + import: 'Import', + selectDirectory: 'Select Directory', + selectFolder: 'Select Folder', + selectFolderError: 'Error selecting folder: ', + }, + + // ===== P1: Database migrations ===== + database: { + migrationV1Desc: 'Add owner_id field to meta table', + migrationV1Message: 'Support "Owner" feature to set your identity in the member list', + migrationV2Desc: 'Add roles, reply_to_message_id, platform_message_id fields', + migrationV2Message: 'Support member roles, message reply relationships and reply preview', + migrationV3Desc: 'Add session index tables (segment, message_context) and session_gap_threshold field', + migrationV3Message: 'Support session timeline browsing and AI-enhanced analysis', + migrationV4Desc: 'Create FTS5 full-text search index (message_fts) and build index data', + migrationV4Message: 'Enable full-text search for significantly faster keyword search', + migrationV5Desc: 'Repair legacy member and message fields', + migrationV5Message: 'Update legacy database fields for compatibility with the current version', + migrationV6Desc: 'Upgrade the session index to the segment schema', + migrationV6Message: 'Upgrade the session index structure while preserving existing indexes and summaries', + migrationV7Desc: 'Repair missing session message mappings', + migrationV7Message: 'Repair missing session index mappings while preserving existing sessions and summaries', + migrationV8Desc: 'Add analysis tool performance indexes', + migrationV8Message: + 'Add performance indexes for analysis tools to speed up queries without affecting existing data', + integrityError: + 'Database structure is incomplete: missing meta table. Please delete this database file and re-import.', + checkFailed: 'Database check failed: {{error}}', + }, + + // ===== Tool system ===== + tools: { + notRegistered: 'Tool "{{toolName}}" is not registered', + }, + + // AI shared translations (from @openchatlab/node-runtime) + ...aiLocale, +} diff --git a/apps/desktop/main/i18n/locales/ja-JP.ts b/apps/desktop/main/i18n/locales/ja-JP.ts new file mode 100644 index 000000000..75420d9b5 --- /dev/null +++ b/apps/desktop/main/i18n/locales/ja-JP.ts @@ -0,0 +1,73 @@ +/** + * メインプロセス日本語翻訳 + * + * AI 共有翻訳は @openchatlab/node-runtime から導入。 + * Electron 固有翻訳はこのファイルで定義。 + */ +import aiLocale from '@openchatlab/node-runtime/src/ai/i18n/locales/ja-JP' + +export default { + // ===== 共通 ===== + common: { + error: 'エラー', + }, + + // ===== P0: アップデートダイアログ ===== + update: { + newVersionTitle: '新バージョン v{{version}} が見つかりました', + newVersionMessage: '新バージョン v{{version}} が見つかりました', + newVersionDetail: '今すぐダウンロードしてインストールしますか?', + downloadNow: '今すぐダウンロード', + cancel: 'キャンセル', + downloadComplete: 'ダウンロード完了', + readyToInstall: '新バージョンの準備ができました。今すぐインストールしますか?', + install: 'インストール', + remindLater: '後で通知', + installOnQuit: '後で(アプリ終了時に自動インストール)', + upToDate: '最新バージョンです', + }, + + // ===== P0: ファイル/ディレクトリダイアログ ===== + dialog: { + selectChatFile: 'チャット履歴ファイルを選択', + chatRecords: 'チャット履歴', + allFiles: 'すべてのファイル', + import: 'インポート', + selectDirectory: 'ディレクトリを選択', + selectFolder: 'フォルダーを選択', + selectFolderError: 'フォルダー選択中にエラーが発生しました:', + }, + + // ===== P1: データベースマイグレーション ===== + database: { + migrationV1Desc: 'meta テーブルに owner_id フィールドを追加', + migrationV1Message: '「Owner」機能に対応。メンバー一覧で自分の立場を設定できます', + migrationV2Desc: 'roles、reply_to_message_id、platform_message_id フィールドを追加', + migrationV2Message: 'メンバーロール、メッセージ返信関係、返信内容プレビューをサポート', + migrationV3Desc: + 'セッションインデックス関連テーブル(segment、message_context)と session_gap_threshold フィールドを追加', + migrationV3Message: 'セッションのタイムライン表示と AI 拡張分析に対応', + migrationV4Desc: 'FTS5 全文検索インデックス(message_fts)を作成しインデックスデータを構築', + migrationV4Message: '全文検索に対応し、キーワード検索速度が大幅に向上', + migrationV5Desc: '旧バージョンのメンバーおよびメッセージフィールドを修復', + migrationV5Message: '現在のバージョンと互換性を保つため、旧データベースのフィールドを更新します', + migrationV6Desc: 'セッションインデックスを segment スキーマに更新', + migrationV6Message: '既存のインデックスと要約を保持したまま、セッションインデックス構造を更新します', + migrationV7Desc: '欠落したセッションメッセージの関連付けを修復', + migrationV7Message: '既存のセッションと要約を保持したまま、欠落したメッセージの関連付けを修復します', + migrationV8Desc: '分析ツールのパフォーマンスインデックスを追加', + migrationV8Message: + '分析ツールのクエリを高速化するパフォーマンスインデックスを追加します(既存データには影響しません)', + integrityError: + 'データベース構造が不完全です:meta テーブルがありません。このデータベースファイルを削除して再インポートすることをお勧めします。', + checkFailed: 'データベースチェックに失敗しました: {{error}}', + }, + + // ===== ツールシステム ===== + tools: { + notRegistered: 'ツール "{{toolName}}" は登録されていません', + }, + + // AI shared translations (from @openchatlab/node-runtime) + ...aiLocale, +} diff --git a/apps/desktop/main/i18n/locales/zh-CN.ts b/apps/desktop/main/i18n/locales/zh-CN.ts new file mode 100644 index 000000000..10e5e3316 --- /dev/null +++ b/apps/desktop/main/i18n/locales/zh-CN.ts @@ -0,0 +1,69 @@ +/** + * 主进程中文翻译 + * + * AI 共享翻译从 @openchatlab/node-runtime 导入,Electron 专有翻译在本文件定义。 + */ +import aiLocale from '@openchatlab/node-runtime/src/ai/i18n/locales/zh-CN' + +export default { + // ===== 通用 ===== + common: { + error: '错误', + }, + + // ===== P0: 更新弹窗 ===== + update: { + newVersionTitle: '发现新版本 v{{version}}', + newVersionMessage: '发现新版本 v{{version}}', + newVersionDetail: '是否立即下载并安装新版本?', + downloadNow: '立即下载', + cancel: '取消', + downloadComplete: '下载完成', + readyToInstall: '新版本已准备就绪,是否现在安装?', + install: '安装', + remindLater: '之后提醒', + installOnQuit: '稍后(应用退出后自动安装)', + upToDate: '已是最新版本', + }, + + // ===== P0: 文件/目录对话框 ===== + dialog: { + selectChatFile: '选择聊天记录文件', + chatRecords: '聊天记录', + allFiles: '所有文件', + import: '导入', + selectDirectory: '选择目录', + selectFolder: '选择文件夹', + selectFolderError: '选择文件夹时发生错误:', + }, + + // ===== P1: 数据库迁移 ===== + database: { + migrationV1Desc: '添加 owner_id 字段到 meta 表', + migrationV1Message: '支持「Owner」功能,可在成员列表中设置自己的身份', + migrationV2Desc: '添加 roles、reply_to_message_id、platform_message_id 字段', + migrationV2Message: '支持成员角色、消息回复关系和回复内容预览', + migrationV3Desc: '添加会话索引相关表(segment、message_context)和 session_gap_threshold 字段', + migrationV3Message: '支持会话时间轴浏览和 AI 增强分析功能', + migrationV4Desc: '创建 FTS5 全文搜索索引(message_fts)并构建索引数据', + migrationV4Message: '支持全文搜索,大幅提升关键词搜索速度', + migrationV5Desc: '修复旧版成员和消息字段', + migrationV5Message: '更新旧版数据库字段以兼容当前版本', + migrationV6Desc: '将会话索引升级为 segment 结构', + migrationV6Message: '升级会话索引结构,并保留现有索引和摘要', + migrationV7Desc: '修复缺失的会话消息关联', + migrationV7Message: '修复会话索引中缺失的消息关联,并保留现有会话和摘要', + migrationV8Desc: '添加分析工具性能索引', + migrationV8Message: '为分析工具添加性能索引,提升查询速度,不影响现有数据', + integrityError: '数据库结构不完整:缺少 meta 表。建议删除此数据库文件后重新导入。', + checkFailed: '数据库检查失败: {{error}}', + }, + + // ===== 工具系统 ===== + tools: { + notRegistered: '工具 "{{toolName}}" 未注册', + }, + + // AI shared translations (from @openchatlab/node-runtime) + ...aiLocale, +} diff --git a/apps/desktop/main/i18n/locales/zh-TW.ts b/apps/desktop/main/i18n/locales/zh-TW.ts new file mode 100644 index 000000000..47e696ea4 --- /dev/null +++ b/apps/desktop/main/i18n/locales/zh-TW.ts @@ -0,0 +1,70 @@ +/** + * 主程序繁體中文翻譯 + * + * AI 共享翻譯自 @openchatlab/node-runtime 匯入, + * Electron 專有翻譯在本檔案定義。 + */ +import aiLocale from '@openchatlab/node-runtime/src/ai/i18n/locales/zh-TW' + +export default { + // ===== 通用 ===== + common: { + error: '錯誤', + }, + + // ===== P0: 更新彈窗 ===== + update: { + newVersionTitle: '發現新版本 v{{version}}', + newVersionMessage: '發現新版本 v{{version}}', + newVersionDetail: '是否立即下載並安裝新版本?', + downloadNow: '立即下載', + cancel: '取消', + downloadComplete: '下載完成', + readyToInstall: '新版本已準備就緒,是否現在安裝?', + install: '安裝', + remindLater: '稍後提醒', + installOnQuit: '稍後(應用退出後自動安裝)', + upToDate: '已是最新版本', + }, + + // ===== P0: 檔案/目錄對話框 ===== + dialog: { + selectChatFile: '選擇聊天紀錄檔案', + chatRecords: '聊天紀錄', + allFiles: '所有檔案', + import: '匯入', + selectDirectory: '選擇目錄', + selectFolder: '選擇資料夾', + selectFolderError: '選擇資料夾時發生錯誤:', + }, + + // ===== P1: 資料庫遷移 ===== + database: { + migrationV1Desc: '在 meta 資料表新增 owner_id 欄位', + migrationV1Message: '支援「Owner」功能,可在成員清單中設定自己的身份', + migrationV2Desc: '新增 roles、reply_to_message_id、platform_message_id 欄位', + migrationV2Message: '支援成員角色、訊息回覆關係和回覆內容預覽', + migrationV3Desc: '新增會話索引相關資料表(segment、message_context)及 session_gap_threshold 欄位', + migrationV3Message: '支援會話時間軸瀏覽與 AI 增強分析功能', + migrationV4Desc: '建立 FTS5 全文搜尋索引(message_fts)並建構索引資料', + migrationV4Message: '支援全文搜尋,大幅提升關鍵詞搜尋速度', + migrationV5Desc: '修復舊版成員和訊息欄位', + migrationV5Message: '更新舊版資料庫欄位以相容目前版本', + migrationV6Desc: '將會話索引升級為 segment 結構', + migrationV6Message: '升級會話索引結構,並保留現有索引和摘要', + migrationV7Desc: '修復缺少的會話訊息關聯', + migrationV7Message: '修復會話索引中缺少的訊息關聯,並保留現有會話和摘要', + migrationV8Desc: '新增分析工具效能索引', + migrationV8Message: '為分析工具新增效能索引,提升查詢速度,不影響現有資料', + integrityError: '資料庫結構不完整:缺少 meta 資料表。建議刪除此資料庫檔案後重新匯入。', + checkFailed: '資料庫檢查失敗: {{error}}', + }, + + // ===== 工具系統 ===== + tools: { + notRegistered: '工具 "{{toolName}}" 未註冊', + }, + + // AI shared translations (from @openchatlab/node-runtime) + ...aiLocale, +} diff --git a/apps/desktop/main/import-source-runtime.test.ts b/apps/desktop/main/import-source-runtime.test.ts new file mode 100644 index 000000000..f3ecc6d1d --- /dev/null +++ b/apps/desktop/main/import-source-runtime.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, it } from 'node:test' +import { writeZipFixture } from '../../../packages/node-runtime/src/import/archive/test-utils' +import { ArchiveImportSourceManager } from '../../../packages/node-runtime/src/import/archive/source-manager' +import { importPreparedChatWithSource } from './import-source-runtime' + +function createTakeout(zipPath: string): void { + writeZipFixture(zipPath, [ + { + name: 'Takeout/Google Chat/Users/User sample/user_info.json', + content: JSON.stringify({ user: { email: 'owner@example.com', name: 'Owner' } }), + }, + { + name: 'Takeout/Google Chat/Groups/DM sample/group_info.json', + content: JSON.stringify({ + members: [ + { email: 'owner@example.com', name: 'Owner' }, + { email: 'other@example.com', name: 'Other User' }, + ], + }), + }, + { + name: 'Takeout/Google Chat/Groups/DM sample/messages.json', + content: JSON.stringify({ messages: [] }), + }, + ]) +} + +describe('desktop archive import runtime', () => { + it('passes an internal manifest to the importer and preserves the local ZIP', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-desktop-import-source-')) + try { + const zipPath = join(dir, 'takeout.zip') + createTakeout(zipPath) + const manager = new ArchiveImportSourceManager({ tempRoot: join(dir, 'temp') }) + const source = await manager.prepareLocalArchive(zipPath) + + const result = await importPreparedChatWithSource( + manager, + source.sourceId, + 'Groups/DM sample', + async (manifestPath) => { + assert.equal(JSON.parse(readFileSync(manifestPath, 'utf8')).chatId, 'Groups/DM sample') + return { success: true, sessionId: 'session-1' } + } + ) + + assert.deepEqual(result, { success: true, sessionId: 'session-1' }) + assert.equal(existsSync(zipPath), true) + await manager.close() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/desktop/main/import-source-runtime.ts b/apps/desktop/main/import-source-runtime.ts new file mode 100644 index 000000000..5c6253ad3 --- /dev/null +++ b/apps/desktop/main/import-source-runtime.ts @@ -0,0 +1,31 @@ +import * as os from 'node:os' +import * as path from 'node:path' +import * as fs from 'node:fs' +import { ArchiveImportSourceManager } from '../../../packages/node-runtime/src/import/archive/source-manager' + +let sourceManager: ArchiveImportSourceManager | null = null + +export function getArchiveImportSourceManager(): ArchiveImportSourceManager { + if (!sourceManager) { + sourceManager = new ArchiveImportSourceManager({ + tempRoot: fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-desktop-import-sources-')), + }) + } + return sourceManager +} + +export async function importPreparedChatWithSource( + manager: ArchiveImportSourceManager, + sourceId: string, + chatId: string, + importer: (manifestPath: string) => Promise +): Promise { + return manager.withMaterializedChat(sourceId, chatId, importer) +} + +export async function cleanupArchiveImportSources(): Promise { + if (!sourceManager) return + const manager = sourceManager + sourceManager = null + await manager.close() +} diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts new file mode 100644 index 000000000..7a8a6fcb9 --- /dev/null +++ b/apps/desktop/main/index.ts @@ -0,0 +1,442 @@ +import { app, shell, BrowserWindow, protocol, nativeTheme, dialog } from 'electron' +import { join } from 'path' +import { optimizer, is, platform } from '@electron-toolkit/utils' +import { checkUpdate } from './update' +import mainIpcMain, { cleanup } from './ipcMain' +import { startInternalServer, stopInternalServer, registerInternalApiIpc } from './internal-api' +import { getPathProvider } from './path-context' +import { initAnalytics } from './analytics' +import { logger } from './logger' +import { initProxy } from './network/proxy' +import { + needsLegacyMigration, + migrateFromLegacyDir, + ensureAppDirs, + cleanupPendingDeleteDir, + applyPendingDataDirMigration, + needsUnifiedDirMigration, + migrateToUnifiedDirs, + verifyDataPath, + getSystemDataDir, + getAiDataDir, +} from './paths' +import { migrateAllDatabases, checkMigrationNeeded } from './database/core' +import { + assertDesktopStartupMigrationSucceeded, + repairDesktopStartupCompatibilityGate, +} from './database/startup-migration' +import { initLocale } from './i18n' +import { MigrationRunner, ALL_MIGRATIONS } from '@openchatlab/config' +import { assertDesktopDataDirCompatible, getDesktopAppVersion } from './runtime-compat' +import type { RuntimeIdentity } from '@openchatlab/node-runtime/src/data-dir-compat' +import { + applyCurrentTitleBarOverlay, + getTitleBarOverlayOptions, + resetCurrentTitleBarOverlayColor, +} from './window-titlebar' + +type AppWithQuitFlag = typeof app & { isQuiting?: boolean } +// 统一通过扩展类型访问退出标记,避免使用 @ts-ignore。 +const appWithQuitFlag = app as AppWithQuitFlag + +class MainProcess { + mainWindow: BrowserWindow | null + isTestMode: boolean + constructor() { + // 主窗口 + this.mainWindow = null + + // E2E 测试模式检查:跳过遗留数据迁移和其他测试无关的初始化 + this.isTestMode = process.env.TEST_MODE === 'true' + + // E2E 测试隔离:为并行测试实例设置独立的用户数据目录 + // 这防止了并发进程的状态泄漏、死锁和数据库冲突 + const e2eUserDataDir = process.env.CHATLAB_E2E_USER_DATA_DIR + if (this.isTestMode && e2eUserDataDir) { + app.setPath('userData', e2eUserDataDir) + } else if (!this.isTestMode && e2eUserDataDir) { + console.warn('[Main] Ignored CHATLAB_E2E_USER_DATA_DIR because TEST_MODE is not enabled') + } + + // 设置应用程序名称 + if (process.platform === 'win32') app.setAppUserModelId(app.getName()) + // 初始化 + this.checkApp().then(async (lockObtained) => { + if (lockObtained) { + await this.init() + } + }) + } + + // 单例锁 + async checkApp() { + // E2E 测试模式:绕过单实例锁以支持并行实例 + const isTestMode = process.env.TEST_MODE === 'true' + if (isTestMode) { + return true + } + + if (!app.requestSingleInstanceLock()) { + app.quit() + // 未获得锁 + return false + } + // 聚焦到当前程序 + else { + app.on('second-instance', () => { + if (this.mainWindow) { + this.mainWindow.show() + if (this.mainWindow.isMinimized()) this.mainWindow.restore() + this.mainWindow.focus() + } + }) + // 获得锁 + return true + } + } + + // 初始化程序 + async init() { + initAnalytics() + logger.info('Desktop app starting') + + // E2E 测试模式:跳过遗留数据迁移 + // 遗留迁移会删除 Documents/ChatLab,在本地测试时可能破坏用户数据 + if (!this.isTestMode) { + // 应用上次设置页登记的数据目录切换;必须早于任何数据库连接初始化 + this.applyPendingDataDirMigrationIfNeeded() + + // 清理上次切换目录后的旧数据目录 + cleanupPendingDeleteDir() + + // 执行数据目录迁移(从 Documents/ChatLab 迁移到 userData) + this.migrateDataIfNeeded() + + // 执行统一目录结构迁移(Electron 旧布局 → 双根目录) + this.migrateToUnifiedDirsIfNeeded() + } + + // 验证数据路径是否正确(安全网:防止 config.toml 指向空目录) + verifyDataPath() + + // 确保应用目录存在 + ensureAppDirs() + + let runtime: RuntimeIdentity + try { + runtime = assertDesktopDataDirCompatible(getPathProvider(), getDesktopAppVersion(app.getVersion())) + } catch (error) { + console.error('[Main] Data directory compatibility check failed:', error) + dialog.showErrorBox( + 'ChatLab Data Directory Incompatible', + `ChatLab cannot open this data directory with the current desktop version.\n\n${ + error instanceof Error ? error.message : String(error) + }` + ) + app.quit() + return + } + + // 执行配置数据迁移(Migration Runner,Electron 和 CLI 共享) + await new MigrationRunner(ALL_MIGRATIONS, { + dataDir: getSystemDataDir(), + aiDataDir: getAiDataDir(), + logger: { + info: (_cat: string, msg: string) => console.log(`[Migration] ${msg}`), + warn: (_cat: string, msg: string) => console.warn(`[Migration] ${msg}`), + error: (_cat: string, msg: string, ...args: unknown[]) => console.error(`[Migration] ${msg}`, ...args), + }, + }).run() + + // 初始化主进程国际化(在 ensureAppDirs 之后,确保 settings 目录存在) + await initLocale() + + // 执行数据库 schema 迁移(确保所有数据库在 Worker 查询前已是最新 schema) + try { + this.migrateDatabasesIfNeeded(runtime) + } catch (error) { + console.error('[Main] Database schema migration failed:', error) + dialog.showErrorBox( + 'ChatLab Database Migration Failed', + `ChatLab cannot start because database migration did not complete safely.\n\n${ + error instanceof Error ? error.message : String(error) + }` + ) + app.quit() + return + } + + initProxy() // 初始化代理配置 + + // 暂不注册自定义协议,避免触发系统 URL 协议关联提示 + + // 应用程序准备好之前注册 + protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: { secure: true, standard: true } }]) + + // 主应用程序事件 + this.mainAppEvents() + } + + // 应用设置页登记的数据目录切换(重启期执行) + applyPendingDataDirMigrationIfNeeded() { + const result = applyPendingDataDirMigration() + if (result.skipped) { + console.log('[Main] No pending data directory migration') + return + } + if (result.success) { + console.log('[Main] Pending data directory migration completed') + } else { + console.error('[Main] Pending data directory migration failed:', result.error) + } + } + + // 从旧目录迁移数据(Documents/ChatLab → userData/data) + migrateDataIfNeeded() { + if (needsLegacyMigration()) { + console.log('[Main] Legacy data migration needed, starting migration...') + const result = migrateFromLegacyDir() + if (result.success) { + console.log(`[Main] Migration completed. Migrated: ${result.migratedDirs.join(', ')}`) + } else { + console.error('[Main] Migration failed:', result.error) + } + } else { + console.log('[Main] No legacy data migration needed') + } + } + + // 从 Electron 旧目录结构迁移到新的双根目录结构 + migrateToUnifiedDirsIfNeeded() { + if (needsUnifiedDirMigration()) { + console.log('[Main] Unified directory migration needed, starting...') + const result = migrateToUnifiedDirs() + if (result.success) { + console.log('[Main] Unified directory migration completed') + } else { + console.error('[Main] Unified directory migration failed:', result.error) + } + } else { + console.log('[Main] No unified directory migration needed') + } + } + + // 执行启动期数据库 schema 迁移;失败时必须中断,避免 schema 已升级但兼容门禁未落盘。 + migrateDatabasesIfNeeded(runtime: RuntimeIdentity) { + const { count } = checkMigrationNeeded() + if (count > 0) { + assertDesktopStartupMigrationSucceeded(migrateAllDatabases(runtime)) + } + + repairDesktopStartupCompatibilityGate(runtime, { pathProvider: getPathProvider() }) + } + + // 创建主窗口 + async createWindow() { + // 平台差异化窗口配置 + const windowOptions: Electron.BrowserWindowConstructorOptions = { + width: 1180, + height: 752, + minWidth: 1180, + minHeight: 752, + show: false, + autoHideMenuBar: true, + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false, + devTools: true, + }, + } + + // macOS: 使用 hiddenInset 保留红绿灯按钮 + // Windows: 使用 titleBarOverlay,在自定义标题栏区域右侧显示原生窗口按钮 + // Linux: 使用自定义标题栏和自定义按钮 + if (platform.isMacOS) { + windowOptions.titleBarStyle = 'hiddenInset' + } else if (platform.isWindows) { + // 保留系统框架,只隐藏标题栏内容,把内容区域顶到最上方 + windowOptions.titleBarStyle = 'hidden' + // 获取当前主题状态 + const isDark = nativeTheme.shouldUseDarkColors + windowOptions.titleBarOverlay = getTitleBarOverlayOptions(isDark) + windowOptions.backgroundColor = isDark ? '#111827' : '#f9fafb' + } else { + // Linux 继续使用无边框 + 自定义按钮 + windowOptions.frame = false + } + + this.mainWindow = new BrowserWindow(windowOptions) + + this.mainWindow.once('ready-to-show', () => { + this.mainWindow?.show() + + // Windows 上根据当前主题设置 titleBarOverlay 颜色 + if (platform.isWindows) { + applyCurrentTitleBarOverlay(this.mainWindow, nativeTheme.shouldUseDarkColors) + + // 监听主题变化,动态更新颜色 + nativeTheme.on('updated', () => { + if (this.mainWindow && platform.isWindows) { + resetCurrentTitleBarOverlayColor() + applyCurrentTitleBarOverlay(this.mainWindow, nativeTheme.shouldUseDarkColors) + } + }) + } + }) + + // 主窗口事件 + this.mainWindowEvents() + + this.mainWindow.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) + + // HMR for renderer base on electron-vite cli. + // Load the remote URL for development or the local html file for production. + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + this.mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + } else { + this.mainWindow.loadFile(join(__dirname, '../../out/renderer/index.html')) + } + } + + // 主应用程序事件 + mainAppEvents() { + app.whenReady().then(async () => { + console.log('[Main] App is ready') + // 设置Windows应用程序用户模型id + if (process.platform === 'win32') app.setAppUserModelId(app.getName()) + + // 启动 Internal API Server(硬依赖:失败则退出应用) + try { + await startInternalServer(getPathProvider()) + registerInternalApiIpc() + console.log('[Main] Internal API Server ready') + } catch (err) { + console.error('[Main] Internal API Server failed to start:', err) + dialog.showErrorBox( + 'ChatLab Internal Server Error', + `Internal API Server failed to start. The application cannot continue.\n\n${err instanceof Error ? err.message : String(err)}` + ) + app.quit() + return + } + + // 创建主窗口 + console.log('[Main] Creating window...') + await this.createWindow() + console.log('[Main] Window created') + + // 检查更新逻辑 + checkUpdate(this.mainWindow) + + // 引入主进程ipcMain + if (this.mainWindow) { + console.log('[Main] Registering IPC handlers...') + mainIpcMain(this.mainWindow) + console.log('[Main] IPC handlers registered') + } + + // 开发环境下 F12 打开控制台 + app.on('browser-window-created', (_, window) => { + optimizer.watchWindowShortcuts(window) + }) + + app.on('activate', () => { + // 在 macOS 上,当单击 Dock 图标且没有其他窗口时,通常会重新创建窗口 + if (BrowserWindow.getAllWindows().length === 0) { + this.createWindow() + return + } + + if (platform.isMacOS) { + this.mainWindow?.show() + } + }) + + // 监听渲染进程崩溃 + app.on('render-process-gone', (_event, w, d) => { + if (d.reason == 'crashed') { + w.reload() + } + // fs.appendFile(`./error-log-${+new Date()}.txt`, `${new Date()}渲染进程被杀死${d.reason}\n`) + }) + + // 自定义协议 + app.on('open-url', (_, url) => { + console.log('Received custom protocol URL:', url) + }) + + // 当所有窗口都关闭时退出应用,macOS 除外 + app.on('window-all-closed', () => { + if (!platform.isMacOS) { + app.quit() + } + }) + + // 只有显式调用quit才退出系统,区分MAC系统程序坞退出和点击X隐藏 + app.on('before-quit', () => { + appWithQuitFlag.isQuiting = true + }) + + // 退出前清理资源 + app.on('will-quit', () => { + stopInternalServer().catch(() => {}) + cleanup() + }) + }) + } + + // 主窗口事件 + mainWindowEvents() { + if (!this.mainWindow) { + return + } + this.mainWindow.webContents.on('did-finish-load', () => { + setTimeout(() => { + if (this.mainWindow) { + this.mainWindow.webContents.send('app-started') + } + }, 500) + }) + + this.mainWindow.on('maximize', () => { + this.mainWindow?.webContents.send('windowState', true) + }) + + this.mainWindow.on('unmaximize', () => { + this.mainWindow?.webContents.send('windowState', false) + }) + + // 窗口关闭 + this.mainWindow.on('close', (event) => { + if (platform.isMacOS) { + // macOS: 只有明确退出时才真正关闭,否则只隐藏窗口(符合 macOS 用户习惯) + if (!appWithQuitFlag.isQuiting) { + event.preventDefault() + this.mainWindow?.hide() + } + } + // Windows/Linux: 不阻止关闭,正常触发 window-all-closed → app.quit() → cleanup() + }) + } +} + +// 捕获未捕获的异常与未处理的 Promise 拒绝,落盘到 logs/app.log 便于排查 +process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error) + logger.error( + `Uncaught Exception: ${error instanceof Error ? `${error.message}\n${error.stack ?? ''}` : String(error)}` + ) + process.exit(1) +}) +process.on('unhandledRejection', (reason) => { + console.error('Unhandled Rejection:', reason) + logger.error( + `Unhandled Rejection: ${reason instanceof Error ? `${reason.message}\n${reason.stack ?? ''}` : String(reason)}` + ) + process.exit(1) +}) + +new MainProcess() diff --git a/apps/desktop/main/internal-api.ts b/apps/desktop/main/internal-api.ts new file mode 100644 index 000000000..b39a76ac0 --- /dev/null +++ b/apps/desktop/main/internal-api.ts @@ -0,0 +1,376 @@ +/** + * Electron Internal API Server + * + * Provides HTTP-based business communication for the Renderer process, + * reusing @openchatlab/http-routes shared routes with ephemeral auth. + * + * Completely isolated from the user-facing External API Server + * (apps/desktop/main/api/). Different port, different token, different lifecycle. + */ + +import * as fs from 'fs' +import * as path from 'path' +import { randomBytes, createHmac, timingSafeEqual } from 'crypto' +import { ipcMain } from 'electron' +import Fastify, { type FastifyInstance, type FastifyError, type FastifyRequest, type FastifyReply } from 'fastify' +import type { PathProvider } from '@openchatlab/core' +import { + DatabaseManager, + createDatabaseManagerAdapter, + LLMConfigStore, + CustomProviderStore, + CustomModelStore, + MergeSessionCache, + raiseChatDbCompatibilityGate, + streamingImport, + createSemanticIndexWorkerRuntimeClient, + appLogger, +} from '@openchatlab/node-runtime' +import type { StreamImportDeps, SemanticIndexRuntime } from '@openchatlab/node-runtime' +import { getLoadablePath as getSqliteVecLoadablePath } from 'sqlite-vec' +import multipart from '@fastify/multipart' +import type { ConfigStorage } from '@openchatlab/node-runtime' +import { + registerSharedRoutes, + ApiError, + ApiErrorCode, + apiErrorFromUnknown, + errorResponse, + serverError, +} from '@openchatlab/http-routes' +import type { HttpRouteContext } from '@openchatlab/http-routes' +import { resolveApiKey, writeAuthProfile, deleteAuthProfile } from '@openchatlab/config' +import { getManager as getAIChatManager } from './ai/chats' +import { getManager as getAssistantManager } from './ai/assistant/manager' +import { getManager as getSkillManager } from './ai/skills/manager' +import { createElectronRunAgentStream } from './ai/agent-stream-runner' +import { createExecuteElectronAiTool } from './ai/tools/debug-executor' +import { assertDesktopDataDirCompatible, getDesktopAppVersion } from './runtime-compat' +import { resolveModelDownloadProxyUrl } from './network/proxy' + +export interface InternalEndpoint { + baseUrl: string + token: string +} + +let server: FastifyInstance | null = null +let endpoint: InternalEndpoint | null = null +let dbManager: DatabaseManager | null = null +let mergeCache: MergeSessionCache | null = null +let semanticIndexService: SemanticIndexRuntime | null = null + +const JSON_BODY_LIMIT = 50 * 1024 * 1024 // 50 MB + +function createFileConfigStorage(aiDataDir: string): ConfigStorage { + return { + readJson(key: string): T | null { + try { + return JSON.parse(fs.readFileSync(path.join(aiDataDir, `${key}.json`), 'utf-8')) as T + } catch { + return null + } + }, + writeJson(key: string, data: T): void { + if (!fs.existsSync(aiDataDir)) fs.mkdirSync(aiDataDir, { recursive: true }) + fs.writeFileSync(path.join(aiDataDir, `${key}.json`), JSON.stringify(data, null, 2), 'utf-8') + }, + } +} + +/** + * Start the Internal API Server. + * Must be called before createWindow() so the Renderer can retrieve the endpoint. + */ +export async function startInternalServer(pathProvider: PathProvider): Promise { + if (server) return endpoint! + + let newServer: FastifyInstance | null = null + let newDbManager: DatabaseManager | null = null + let newSemanticIndexService: SemanticIndexRuntime | null = null + + try { + const token = `int_${randomBytes(32).toString('hex')}` + const { app } = await import('electron') + const runtime = assertDesktopDataDirCompatible(pathProvider, getDesktopAppVersion(app.getVersion())) + + newDbManager = new DatabaseManager(pathProvider, { runtime }) + const sessionAdapter = createDatabaseManagerAdapter(newDbManager) + + const aiDataDir = pathProvider.getAiDataDir() + const llmConfigStore = new LLMConfigStore(createFileConfigStorage(aiDataDir), { + resolveApiKey: (provider, authProfile) => resolveApiKey(provider, authProfile) || undefined, + onApiKeyCreated: (config, apiKey) => { + const profileName = config.name?.toLowerCase().replace(/\s+/g, '-') || config.provider + writeAuthProfile(profileName, { type: 'api_key', provider: config.provider, key: apiKey }) + return profileName + }, + onApiKeyDeleted: (config) => { + const profileName = (config as unknown as Record).authProfile as string | undefined + if (profileName) deleteAuthProfile(profileName) + }, + }) + + const configStorage = createFileConfigStorage(aiDataDir) + + const newMergeCache = new MergeSessionCache(pathProvider) + newMergeCache.cleanupOrphans() + + // 语义索引 worker client:启动 internal server 时不拉起 worker;状态/构建/检索按需 lazy start。 + try { + newSemanticIndexService = createSemanticIndexWorkerRuntimeClient({ + pathProvider, + runtime, + sqliteVecLoadablePath: getSqliteVecLoadablePath().replace('app.asar', 'app.asar.unpacked'), + getModelDownloadProxyUrl: resolveModelDownloadProxyUrl, + workerEntryUrl: import.meta.url.endsWith('.ts') + ? undefined + : new URL('./semantic-index-worker.js', import.meta.url), + }) + } catch (err) { + console.warn('[semantic-index] worker client unavailable:', err instanceof Error ? err.message : String(err)) + newSemanticIndexService = null + } + + const electronStreamImport = async (dm: DatabaseManager, filePath: string) => { + const deps: StreamImportDeps = { + openDatabase(sessionId: string) { + return dm.openRawSessionDatabase(sessionId, { create: true, initializeChatTables: true }) + }, + deleteDatabase(sessionId: string) { + dm.deleteSessionDatabaseFiles(sessionId) + }, + onProgress() { + /* no progress for merge-triggered import */ + }, + } + const result = await streamingImport(filePath, deps) + if (!result.sessionId) throw new Error('Import succeeded but no sessionId returned') + try { + raiseChatDbCompatibilityGate(pathProvider, runtime) + } catch (error) { + dm.deleteSessionDatabaseFiles(result.sessionId) + throw error + } + return { sessionId: result.sessionId } + } + + const { shell } = await import('electron') + const { getDefaultUserDataDir, getUserDataDir, getDownloadsDir } = await import('./paths') + + const ctx: HttpRouteContext = { + dbManager: newDbManager, + sessionAdapter, + pathProvider, + runtimeIdentity: runtime, + getVersion: () => getDesktopAppVersion(app.getVersion()), + mergeSessionCache: newMergeCache, + streamImport: electronStreamImport, + aiDataDir, + aiChatManager: getAIChatManager(), + assistantManager: getAssistantManager(), + skillManagerCore: getSkillManager(), + llmConfigStore, + customProviderStore: new CustomProviderStore(configStorage), + customModelStore: new CustomModelStore(configStorage), + semanticIndexService: newSemanticIndexService ?? undefined, + openDirectory: (dirPath) => shell.openPath(dirPath).then(() => {}), + showInFolder: (filePath) => { + shell.showItemInFolder(filePath) + return Promise.resolve() + }, + downloadsDir: getDownloadsDir(), + defaultUserDataDir: getDefaultUserDataDir(), + isCustomDataDir: path.resolve(getUserDataDir()) !== path.resolve(getDefaultUserDataDir()), + runAgentStream: createElectronRunAgentStream(newSemanticIndexService ?? undefined), + executeAiTool: createExecuteElectronAiTool(newSemanticIndexService ?? undefined), + } + + newServer = Fastify({ logger: false, bodyLimit: JSON_BODY_LIMIT }) + + await newServer.register(multipart, { limits: { fileSize: 1024 * 1024 * 1024 } }) + + // CORS: dev allows the Vite dev server origin; prod allows Electron app origins only + const isDev = !app.isPackaged + const devOrigin = process.env.ELECTRON_RENDERER_URL || 'http://localhost:13100' + const setCorsHeader = (reply: FastifyReply, name: string, value: string) => { + reply.header(name, value) + reply.raw.setHeader(name, value) + } + newServer.addHook('onRequest', (request, reply, done) => { + const origin = request.headers.origin + if (!origin) { + done() + return + } + + if (isDev) { + const isLoopbackOrigin = + origin.startsWith('http://localhost:') || + origin.startsWith('http://127.0.0.1:') || + origin.startsWith('http://[::1]:') + if (origin === devOrigin || isLoopbackOrigin) { + setCorsHeader(reply, 'Access-Control-Allow-Origin', origin) + } + } else if (origin === 'file://' || origin === 'app://' || origin === 'null') { + setCorsHeader(reply, 'Access-Control-Allow-Origin', origin) + } + + // SSE 路由会直接调用 reply.raw.writeHead(),因此 CORS 必须同时写到 raw response; + // 只写 Fastify reply header 时,流式响应会丢失跨域头并在浏览器侧表现为 Failed to fetch。 + setCorsHeader(reply, 'Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') + setCorsHeader(reply, 'Access-Control-Allow-Headers', 'Content-Type, Authorization') + + if (request.method === 'OPTIONS') { + reply.code(204).send() + return + } + done() + }) + + newServer.addHook('onRequest', createInternalAuthHook(token)) + + newServer.setErrorHandler((error: FastifyError, request, reply) => { + const apiError = apiErrorFromUnknown(error) + if (apiError) { + reply.code(apiError.statusCode).send(errorResponse(apiError)) + return + } + if (error.statusCode === 413) { + const bodyErr = new ApiError(ApiErrorCode.BODY_TOO_LARGE, 'Request body exceeds 50MB limit') + reply.code(413).send(errorResponse(bodyErr)) + return + } + const statusCode = (error as any).statusCode + if (statusCode && statusCode >= 400 && statusCode < 600) { + reply.code(statusCode).send({ success: false, error: { code: 'CLIENT_ERROR', message: error.message } }) + return + } + appLogger.error('http', `${request.method} ${request.url} -> 500`, error) + const err = serverError(error.message) + reply.code(err.statusCode).send(errorResponse(err)) + }) + + registerSharedRoutes(newServer, ctx, { requireAi: true }) + + await newServer.listen({ port: 0, host: '127.0.0.1' }) + + const address = newServer.server.address() + const port = typeof address === 'object' && address ? address.port : 0 + + server = newServer + dbManager = newDbManager + mergeCache = newMergeCache + semanticIndexService = newSemanticIndexService + endpoint = { baseUrl: `http://127.0.0.1:${port}`, token } + console.log(`[InternalAPI] Server started on port ${port}`) + + return endpoint + } catch (err) { + try { + await newServer?.close() + } catch { + /* best-effort */ + } + try { + newDbManager?.closeAll() + } catch { + /* best-effort */ + } + try { + await newSemanticIndexService?.close() + } catch { + /* best-effort */ + } + server = null + dbManager = null + mergeCache = null + semanticIndexService = null + endpoint = null + throw err + } +} + +export function getInternalEndpoint(): InternalEndpoint | null { + return endpoint +} + +/** Main-process DatabaseManager backing the internal server (null before startup). */ +export function getInternalDbManager(): DatabaseManager | null { + return dbManager +} + +/** Lazy semantic-index runtime backing the internal server (null before startup / unavailable). */ +export function getInternalSemanticIndexService(): SemanticIndexRuntime | null { + return semanticIndexService +} + +export async function stopInternalServer(): Promise { + if (!server) return + try { + await server.close() + } catch (err) { + console.error('[InternalAPI] Error closing server:', err) + } finally { + try { + mergeCache?.clear() + } catch { + /* best-effort */ + } + try { + dbManager?.closeAll() + } catch { + /* best-effort */ + } + try { + await semanticIndexService?.close() + } catch { + /* best-effort */ + } + server = null + endpoint = null + dbManager = null + mergeCache = null + semanticIndexService = null + console.log('[InternalAPI] Server stopped') + } +} + +/** + * Register IPC handler so the Renderer can retrieve the endpoint via preload. + * Must be called before createWindow(). + */ +export function registerInternalApiIpc(): void { + ipcMain.handle('internal-api:getEndpoint', () => getInternalEndpoint()) +} + +// ==================== Auth Hook (Internal Only) ==================== + +const hmacKey = randomBytes(32) + +function safeTokenCompare(a: string, b: string): boolean { + const hashA = createHmac('sha256', hmacKey).update(a).digest() + const hashB = createHmac('sha256', hmacKey).update(b).digest() + return timingSafeEqual(hashA, hashB) +} + +/** + * Auth hook for the Internal Server. ALL routes require Bearer token, no exceptions. + * Independent from @openchatlab/http-routes auth (which uses global state for External Server). + */ +function createInternalAuthHook(token: string) { + return async function internalAuthHook(request: FastifyRequest, reply: FastifyReply): Promise { + if (request.method === 'OPTIONS') return + + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + reply.code(401).send({ success: false, error: { code: 'UNAUTHORIZED', message: 'Missing or invalid token' } }) + return + } + + const provided = authHeader.slice(7) + if (!safeTokenCompare(provided, token)) { + reply.code(401).send({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } }) + return + } + } +} diff --git a/apps/desktop/main/ipc/ai.ts b/apps/desktop/main/ipc/ai.ts new file mode 100644 index 000000000..ce8fbb2e5 --- /dev/null +++ b/apps/desktop/main/ipc/ai.ts @@ -0,0 +1,79 @@ +/** + * AI IPC handlers — Electron-only subset + * + * Most AI functionality has been migrated to shared HTTP/SSE routes. + * This file retains only: debug mode toggle + native shell log file opening. + */ +import { ipcMain, shell } from 'electron' +import * as fs from 'fs' +import * as path from 'path' +import { aiLogger, setDebugMode } from '../ai/logger' +import { getLogsDir } from '../paths' +import * as assistantManager from '../ai/assistant' +import * as skillManager from '../ai/skills' +import type { IpcContext } from './types' + +export function registerAIHandlers(_ctx: IpcContext): void { + console.log('[IPC] Registering AI handlers...') + + try { + assistantManager.initAssistantManager() + console.log('[IPC] Assistant manager initialized') + } catch (error) { + console.error('[IPC] Failed to initialize assistant manager:', error) + } + + try { + skillManager.initSkillManager() + console.log('[IPC] Skill manager initialized') + } catch (error) { + console.error('[IPC] Failed to initialize skill manager:', error) + } + + // ==================== Debug 模式 ==================== + + ipcMain.on('app:setDebugMode', (_, enabled: boolean) => { + setDebugMode(enabled) + aiLogger.info('Config', `Debug mode ${enabled ? 'enabled' : 'disabled'}`) + }) + + // ==================== AI 日志 ==================== + + ipcMain.handle('ai:showLogFile', async () => { + try { + const existingLogPath = aiLogger.getExistingLogPath() + if (existingLogPath) { + shell.showItemInFolder(existingLogPath) + return { success: true, path: existingLogPath } + } + + const logDir = path.join(getLogsDir(), 'ai') + if (!fs.existsSync(logDir)) { + return { success: false, error: 'No AI log files found' } + } + + const logFiles = fs.readdirSync(logDir).filter((name) => name.startsWith('ai_') && name.endsWith('.log')) + + if (logFiles.length === 0) { + return { success: false, error: 'No AI log files found' } + } + + const latestLog = logFiles + .map((name) => { + const filePath = path.join(logDir, name) + const stat = fs.statSync(filePath) + return { path: filePath, mtimeMs: stat.mtimeMs } + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs)[0] + + shell.showItemInFolder(latestLog.path) + return { success: true, path: latestLog.path } + } catch (error) { + console.error('Failed to open AI log file:', error) + return { success: false, error: String(error) } + } + }) + + // Desensitize rules, LLM chat, estimateContextTokens, tool testing + // have all been migrated to shared HTTP routes. +} diff --git a/apps/desktop/main/ipc/api.ts b/apps/desktop/main/ipc/api.ts new file mode 100644 index 000000000..54ac297ed --- /dev/null +++ b/apps/desktop/main/ipc/api.ts @@ -0,0 +1,265 @@ +/** + * ChatLab API — IPC handlers for renderer process (hierarchical data source model) + * + * Migrated to @openchatlab/sync shared package. + */ + +import { ipcMain, net } from 'electron' +import type { IpcContext } from './types' +import * as apiServer from '../api' +import { setConfigManager } from '../api' +import { getSettingsDir, getSystemDataDir } from '../paths' +import { apiLogger } from '../api/logger' +import { getImportingStatus } from '../api/routes/import' +import { deleteSession as deleteSessionFile } from '../database/core' +import { getInternalDbManager } from '../internal-api' +import { PreferencesManager, createDatabaseManagerAdapter, ownerProfileService } from '@openchatlab/node-runtime' +import { ElectronFetcher, WorkerImporter, BrowserWindowNotifier } from '../api/adapters' +import { + ConfigManager, + DataSourceManager, + PullEngine, + initScheduler, + stopAllTimers, + stopTimer, + reloadTimer, + buildRemoteSessionsUrl, + parseRemoteSessionsResponse, + normalizeBaseUrl, +} from '@openchatlab/sync' +import type { DataSourceUpdatable, RemoteSessionDiscoveryQuery, RemoteSessionDiscoveryResult } from '@openchatlab/sync' + +const syncLogger = { + info: (msg: string) => apiLogger.info(msg), + warn: (msg: string) => apiLogger.warn(msg), + error: (msg: string, err?: unknown) => apiLogger.error(msg, err), +} + +let configManager: ConfigManager +let dsManager: DataSourceManager +let pullEngine: PullEngine + +function ensureInstances(): void { + if (configManager) return + + const settingsDir = getSettingsDir() + configManager = new ConfigManager(settingsDir, syncLogger) + dsManager = new DataSourceManager(settingsDir, syncLogger) + setConfigManager(configManager) + + pullEngine = new PullEngine({ + fetcher: new ElectronFetcher(), + importer: new WorkerImporter(syncLogger), + notifier: new BrowserWindowNotifier(), + dsManager, + logger: syncLogger, + isImporting: getImportingStatus, + onSessionImported: (localSessionId) => { + // Resolve lazily: the internal server (and its DatabaseManager) starts after IPC registration + const dbManager = getInternalDbManager() + if (!dbManager) return + const result = ownerProfileService.tryApplyOwnerProfile( + createDatabaseManagerAdapter(dbManager), + new PreferencesManager(getSystemDataDir()), + localSessionId + ) + if (result.applied) { + syncLogger.info(`[Pull] Applied owner profile to session ${localSessionId} (owner: ${result.ownerId})`) + } + }, + }) +} + +/** Exported for use by api/index.ts */ +export function getConfigManager(): ConfigManager { + ensureInstances() + return configManager +} + +function fetchRemoteSessions( + baseUrl: string, + token?: string, + query: RemoteSessionDiscoveryQuery = {} +): Promise { + return new Promise((resolve, reject) => { + const url = buildRemoteSessionsUrl(normalizeBaseUrl(baseUrl), query) + + const request = net.request(url) + if (token) { + request.setHeader('Authorization', `Bearer ${token}`) + } + request.setHeader('Accept', 'application/json') + + let body = '' + + request.on('response', (response) => { + if (response.statusCode !== 200) { + reject(new Error(`Remote server returned HTTP ${response.statusCode}`)) + return + } + + response.on('data', (chunk: Buffer) => { + body += chunk.toString('utf-8') + }) + + response.on('end', () => { + try { + resolve(parseRemoteSessionsResponse(body)) + } catch { + reject(new Error('Failed to parse remote sessions response')) + } + }) + + response.on('error', (err: Error) => { + reject(err) + }) + }) + + request.on('error', (err: Error) => { + reject(err) + }) + + request.end() + }) +} + +export function registerApiHandlers(_ctx: IpcContext): void { + ensureInstances() + + // ==================== API Server Management ==================== + + ipcMain.handle('api:getConfig', () => { + const config = configManager.load() + return { + enabled: config.enabled, + port: config.port, + token: config.token, + createdAt: config.createdAt, + } + }) + + ipcMain.handle('api:getStatus', () => { + return apiServer.getStatus() + }) + + ipcMain.handle('api:setEnabled', async (_event, enabled: boolean) => { + return apiServer.setEnabled(enabled) + }) + + ipcMain.handle('api:setPort', async (_event, port: number) => { + return apiServer.setPort(port) + }) + + ipcMain.handle('api:regenerateToken', () => { + return configManager.regenerateToken() + }) + + ipcMain.handle('api:updateConfig', (_event, partial: Record) => { + return configManager.update(partial as any) + }) + + // ==================== Data Source Management ==================== + + ipcMain.handle('api:getDataSources', () => { + return dsManager.loadAll() + }) + + ipcMain.handle( + 'api:addDataSource', + ( + _event, + partial: { name?: string; baseUrl: string; token: string; intervalMinutes: number; pullLimit?: number } + ) => { + return dsManager.add(partial) + } + ) + + ipcMain.handle('api:updateDataSource', (_event, id: string, updates: DataSourceUpdatable) => { + const ds = dsManager.update(id, updates) + if (ds) { + reloadTimer(ds.id) + } + return ds + }) + + ipcMain.handle('api:deleteDataSource', (_event, id: string) => { + stopTimer(id) + return dsManager.delete(id) + }) + + // ==================== Import Session Management ==================== + + ipcMain.handle( + 'api:addImportSessions', + (_event, sourceId: string, sessions: Array<{ name: string; remoteSessionId: string }>) => { + const added = dsManager.addSessions(sourceId, sessions) + reloadTimer(sourceId, true) + for (const sess of added) { + pullEngine.triggerPull(sourceId, sess.id).catch(() => {}) + } + return added + } + ) + + ipcMain.handle('api:removeImportSession', (_event, sourceId: string, sessionId: string, deleteData?: boolean) => { + const removed = dsManager.removeSession(sourceId, sessionId) + if (!removed) return false + reloadTimer(sourceId) + if (deleteData && removed.targetSessionId) { + deleteSessionFile(removed.targetSessionId) + } + return true + }) + + // ==================== Sync ==================== + + ipcMain.handle('api:triggerPull', async (_event, sourceId: string, sessionId?: string) => { + return pullEngine.triggerPull(sourceId, sessionId) + }) + + ipcMain.handle('api:triggerPullAll', async (_event, sourceId: string) => { + return pullEngine.triggerPullAll(sourceId) + }) + + // ==================== Remote Discovery ==================== + + ipcMain.handle( + 'api:fetchRemoteSessions', + async (_event, baseUrl: string, token: string, query?: { keyword?: string; limit?: number; cursor?: string }) => { + try { + return await fetchRemoteSessions(baseUrl, token || undefined, query) + } catch (err: any) { + throw new Error(err.message || 'Failed to fetch remote sessions') + } + } + ) +} + +/** + * Auto-start API server and Pull scheduler after app launch + */ +export async function initApiServer(ctx: IpcContext): Promise { + ensureInstances() + + await apiServer.autoStart() + + const status = apiServer.getStatus() + if (status.error) { + ctx.win.webContents.once('did-finish-load', () => { + ctx.win.webContents.send('api:startupError', { + error: status.error, + }) + }) + } + + initScheduler({ + dsManager, + pullEngine, + logger: syncLogger, + }) +} + +export async function cleanupApiServer(): Promise { + stopAllTimers() + await apiServer.stop() +} diff --git a/apps/desktop/main/ipc/cache.ts b/apps/desktop/main/ipc/cache.ts new file mode 100644 index 000000000..664908cf3 --- /dev/null +++ b/apps/desktop/main/ipc/cache.ts @@ -0,0 +1,68 @@ +/** + * Cache IPC handlers — IPC-only subset + * + * Most cache operations (getInfo, clear, openDir, saveToDownloads, etc.) + * have been migrated to HTTP shared routes (packages/http-routes). + * Only selectDataDir and setDataDir remain on IPC because they require + * native Electron dialogs (showOpenDialog) and app-level config mutations. + */ +import { ipcMain, dialog, app } from 'electron' +import type { IpcContext } from './types' +import { getUserDataDir, setCustomDataDir, ensureAppDirs } from '../paths' +import { isInsideAppInstallDir } from '../utils/pathUtils' + +export function registerCacheHandlers(_context: IpcContext): void { + console.log('[IPC] Registering cache handlers (IPC-only subset)...') + + ipcMain.handle('cache:selectDataDir', async () => { + try { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory', 'createDirectory'], + defaultPath: getUserDataDir(), + title: '选择数据目录', + buttonLabel: '选择', + }) + + if (result.canceled || result.filePaths.length === 0) { + return { success: false } + } + + const selectedPath = result.filePaths[0] + + try { + const exePath = app.getPath('exe') + if (isInsideAppInstallDir(selectedPath, exePath)) { + return { success: false, error: 'INSTALL_DIR_FORBIDDEN' } + } + } catch { + // exe path unavailable, skip check + } + + return { success: true, path: selectedPath } + } catch (error) { + console.error('[Cache] Error selecting data dir:', error) + return { success: false, error: String(error) } + } + }) + + ipcMain.handle('cache:setDataDir', async (_, payload: { path?: string | null; migrate?: boolean }) => { + const targetPath = typeof payload?.path === 'string' ? payload.path : null + const migrate = payload?.migrate !== false + + const result = setCustomDataDir(targetPath, migrate) + if (!result.success) { + return { success: false, error: result.error } + } + + if (result.requiresRelaunch === false) { + ensureAppDirs() + } + + return { + success: true, + from: result.from, + to: result.to, + requiresRelaunch: result.requiresRelaunch, + } + }) +} diff --git a/apps/desktop/main/ipc/chat.ts b/apps/desktop/main/ipc/chat.ts new file mode 100644 index 000000000..7d6d536dd --- /dev/null +++ b/apps/desktop/main/ipc/chat.ts @@ -0,0 +1,301 @@ +/** + * 聊天记录导入、迁移与摘要 IPC 处理器 + * + * 数据查询/分析/成员管理/SQL/会话索引等业务已迁移到 + * Internal HTTP Server (@openchatlab/http-routes)。 + * 本文件仅保留:数据库迁移、文件导入、摘要生成、临时导出。 + */ + +import { ipcMain, app, dialog } from 'electron' +import * as databaseCore from '../database/core' +import * as worker from '../worker/workerManager' +import { detectFormat, findEntryFileInDirectory, scanMultiChatFile, type ParseProgress } from '../parser' +import * as parser from '../parser' +import type { IpcContext } from './types' +import { CURRENT_SCHEMA_VERSION, getPendingMigrationInfos } from '../database/migrations' +import { t } from '../i18n' +import { getArchiveImportSourceManager, importPreparedChatWithSource } from '../import-source-runtime' + +export function registerChatHandlers(ctx: IpcContext): void { + const { win } = ctx + + // ==================== 数据库迁移 ==================== + + ipcMain.handle('chat:checkMigration', async () => { + try { + const result = databaseCore.checkMigrationNeeded() + const pendingMigrations = getPendingMigrationInfos(result.lowestVersion) + return { + needsMigration: result.count > 0, + count: result.count, + currentVersion: CURRENT_SCHEMA_VERSION, + pendingMigrations, + } + } catch (error) { + console.error('[IpcMain] Migration check failed:', error) + return { needsMigration: false, count: 0, currentVersion: CURRENT_SCHEMA_VERSION, pendingMigrations: [] } + } + }) + + ipcMain.handle('chat:runMigration', async () => { + try { + return databaseCore.migrateAllDatabases() + } catch (error) { + console.error('[IpcMain] Migration execution failed:', error) + return { success: false, migratedCount: 0, error: String(error) } + } + }) + + // ==================== 文件选择与格式检测 ==================== + + ipcMain.handle('chat:selectFile', async () => { + try { + const { canceled, filePaths } = await dialog.showOpenDialog({ + title: t('dialog.selectChatFile'), + defaultPath: app.getPath('documents'), + properties: ['openFile'], + filters: [ + { name: t('dialog.chatRecords'), extensions: ['json', 'jsonl', 'txt'] }, + { name: t('dialog.allFiles'), extensions: ['*'] }, + ], + buttonLabel: t('dialog.import'), + }) + + if (canceled || filePaths.length === 0) return null + + const filePath = filePaths[0] + const formatFeature = detectFormat(filePath) + const format = formatFeature?.name || null + if (!format) return { error: 'error.unrecognized_format' } + + return { filePath, format } + } catch (error) { + console.error('[IpcMain] Error selecting file:', error) + return { error: String(error) } + } + }) + + ipcMain.handle('chat:detectFormat', async (_, filePath: string) => { + try { + const formatFeature = detectFormat(filePath) + if (!formatFeature) return null + return { + id: formatFeature.id, + name: formatFeature.name, + platform: formatFeature.platform, + multiChat: formatFeature.multiChat || false, + } + } catch { + return null + } + }) + + ipcMain.handle('chat:scanMultiChatFile', async (_, filePath: string) => { + try { + const chats = await scanMultiChatFile(filePath) + return { success: true, chats } + } catch (error) { + console.error('[IpcMain] Failed to scan multi-chat files:', error) + return { success: false, error: String(error), chats: [] } + } + }) + + ipcMain.handle('chat:getSupportedFormats', async () => { + return parser.getSupportedFormats() + }) + + ipcMain.handle('chat:prepareImportSource', async (_, filePath: string) => { + try { + const source = await getArchiveImportSourceManager().prepareLocalArchive(filePath) + return { success: true, source } + } catch (error) { + return { + success: false, + error: + error && typeof error === 'object' && 'code' in error + ? String(error.code) + : error instanceof Error + ? error.message + : String(error), + } + } + }) + + ipcMain.handle('chat:importPreparedChat', async (_, sourceId: string, chatId: string) => { + try { + const result = await importPreparedChatWithSource( + getArchiveImportSourceManager(), + sourceId, + chatId, + (manifestPath) => + worker.streamImport( + manifestPath, + (progress: ParseProgress) => { + win.webContents.send('chat:importProgress', { + stage: progress.stage, + progress: progress.percentage, + message: progress.message, + bytesRead: progress.bytesRead, + totalBytes: progress.totalBytes, + messagesProcessed: progress.messagesProcessed, + }) + }, + { formatId: 'google-chat-takeout' } + ) + ) + return { + success: result.success, + sessionId: result.sessionId, + error: result.error, + diagnostics: result.diagnostics, + } + } catch (error) { + return { + success: false, + error: + error && typeof error === 'object' && 'code' in error + ? String(error.code) + : error instanceof Error + ? error.message + : String(error), + } + } + }) + + ipcMain.handle('chat:releaseImportSource', async (_, sourceId: string) => { + await getArchiveImportSourceManager().release(sourceId) + return { success: true } + }) + + // ==================== 导入 ==================== + + ipcMain.handle('chat:import', async (_, filePath: string) => { + try { + win.webContents.send('chat:importProgress', { stage: 'detecting', progress: 5, message: '' }) + + const result = await worker.streamImport(filePath, (progress: ParseProgress) => { + win.webContents.send('chat:importProgress', { + stage: progress.stage, + progress: progress.percentage, + message: progress.message, + bytesRead: progress.bytesRead, + totalBytes: progress.totalBytes, + messagesProcessed: progress.messagesProcessed, + }) + }) + + if (result.success) { + return { success: true, sessionId: result.sessionId, diagnostics: result.diagnostics } + } else { + win.webContents.send('chat:importProgress', { stage: 'error', progress: 0, message: result.error }) + return { success: false, error: result.error, diagnostics: result.diagnostics } + } + } catch (error) { + win.webContents.send('chat:importProgress', { stage: 'error', progress: 0, message: String(error) }) + return { success: false, error: String(error) } + } + }) + + ipcMain.handle('chat:importDirectory', async (_, dirPath: string) => { + try { + const entryPath = findEntryFileInDirectory(dirPath) + if (!entryPath) return { success: false, error: 'No recognizable import format found in directory' } + + win.webContents.send('chat:importProgress', { stage: 'detecting', progress: 5, message: '' }) + + const result = await worker.streamImport(entryPath, (progress: ParseProgress) => { + win.webContents.send('chat:importProgress', { + stage: progress.stage, + progress: progress.percentage, + message: progress.message, + bytesRead: progress.bytesRead, + totalBytes: progress.totalBytes, + messagesProcessed: progress.messagesProcessed, + }) + }) + + if (result.success) { + return { success: true, sessionId: result.sessionId, diagnostics: result.diagnostics } + } else { + win.webContents.send('chat:importProgress', { stage: 'error', progress: 0, message: result.error }) + return { success: false, error: result.error, diagnostics: result.diagnostics } + } + } catch (error) { + win.webContents.send('chat:importProgress', { stage: 'error', progress: 0, message: String(error) }) + return { success: false, error: String(error) } + } + }) + + ipcMain.handle('chat:importWithOptions', async (_, filePath: string, formatOptions: Record) => { + try { + win.webContents.send('chat:importProgress', { stage: 'detecting', progress: 5, message: '' }) + + const result = await worker.streamImport( + filePath, + (progress: ParseProgress) => { + win.webContents.send('chat:importProgress', { + stage: progress.stage, + progress: progress.percentage, + message: progress.message, + bytesRead: progress.bytesRead, + totalBytes: progress.totalBytes, + messagesProcessed: progress.messagesProcessed, + }) + }, + formatOptions + ) + + if (result.success) { + return { success: true, sessionId: result.sessionId, diagnostics: result.diagnostics } + } else { + win.webContents.send('chat:importProgress', { stage: 'error', progress: 0, message: result.error }) + return { success: false, error: result.error, diagnostics: result.diagnostics } + } + } catch (error) { + win.webContents.send('chat:importProgress', { stage: 'error', progress: 0, message: String(error) }) + return { success: false, error: String(error) } + } + }) + + // ==================== 增量导入 ==================== + + ipcMain.handle('chat:analyzeIncrementalImport', async (_, sessionId: string, filePath: string) => { + try { + const formatFeature = detectFormat(filePath) + if (!formatFeature) return { error: 'error.unrecognized_format' } + return await worker.analyzeIncrementalImport(sessionId, filePath) + } catch (error) { + console.error('[IpcMain] Failed to analyze incremental import:', error) + return { error: String(error) } + } + }) + + ipcMain.handle('chat:incrementalImport', async (_, sessionId: string, filePath: string) => { + try { + win.webContents.send('chat:importProgress', { stage: 'saving', progress: 0, message: '' }) + + const result = await worker.incrementalImport(sessionId, filePath, (progress) => { + win.webContents.send('chat:importProgress', { + stage: progress.stage, + progress: progress.percentage, + message: progress.message, + }) + }) + + if (result.success) { + // 分析缓存按 DB 文件版本自动失效,无需手动清理 + // 通知渲染进程刷新会话列表(与 API 路由的 notifySessionListChanged 保持一致) + win.webContents.send('api:importCompleted') + } + + return result + } catch (error) { + console.error('[IpcMain] Failed to execute incremental import:', error) + return { success: false, error: String(error) } + } + }) + + // Session index and summary IPC handlers have been removed. + // All session-index operations now go through shared HTTP routes + // (FetchSessionIndexAdapter via /_web/sessions/* endpoints). +} diff --git a/apps/desktop/main/ipc/demo.ts b/apps/desktop/main/ipc/demo.ts new file mode 100644 index 000000000..ee3b521ea --- /dev/null +++ b/apps/desktop/main/ipc/demo.ts @@ -0,0 +1,130 @@ +/** + * Demo 示例数据下载与导入 IPC 处理器 + */ + +import { ipcMain, app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' +import * as worker from '../worker/workerManager' +import type { IpcContext } from './types' + +const DEMO_BASE_URL = 'https://chatlab.fun/assets/demo' + +interface DemoProgress { + stage: 'downloading' | 'importing' | 'done' | 'error' + /** 当前处理的文件序号 (1-based) */ + current: number + total: number + message?: string +} + +function getDemoTempDir(): string { + const tempDir = path.join(app.getPath('userData'), 'temp', 'demo') + fs.mkdirSync(tempDir, { recursive: true }) + return tempDir +} + +async function downloadFile(url: string, destPath: string): Promise { + const tmpPath = destPath + '.tmp' + const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }) + if (!response.ok) { + throw new Error(`Download failed: HTTP ${response.status}`) + } + + const buffer = Buffer.from(await response.arrayBuffer()) + if (buffer.length < 100) { + throw new Error(`Downloaded file too small (${buffer.length} bytes)`) + } + + fs.writeFileSync(tmpPath, buffer) + fs.renameSync(tmpPath, destPath) +} + +function cleanupDemoTemp(tempDir: string): void { + try { + if (fs.existsSync(tempDir)) { + for (const file of fs.readdirSync(tempDir)) { + fs.unlinkSync(path.join(tempDir, file)) + } + fs.rmdirSync(tempDir) + } + } catch { + // best-effort cleanup + } +} + +export function registerDemoHandlers(ctx: IpcContext): void { + const { win } = ctx + + /** + * 下载并导入 Demo 示例数据 + * 返回群聊和私聊的 sessionId + */ + const DEMO_FILES = [ + 'demo-group.json', + 'demo-private-A-cuilan.json', + 'demo-private-B-wukong.json', + 'demo-private-C-spider.json', + ] + + ipcMain.handle( + 'demo:downloadAndImport', + async ( + _, + locale: string + ): Promise<{ + success: boolean + groupSessionId?: string + privateSessionIds?: string[] + error?: string + }> => { + const tempDir = getDemoTempDir() + const total = DEMO_FILES.length + + const sendProgress = (progress: DemoProgress) => { + win.webContents.send('demo:progress', progress) + } + + try { + const localPaths: string[] = [] + for (let i = 0; i < total; i++) { + sendProgress({ stage: 'downloading', current: i + 1, total }) + const localPath = path.join(tempDir, DEMO_FILES[i]) + await downloadFile(`${DEMO_BASE_URL}/${locale}/${DEMO_FILES[i]}`, localPath) + localPaths.push(localPath) + } + + sendProgress({ stage: 'importing', current: 1, total }) + const groupResult = await worker.streamImport(localPaths[0]) + if (!groupResult.success || !groupResult.sessionId) { + throw new Error(groupResult.error || 'Failed to import group demo') + } + + const privateSessionIds: string[] = [] + for (let i = 1; i < localPaths.length; i++) { + sendProgress({ stage: 'importing', current: i + 1, total }) + const result = await worker.streamImport(localPaths[i]) + if (!result.success || !result.sessionId) { + throw new Error(result.error || `Failed to import private demo: ${DEMO_FILES[i]}`) + } + privateSessionIds.push(result.sessionId) + } + + sendProgress({ stage: 'done', current: total, total }) + + return { + success: true, + groupSessionId: groupResult.sessionId, + privateSessionIds, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error('[Demo] Download and import failed:', message) + sendProgress({ stage: 'error', current: 0, total, message }) + return { success: false, error: message } + } finally { + cleanupDemoTemp(tempDir) + } + } + ) +} diff --git a/apps/desktop/main/ipc/messages.ts b/apps/desktop/main/ipc/messages.ts new file mode 100644 index 000000000..8a1f54c87 --- /dev/null +++ b/apps/desktop/main/ipc/messages.ts @@ -0,0 +1,30 @@ +/** + * 消息导出 IPC 处理器 + */ + +import { ipcMain } from 'electron' +import type { IpcContext } from './types' +import * as worker from '../worker/workerManager' + +export function registerMessagesHandlers(_ctx: IpcContext): void { + ipcMain.handle( + 'ai:exportFilterResultToFile', + async ( + _, + params: { + sessionId: string + sessionName: string + outputDir: string + format?: 'txt' | 'json' | 'markdown' + timeFilter?: { startTs: number; endTs: number } + } + ) => { + try { + return await worker.exportFilterResultToFile(params) + } catch (error) { + console.error('Failed to export filtered results:', error) + return { success: false, error: String(error) } + } + } + ) +} diff --git a/electron/main/ipc/network.ts b/apps/desktop/main/ipc/network.ts similarity index 84% rename from electron/main/ipc/network.ts rename to apps/desktop/main/ipc/network.ts index 1daec17a9..80a70e042 100644 --- a/electron/main/ipc/network.ts +++ b/apps/desktop/main/ipc/network.ts @@ -31,17 +31,17 @@ export function registerNetworkHandlers(_context: IpcContext): void { */ ipcMain.handle( 'network:saveProxyConfig', - (_event, config: ProxyConfig): { success: boolean; error?: string } => { + async (_event, config: ProxyConfig): Promise<{ success: boolean; error?: string }> => { try { - // 如果启用了代理,验证 URL 格式 - if (config.enabled && config.url) { + // 如果是手动模式且填写了 URL,验证 URL 格式 + if (config.mode === 'manual' && config.url) { const validation = validateProxyUrl(config.url) if (!validation.valid) { return { success: false, error: validation.error } } } - saveProxyConfig(config) + await saveProxyConfig(config) return { success: true } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) @@ -62,4 +62,3 @@ export function registerNetworkHandlers(_context: IpcContext): void { console.log('[IpcMain] Network handlers registered') } - diff --git a/electron/main/ipc/types.ts b/apps/desktop/main/ipc/types.ts similarity index 100% rename from electron/main/ipc/types.ts rename to apps/desktop/main/ipc/types.ts diff --git a/apps/desktop/main/ipc/window.ts b/apps/desktop/main/ipc/window.ts new file mode 100644 index 000000000..b45ee9401 --- /dev/null +++ b/apps/desktop/main/ipc/window.ts @@ -0,0 +1,326 @@ +/** + * 窗口和文件系统操作 IPC 处理器 + */ + +import { ipcMain, app, dialog, clipboard, shell, nativeTheme } from 'electron' +import * as fs from 'fs/promises' +import type { IpcContext } from './types' +import { simulateUpdateDialog, manualCheckForUpdates } from '../update' +import { t } from '../i18n' +import { + applyCurrentTitleBarOverlay, + applyTitleBarOverlayColor, + resetCurrentTitleBarOverlayColor, +} from '../window-titlebar' +import { getDesktopAppVersion } from '../runtime-compat' + +type AppWithQuitFlag = typeof app & { isQuiting?: boolean } +// 通过类型扩展记录应用退出意图,避免使用 @ts-ignore。 +const appWithQuitFlag = app as AppWithQuitFlag + +const REMOTE_CONFIG_ALLOWED_DOMAINS = ['chatlab.fun', '1app.top'] +const REMOTE_CONFIG_TIMEOUT_MS = 8000 +const REMOTE_CONFIG_MAX_BYTES = 1024 * 1024 // 1MB + +function isAllowedRemoteConfigUrl(rawUrl: string): boolean { + let parsed: URL + try { + parsed = new URL(rawUrl) + } catch { + return false + } + + if (parsed.protocol !== 'https:') return false + if (parsed.username || parsed.password) return false + if (parsed.port && parsed.port !== '443') return false + + const hostname = parsed.hostname.toLowerCase() + return REMOTE_CONFIG_ALLOWED_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)) +} + +/** + * 注册窗口和文件系统操作 IPC 处理器 + */ +export function registerWindowHandlers(ctx: IpcContext): void { + const { win } = ctx + + // ==================== 窗口操作 ==================== + ipcMain.on('window-min', (ev) => { + ev.preventDefault() + win.minimize() + }) + + ipcMain.on('window-maxOrRestore', (ev) => { + const winSizeState = win.isMaximized() + if (winSizeState) { + win.restore() + } else { + win.maximize() + } + ev.reply('windowState', win.isMaximized()) + }) + + ipcMain.on('window-restore', () => { + win.restore() + }) + + ipcMain.on('window-hide', () => { + win.hide() + }) + + ipcMain.on('window-close', () => { + win.close() + appWithQuitFlag.isQuiting = true + app.quit() + }) + + ipcMain.on('window-resize', (_, data) => { + if (data.resize) { + win.setResizable(true) + } else { + win.setSize(1180, 752) + win.setResizable(false) + } + }) + + ipcMain.on('open-devtools', () => { + win.webContents.openDevTools() + }) + + // 设置主题模式 + ipcMain.on('window:setThemeSource', (_, mode: 'system' | 'light' | 'dark') => { + nativeTheme.themeSource = mode + + // Windows 上动态更新 overlay 颜色以匹配主题 + if (process.platform === 'win32' && win) { + resetCurrentTitleBarOverlayColor() + applyCurrentTitleBarOverlay(win, nativeTheme.shouldUseDarkColors) + } + }) + + ipcMain.on('window:setTitleBarOverlayColor', (_, color: string) => { + if (!/^#[0-9a-f]{6}$/i.test(color)) return + + if (process.platform === 'win32' && win) { + applyTitleBarOverlayColor(win, color) + } + }) + + // ==================== 应用信息 ==================== + ipcMain.handle('app:getVersion', () => { + return getDesktopAppVersion(app.getVersion()) + }) + + // 重启应用 + ipcMain.handle('app:relaunch', () => { + app.relaunch() + app.quit() + }) + + // 获取远程配置(支持 JSON 和纯文本/Markdown) + ipcMain.handle('app:fetchRemoteConfig', async (_, url: string) => { + const normalizedUrl = typeof url === 'string' ? url.trim() : '' + if (!isAllowedRemoteConfigUrl(normalizedUrl)) { + return { success: false, error: 'URL is not allowed' } + } + + const abortController = new AbortController() + const timeout = setTimeout(() => abortController.abort(), REMOTE_CONFIG_TIMEOUT_MS) + + try { + // 使用 manual 重定向模式,手动验证每个重定向目标 + let currentUrl = normalizedUrl + let response = await fetch(currentUrl, { + signal: abortController.signal, + redirect: 'manual', + }) + + // 处理重定向链(最多跟随3次重定向,避免无限循环) + let redirectCount = 0 + const maxRedirects = 3 + + while (response.status >= 300 && response.status < 400 && redirectCount < maxRedirects) { + redirectCount++ + + const location = response.headers.get('location') + if (!location) { + return { success: false, error: `Redirect response without location header (hop ${redirectCount})` } + } + + // 构建完整的重定向 URL + const redirectUrl = new URL(location, currentUrl).href + if (!isAllowedRemoteConfigUrl(redirectUrl)) { + return { success: false, error: `Redirect URL is not allowed (hop ${redirectCount}): ${redirectUrl}` } + } + + // 跟随重定向 + currentUrl = redirectUrl + response = await fetch(currentUrl, { + signal: abortController.signal, + redirect: 'manual', + }) + } + + // 检查是否超过最大重定向次数(严格大于,允许恰好等于最大次数) + if (redirectCount > maxRedirects) { + return { success: false, error: `Too many redirects (exceeded ${maxRedirects})` } + } + + // 验证最终响应的 URL + const finalUrl = response.url || currentUrl + if (!isAllowedRemoteConfigUrl(finalUrl)) { + return { success: false, error: 'Final URL is not allowed' } + } + + const contentType = response.headers.get('content-type') || '' + const contentLength = Number(response.headers.get('content-length') || 0) + + if (Number.isFinite(contentLength) && contentLength > REMOTE_CONFIG_MAX_BYTES) { + return { success: false, error: 'Response is too large' } + } + + if (!response.ok) { + return { success: false, error: `HTTP ${response.status}: ${response.statusText}` } + } + + const buffer = Buffer.from(await response.arrayBuffer()) + if (buffer.length > REMOTE_CONFIG_MAX_BYTES) { + return { success: false, error: 'Response is too large' } + } + + // 根据 Content-Type 或 URL 后缀决定解析方式 + const isJson = contentType.includes('application/json') || finalUrl.endsWith('.json') + + if (isJson) { + const data = JSON.parse(buffer.toString('utf-8')) + return { success: true, data } + } else { + // 纯文本/Markdown 等其他格式 + const data = buffer.toString('utf-8') + return { success: true, data } + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { success: false, error: 'Request timeout' } + } + return { success: false, error: String(error) } + } finally { + clearTimeout(timeout) + } + }) + + // ==================== 开机自启动 ==================== + ipcMain.handle('app:getOpenAtLogin', () => { + if (!app.isPackaged) return false + const { openAtLogin } = app.getLoginItemSettings() + return openAtLogin + }) + + ipcMain.handle('app:setOpenAtLogin', (_, enabled: boolean) => { + if (!app.isPackaged) return { success: false, error: 'Not available in dev mode' } + try { + app.setLoginItemSettings({ openAtLogin: enabled }) + return { success: true } + } catch (error) { + return { success: false, error: String(error) } + } + }) + + // ==================== 更新检查 ==================== + ipcMain.on('check-update', () => { + // 手动检查更新(即使是预发布版本也会提示) + manualCheckForUpdates() + }) + + // 模拟更新弹窗(仅开发模式使用) + ipcMain.on('simulate-update', () => { + if (!app.isPackaged) { + simulateUpdateDialog(win) + } + }) + + // ==================== 通用工具 ==================== + ipcMain.handle('show-message', (event, args) => { + event.sender.send('show-message', args) + }) + + // 复制到剪贴板(文本) + ipcMain.handle('copyData', async (_, data) => { + try { + clipboard.writeText(data) + return true + } catch (error) { + console.error('Copy operation error:', error) + return false + } + }) + + // 复制图片到剪贴板(base64 data URL) + ipcMain.handle('copyImage', async (_, dataUrl: string) => { + try { + // 从 data URL 中提取 base64 数据 + const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '') + const imageBuffer = Buffer.from(base64Data, 'base64') + // 使用 nativeImage 创建图片并写入剪贴板 + const { nativeImage } = await import('electron') + const image = nativeImage.createFromBuffer(imageBuffer) + clipboard.writeImage(image) + return { success: true } + } catch (error) { + console.error('Image copy error:', error) + return { success: false, error: String(error) } + } + }) + + // ==================== 文件系统操作 ==================== + // 选择文件夹 + ipcMain.handle('selectDir', async (_, defaultPath = '') => { + try { + const { canceled, filePaths } = await dialog.showOpenDialog({ + title: t('dialog.selectDirectory'), + defaultPath: defaultPath || app.getPath('documents'), + properties: ['openDirectory', 'createDirectory'], + buttonLabel: t('dialog.selectFolder'), + }) + if (!canceled) { + return filePaths[0] + } + return null + } catch (err) { + console.error(t('dialog.selectFolderError'), err) + throw err + } + }) + + // 检查文件是否存在 + ipcMain.handle('checkFileExist', async (_, filePath) => { + try { + await fs.access(filePath) + return true + } catch { + return false + } + }) + + // 在文件管理器中打开 + ipcMain.handle('openInFolder', async (_, path) => { + try { + await fs.access(path) + await shell.showItemInFolder(path) + return true + } catch (error) { + console.error('Error opening directory:', error) + return false + } + }) + + // 显示打开对话框(通用) + ipcMain.handle('dialog:showOpenDialog', async (_, options) => { + try { + return await dialog.showOpenDialog(options) + } catch (error) { + console.error('Failed to show dialog:', error) + throw error + } + }) +} diff --git a/electron/main/ipcMain.ts b/apps/desktop/main/ipcMain.ts similarity index 55% rename from electron/main/ipcMain.ts rename to apps/desktop/main/ipcMain.ts index 4463c3960..2d8bca808 100644 --- a/electron/main/ipcMain.ts +++ b/apps/desktop/main/ipcMain.ts @@ -8,12 +8,14 @@ import type { IpcContext } from './ipc/types' // 导入各功能模块 import { registerWindowHandlers } from './ipc/window' import { registerChatHandlers } from './ipc/chat' -import { registerMergeHandlers, initMergeModule } from './ipc/merge' import { registerAIHandlers } from './ipc/ai' import { registerMessagesHandlers } from './ipc/messages' import { registerCacheHandlers } from './ipc/cache' import { registerNetworkHandlers } from './ipc/network' import { registerAnalyticsHandlers } from './analytics' +import { registerApiHandlers, initApiServer, cleanupApiServer } from './ipc/api' +import { registerDemoHandlers } from './ipc/demo' +import { cleanupArchiveImportSources } from './import-source-runtime' // 导入 Worker 模块(用于异步分析查询和流式导入) import * as worker from './worker/workerManager' @@ -24,9 +26,6 @@ import * as worker from './worker/workerManager' const mainIpcMain = (win: BrowserWindow) => { console.log('[IpcMain] Registering IPC handlers...') - // 初始化合并模块(清理残留的临时数据库) - initMergeModule() - // 初始化 Worker try { worker.initWorker() @@ -40,14 +39,47 @@ const mainIpcMain = (win: BrowserWindow) => { // 注册各模块的处理器 registerWindowHandlers(context) registerChatHandlers(context) - registerMergeHandlers(context) registerAIHandlers(context) registerMessagesHandlers(context) registerCacheHandlers(context) registerNetworkHandlers(context) registerAnalyticsHandlers() + registerApiHandlers(context) + registerDemoHandlers(context) + + // 启动 ChatLab API 服务(异步,不阻塞 IPC 注册) + initApiServer(context).catch((err) => { + console.error('[IpcMain] API server init failed:', err) + }) console.log('[IpcMain] All IPC handlers registered successfully') } +export const cleanup = () => { + console.log('[IpcMain] Cleaning up resources...') + try { + void cleanupArchiveImportSources() + worker.closeWorker() + } catch (error) { + console.error('[IpcMain] Error during cleanup:', error) + } +} + +/** + * 异步清理资源(用于更新安装前,确保 Worker 完全关闭) + */ +export const cleanupAsync = async () => { + console.log('[IpcMain] Cleaning up resources (async)...') + try { + // 关闭 ChatLab API 服务 + await cleanupApiServer() + await cleanupArchiveImportSources() + // 等待 Worker 完全关闭 + await worker.closeWorkerAsync() + console.log('[IpcMain] Cleanup completed') + } catch (error) { + console.error('[IpcMain] Error during async cleanup:', error) + } +} + export default mainIpcMain diff --git a/apps/desktop/main/logger.ts b/apps/desktop/main/logger.ts new file mode 100644 index 000000000..6d48de187 --- /dev/null +++ b/apps/desktop/main/logger.ts @@ -0,0 +1,36 @@ +/** + * General app logger (Electron main). + * + * Thin wrapper over the shared node-runtime appLogger so all runtimes write to + * the same rolling `logs/app.log`. Exported shape kept for existing call sites. + */ + +import { initAppLogger, appLogger } from '@openchatlab/node-runtime' +import { getLogsDir } from './paths' + +let initialized = false + +function ensureInit(): void { + if (initialized) return + initialized = true + initAppLogger(getLogsDir()) +} + +export const logger = { + info: (message: string) => { + ensureInit() + appLogger.info('app', message) + }, + warn: (message: string) => { + ensureInit() + appLogger.warn('app', message) + }, + error: (message: string) => { + ensureInit() + appLogger.error('app', message) + }, + debug: (message: string) => { + ensureInit() + appLogger.debug('app', message) + }, +} diff --git a/electron/main/network/index.ts b/apps/desktop/main/network/index.ts similarity index 100% rename from electron/main/network/index.ts rename to apps/desktop/main/network/index.ts diff --git a/apps/desktop/main/network/proxy-apply-queue.test.ts b/apps/desktop/main/network/proxy-apply-queue.test.ts new file mode 100644 index 000000000..aed172e9a --- /dev/null +++ b/apps/desktop/main/network/proxy-apply-queue.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { ProxyApplyQueue } from './proxy-apply-queue' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function nextTick(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + +test('ProxyApplyQueue waits for the pending apply before resolving waiters', async () => { + const queue = new ProxyApplyQueue() + const first = deferred() + const events: string[] = [] + + const applyPromise = queue.apply(async () => { + events.push('apply:start') + await first.promise + events.push('apply:end') + }) + + const waitPromise = queue.waitForPending().then(() => { + events.push('wait:end') + }) + + await nextTick() + assert.deepEqual(events, ['apply:start']) + + first.resolve() + await Promise.all([applyPromise, waitPromise]) + + assert.deepEqual(events, ['apply:start', 'apply:end', 'wait:end']) +}) + +test('ProxyApplyQueue serializes overlapping apply calls', async () => { + const queue = new ProxyApplyQueue() + const first = deferred() + const events: string[] = [] + + const firstApply = queue.apply(async () => { + events.push('first:start') + await first.promise + events.push('first:end') + }) + const secondApply = queue.apply(async () => { + events.push('second:start') + events.push('second:end') + }) + + await nextTick() + assert.deepEqual(events, ['first:start']) + + first.resolve() + await Promise.all([firstApply, secondApply]) + + assert.deepEqual(events, ['first:start', 'first:end', 'second:start', 'second:end']) +}) + +test('ProxyApplyQueue continues with later apply calls after an earlier failure', async () => { + const queue = new ProxyApplyQueue() + const events: string[] = [] + + const firstApply = queue.apply(async () => { + events.push('first:start') + throw new Error('apply failed') + }) + const secondApply = queue.apply(async () => { + events.push('second:start') + events.push('second:end') + }) + + await assert.rejects(firstApply, /apply failed/) + await secondApply + + assert.deepEqual(events, ['first:start', 'second:start', 'second:end']) +}) diff --git a/apps/desktop/main/network/proxy-apply-queue.ts b/apps/desktop/main/network/proxy-apply-queue.ts new file mode 100644 index 000000000..765bba6b9 --- /dev/null +++ b/apps/desktop/main/network/proxy-apply-queue.ts @@ -0,0 +1,21 @@ +export class ProxyApplyQueue { + private pendingApply: Promise | null = null + + apply(task: () => Promise): Promise { + const previous = this.pendingApply ?? Promise.resolve() + const current = previous.catch(() => undefined).then(task) + this.pendingApply = current + + const clear = () => { + if (this.pendingApply === current) this.pendingApply = null + } + current.then(clear, clear) + + return current + } + + async waitForPending(): Promise { + const pending = this.pendingApply + if (pending) await pending + } +} diff --git a/apps/desktop/main/network/proxy-resolver.test.ts b/apps/desktop/main/network/proxy-resolver.test.ts new file mode 100644 index 000000000..c56922c95 --- /dev/null +++ b/apps/desktop/main/network/proxy-resolver.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { proxyUrlFromElectronResolvedProxy } from './proxy-resolver' + +test('proxyUrlFromElectronResolvedProxy returns the first usable model download proxy', () => { + assert.equal(proxyUrlFromElectronResolvedProxy('DIRECT'), undefined) + assert.equal(proxyUrlFromElectronResolvedProxy('DIRECT; PROXY 127.0.0.1:7890'), undefined) + assert.equal(proxyUrlFromElectronResolvedProxy('PROXY 127.0.0.1:7890; DIRECT'), 'http://127.0.0.1:7890') + assert.equal( + proxyUrlFromElectronResolvedProxy('HTTPS proxy.example.com:443; DIRECT'), + 'https://proxy.example.com:443' + ) + assert.equal( + proxyUrlFromElectronResolvedProxy('SOCKS5 127.0.0.1:1080; PROXY 127.0.0.1:7890'), + 'http://127.0.0.1:7890' + ) + assert.equal(proxyUrlFromElectronResolvedProxy('SOCKS5 127.0.0.1:1080; DIRECT'), 'socks5://127.0.0.1:1080') + assert.equal(proxyUrlFromElectronResolvedProxy('SOCKS5 127.0.0.1:1080'), 'socks5://127.0.0.1:1080') + assert.equal(proxyUrlFromElectronResolvedProxy('SOCKS 127.0.0.1:1080'), 'socks://127.0.0.1:1080') +}) diff --git a/apps/desktop/main/network/proxy-resolver.ts b/apps/desktop/main/network/proxy-resolver.ts new file mode 100644 index 000000000..f128050d9 --- /dev/null +++ b/apps/desktop/main/network/proxy-resolver.ts @@ -0,0 +1,33 @@ +/** + * Convert Electron's `session.resolveProxy()` result to a model download proxy URL. + * Electron returns entries like `DIRECT`, `PROXY host:port`, `HTTPS host:port`; + * HTTP(S) proxies can be used directly by the model downloader. SOCKS entries + * are still returned so the downloader can fail explicitly instead of going direct. + */ +export function proxyUrlFromElectronResolvedProxy(resolvedProxy: string): string | undefined { + let firstSocksProxyUrl: string | undefined + + for (const rawRule of resolvedProxy.split(';')) { + const rule = rawRule.trim() + if (!rule) continue + if (rule.toUpperCase() === 'DIRECT') return firstSocksProxyUrl + + const [schemeRaw, targetRaw] = rule.split(/\s+/, 2) + const scheme = schemeRaw?.toUpperCase() + const target = targetRaw?.trim() + if (!scheme || !target) continue + + if (scheme === 'PROXY' || scheme === 'HTTP') return target.includes('://') ? target : `http://${target}` + if (scheme === 'HTTPS') return target.includes('://') ? target : `https://${target}` + if (scheme === 'SOCKS' && !firstSocksProxyUrl) { + firstSocksProxyUrl = target.includes('://') ? target : `socks://${target}` + } + if (scheme === 'SOCKS4' && !firstSocksProxyUrl) { + firstSocksProxyUrl = target.includes('://') ? target : `socks4://${target}` + } + if (scheme === 'SOCKS5' && !firstSocksProxyUrl) { + firstSocksProxyUrl = target.includes('://') ? target : `socks5://${target}` + } + } + return firstSocksProxyUrl +} diff --git a/apps/desktop/main/network/proxy.ts b/apps/desktop/main/network/proxy.ts new file mode 100644 index 000000000..74589e001 --- /dev/null +++ b/apps/desktop/main/network/proxy.ts @@ -0,0 +1,325 @@ +/** + * 代理配置管理模块 + * 提供 HTTP/HTTPS 代理的配置存储、读取和连接测试 + */ + +import * as fs from 'fs' +import * as path from 'path' +import { app, session } from 'electron' +import { getSettingsDir } from '../paths' +import { ProxyApplyQueue } from './proxy-apply-queue' +import { proxyUrlFromElectronResolvedProxy } from './proxy-resolver' + +// 代理模式 +export type ProxyMode = 'off' | 'system' | 'manual' + +// 代理配置接口 +export interface ProxyConfig { + mode: ProxyMode // 代理模式:关闭、跟随系统、手动配置 + url: string // 完整的代理 URL,如 http://127.0.0.1:7890(仅 manual 模式使用) +} + +// 默认配置 - 默认跟随系统 +const DEFAULT_CONFIG: ProxyConfig = { + mode: 'system', + url: '', +} + +// 配置文件路径 +let CONFIG_PATH: string | null = null +const proxyApplyQueue = new ProxyApplyQueue() + +function getConfigPath(): string { + if (CONFIG_PATH) return CONFIG_PATH + CONFIG_PATH = path.join(getSettingsDir(), 'proxy.json') + return CONFIG_PATH +} + +/** + * 迁移旧版配置到新版 + * 旧版: { enabled: boolean, url: string } + * 新版: { mode: ProxyMode, url: string } + */ +function migrateOldConfig(data: Record): ProxyConfig { + // 如果是旧版配置(有 enabled 字段,没有 mode 字段) + if ('enabled' in data && !('mode' in data)) { + const enabled = Boolean(data.enabled) + const url = String(data.url || '') + return { + mode: enabled && url ? 'manual' : 'system', // 旧版关闭的改为跟随系统 + url: url, + } + } + // 新版配置 + const mode = data.mode as ProxyMode + if (!['off', 'system', 'manual'].includes(mode)) { + return { ...DEFAULT_CONFIG } + } + return { + mode: mode, + url: String(data.url || ''), + } +} + +/** + * 加载代理配置 + */ +export function loadProxyConfig(): ProxyConfig { + const configPath = getConfigPath() + + if (!fs.existsSync(configPath)) { + return { ...DEFAULT_CONFIG } + } + + try { + const content = fs.readFileSync(configPath, 'utf-8') + const data = JSON.parse(content) + return migrateOldConfig(data) + } catch { + return { ...DEFAULT_CONFIG } + } +} + +/** + * 保存代理配置 + */ +export async function saveProxyConfig(config: ProxyConfig): Promise { + const configPath = getConfigPath() + const dir = path.dirname(configPath) + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8') + + // 保存后立即应用代理设置到 Electron session + await applyProxyToSession() +} + +/** + * 验证代理 URL 格式 + */ +export function validateProxyUrl(url: string): { valid: boolean; error?: string } { + if (!url) { + return { valid: false, error: '代理地址不能为空' } + } + + try { + const parsed = new URL(url) + if (!['http:', 'https:'].includes(parsed.protocol)) { + return { valid: false, error: '仅支持 http:// 或 https:// 协议' } + } + if (!parsed.hostname) { + return { valid: false, error: '代理地址格式无效' } + } + return { valid: true } + } catch { + return { valid: false, error: '代理地址格式无效,请使用 http://host:port 格式' } + } +} + +/** + * 将代理设置应用到 Electron session + * 这会影响所有通过 Electron 发起的网络请求(包括主进程的 fetch) + */ +async function applyProxyConfigToSession(config: ProxyConfig): Promise { + try { + switch (config.mode) { + case 'off': + // 直接连接,不使用代理 + await session.defaultSession.setProxy({ + mode: 'direct', + }) + console.log('[Proxy] Proxy disabled (direct connection)') + break + + case 'system': + // 使用系统代理设置 + await session.defaultSession.setProxy({ + mode: 'system', + }) + console.log('[Proxy] Using system proxy') + break + + case 'manual': + // 手动配置代理 + if (config.url) { + const validation = validateProxyUrl(config.url) + if (validation.valid) { + await session.defaultSession.setProxy({ + proxyRules: config.url, + }) + console.log(`[Proxy] Manual proxy enabled: ${config.url}`) + } else { + // URL 无效,回退到系统代理 + await session.defaultSession.setProxy({ + mode: 'system', + }) + console.log('[Proxy] Invalid manual proxy URL, falling back to system proxy') + } + } else { + // 没有填写 URL,回退到系统代理 + await session.defaultSession.setProxy({ + mode: 'system', + }) + console.log('[Proxy] Manual proxy URL not configured, falling back to system proxy') + } + break + + default: + // 未知模式,使用系统代理 + await session.defaultSession.setProxy({ + mode: 'system', + }) + console.log('[Proxy] Unknown proxy mode, using system proxy') + } + } catch (error) { + console.error('[Proxy] Failed to set proxy:', error) + } +} + +export function applyProxyToSession(): Promise { + return proxyApplyQueue.apply(() => applyProxyConfigToSession(loadProxyConfig())) +} + +function waitForPendingProxyApply(): Promise { + return proxyApplyQueue.waitForPending() +} + +/** + * 测试代理连接 + * 通过代理请求一个可靠的 HTTPS 地址来验证代理是否可用 + */ +export async function testProxyConnection(proxyUrl: string): Promise<{ success: boolean; error?: string }> { + // 先验证格式 + const validation = validateProxyUrl(proxyUrl) + if (!validation.valid) { + return { success: false, error: validation.error } + } + + return await proxyApplyQueue.apply(async () => { + // 测试 URL 列表(按优先级) + const testUrls = ['https://www.google.com', 'https://www.cloudflare.com', 'https://api.deepseek.com'] + + try { + // 临时设置代理 + await session.defaultSession.setProxy({ + proxyRules: proxyUrl, + }) + + // 使用 Electron 的 net 模块测试连接 + const { net } = await import('electron') + + let lastError: string = '' + + for (const testUrl of testUrls) { + try { + const result = await new Promise<{ success: boolean; error?: string }>((resolve) => { + const request = net.request({ + method: 'HEAD', + url: testUrl, + }) + + const timeout = setTimeout(() => { + request.abort() + resolve({ success: false, error: '连接超时' }) + }, 10000) + + request.on('response', (response) => { + clearTimeout(timeout) + // 任何响应都说明代理可用 + if (response.statusCode < 500) { + resolve({ success: true }) + } else { + resolve({ success: false, error: `HTTP ${response.statusCode}` }) + } + }) + + request.on('error', (error) => { + clearTimeout(timeout) + resolve({ success: false, error: error.message }) + }) + + request.end() + }) + + if (result.success) return { success: true } + + lastError = result.error || '' + } catch (e) { + lastError = e instanceof Error ? e.message : String(e) + } + } + + return { success: false, error: lastError || '无法通过代理连接到测试服务器' } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + + // 友好的错误提示 + if (errorMessage.includes('ECONNREFUSED')) { + return { success: false, error: '连接被拒绝,请检查代理服务器是否运行中' } + } + if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('timeout')) { + return { success: false, error: '连接超时,请检查代理地址和端口' } + } + if (errorMessage.includes('ENOTFOUND')) { + return { success: false, error: '无法解析代理服务器地址' } + } + + return { success: false, error: `代理连接失败: ${errorMessage}` } + } finally { + await applyProxyConfigToSession(loadProxyConfig()) + } + }) +} + +/** + * 获取当前有效的代理 URL + * 仅在手动模式且 URL 有效时返回代理 URL,否则返回 undefined + */ +export function getActiveProxyUrl(): string | undefined { + const config = loadProxyConfig() + if (config.mode === 'manual' && config.url) { + const validation = validateProxyUrl(config.url) + if (validation.valid) { + return config.url + } + } + return undefined +} + +export async function resolveModelDownloadProxyUrl(): Promise { + const config = loadProxyConfig() + + if (config.mode === 'manual' && config.url) { + const validation = validateProxyUrl(config.url) + if (validation.valid) return config.url + } + + if (config.mode !== 'system') return undefined + + try { + await waitForPendingProxyApply() + const resolvedProxy = await session.defaultSession.resolveProxy('https://huggingface.co/') + return proxyUrlFromElectronResolvedProxy(resolvedProxy) + } catch (error) { + console.warn('[Proxy] Failed to resolve system proxy for model downloads:', error) + return undefined + } +} + +/** + * 初始化代理模块 + * 应用启动时调用,加载并应用代理配置 + */ +export function initProxy(): void { + // 延迟执行,确保 app ready + if (app.isReady()) { + void applyProxyToSession() + } else { + app.whenReady().then(() => { + void applyProxyToSession() + }) + } +} diff --git a/apps/desktop/main/nlp/dictManager.ts b/apps/desktop/main/nlp/dictManager.ts new file mode 100644 index 000000000..c9276b89a --- /dev/null +++ b/apps/desktop/main/nlp/dictManager.ts @@ -0,0 +1,51 @@ +/** + * Electron 词库管理器 + * + * 封装 Electron 特定的 nlpDir 路径解析, + * 实际词库操作委托给 @openchatlab/node-runtime 的共享实现。 + */ + +import * as path from 'path' +import { + isDictDownloaded as _isDictDownloaded, + getDictList as _getDictList, + loadDictBuffer as _loadDictBuffer, + downloadDict as _downloadDict, + deleteDict as _deleteDict, + ensureDefaultDict as _ensureDefaultDict, +} from '@openchatlab/node-runtime' +import type { DictInfo } from '@openchatlab/core' +import { getSystemDataDir } from '../paths' + +export type { DictInfo } + +export function getNlpDir(): string { + return path.join(getSystemDataDir(), 'nlp') +} + +export function isDictDownloaded(dictId: string): boolean { + return _isDictDownloaded(getNlpDir(), dictId) +} + +export function getDictList(): DictInfo[] { + return _getDictList(getNlpDir()) +} + +export function loadDictBuffer(dictId: string): Buffer | null { + return _loadDictBuffer(getNlpDir(), dictId) +} + +export async function downloadDict( + dictId: string, + onProgress?: (percent: number) => void +): Promise<{ success: boolean; error?: string }> { + return _downloadDict(getNlpDir(), dictId, onProgress) +} + +export function deleteDict(dictId: string): { success: boolean; error?: string } { + return _deleteDict(getNlpDir(), dictId) +} + +export async function ensureDefaultDict(): Promise { + return _ensureDefaultDict(getNlpDir()) +} diff --git a/apps/desktop/main/parser/index.ts b/apps/desktop/main/parser/index.ts new file mode 100644 index 000000000..ee340c743 --- /dev/null +++ b/apps/desktop/main/parser/index.ts @@ -0,0 +1,43 @@ +/** + * Parser V2 - 模块入口(薄封装) + * 实际实现已迁移到 @openchatlab/parser 共享包 + */ +export { + detectFormat, + detectAllFormats, + getParser, + getSupportedFormats, + getFormatFeatureById, + getPreprocessor, + scanMultiChatFile, + needsPreprocess, + parseFile, + parseFileWithFormat, + parseFileSync, + parseFileInfo, + streamParseFile, + findEntryFileInDirectory, + FormatSniffer, + createSniffer, + getFileSize, + formatFileSize, + parseTimestamp, + isValidYear, + createProgress, + readFileHeadBytes, +} from '@openchatlab/parser' + +export type { + ParseOptions, + ParseEvent, + ParseResult, + ParseProgress, + FormatFeature, + Parser, + ParsedMeta, + ParsedMember, + ParsedMessage, + MultiChatInfo, + StreamParseCallbacks, + StreamParseOptions, +} from '@openchatlab/parser' diff --git a/apps/desktop/main/path-context.ts b/apps/desktop/main/path-context.ts new file mode 100644 index 000000000..4129956e1 --- /dev/null +++ b/apps/desktop/main/path-context.ts @@ -0,0 +1,20 @@ +/** + * Global PathProvider singleton for the Electron main process. + * + * Initializes lazily on first access. All modules that need directory paths + * should import getPathProvider() instead of importing functions from paths.ts + * directly — this makes them PathProvider-aware and easier to extract to + * shared packages later. + */ + +import type { PathProvider } from '@openchatlab/core' +import { ElectronPathProvider } from './electron-path-provider' + +let _provider: PathProvider | null = null + +export function getPathProvider(): PathProvider { + if (!_provider) { + _provider = new ElectronPathProvider() + } + return _provider +} diff --git a/apps/desktop/main/paths.ts b/apps/desktop/main/paths.ts new file mode 100644 index 000000000..089f03fdc --- /dev/null +++ b/apps/desktop/main/paths.ts @@ -0,0 +1,761 @@ +/** + * 统一路径管理模块 + * + * 目录分为两类: + * 1. 系统数据(~/.chatlab/)— 固定位置,不可更改 + * 配置、日志、缓存、AI 数据、设置偏好等 + * 2. 用户核心数据(userDataDir)— 可通过 config.toml 配置 + * 聊天记录数据库、向量库(未来) + * + * userDataDir 解析优先级: + * CHATLAB_DATA_DIR 环境变量 > config.toml [data] user_data_dir > 平台默认 + * + * 平台默认 userDataDir: + * - macOS: ~/Documents/ChatLab/ + * - Windows: %USERPROFILE%\Documents\ChatLab\ + * - Linux: ~/ChatLab/ + * + * Electron 旧版数据目录(迁移用): + * - Windows: %APPDATA%/ChatLab/data + * - macOS: ~/Library/Application Support/ChatLab/data + * - Linux: ~/.config/ChatLab/data + */ + +import { app } from 'electron' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { loadConfig, writeConfigField } from '@openchatlab/config' +import { + copyDirMerge, + copyDirRecursive, + ensureMarkerFile, + isInsideAppInstallDir, + isPathSafe, + isSubPath, + writeMigrationLog, +} from './utils/pathUtils' +import { shouldMarkUnifiedDirMigrationDone } from './utils/unifiedDirMigration' +import { + createPendingDataDirMigration, + isDirectoryEmptyOrMissing, + isExistingUserDataDir, + isUserDataDirSafeToUse, + runPendingDataDirMigration, + type PendingDataDirMigration, +} from './utils/dataDirSwitch' + +// 缓存路径,避免重复计算 +let _systemDataDir: string | null = null +let _userDataDir: string | null = null +let _legacyDataDir: string | null = null + +// 旧版存储配置文件名(迁移兼容用) +const STORAGE_CONFIG_FILE = 'storage.json' + +// ChatLab 数据目录标记文件(用于更严格的目录识别) +const CHATLAB_MARKER_FILE = '.chatlab' + +// ==================== 新路径体系 ==================== + +/** + * 获取系统数据根目录(固定 ~/.chatlab/) + */ +export function getSystemDataDir(): string { + if (_systemDataDir) return _systemDataDir + _systemDataDir = path.join(os.homedir(), '.chatlab') + return _systemDataDir +} + +/** + * 获取用户数据根目录(可配置) + * + * 解析优先级: + * 1. CHATLAB_DATA_DIR 环境变量 + * 2. ~/.chatlab/config.toml [data] user_data_dir + * 3. 平台默认路径(首次使用时写入 config.toml) + */ +export function getUserDataDir(): string { + if (_userDataDir) return _userDataDir + + const envDir = process.env.CHATLAB_DATA_DIR + if (envDir) { + _userDataDir = envDir + return _userDataDir + } + + const config = loadConfig() + if (config.data.user_data_dir) { + _userDataDir = config.data.user_data_dir + return _userDataDir + } + + _userDataDir = getDefaultUserDataDir() + writeConfigField('data', 'user_data_dir', _userDataDir) + return _userDataDir +} + +export function getDefaultUserDataDir(): string { + return path.join(os.homedir(), '.chatlab', 'data') +} + +// ==================== 旧版路径(迁移兼容) ==================== + +/** + * 获取 Electron 旧版数据根目录(userData/data) + * 仅供迁移检测使用,新代码请使用 getSystemDataDir/getUserDataDir + */ +export function getElectronLegacyDataDir(): string { + try { + return path.join(app.getPath('userData'), 'data') + } catch (error) { + console.error('[Paths] Error getting userData path:', error) + return path.join(process.cwd(), 'userData', 'data') + } +} + +/** + * 旧版存储配置文件路径(userData 根目录) + */ +function getStorageConfigPath(): string { + try { + return path.join(app.getPath('userData'), STORAGE_CONFIG_FILE) + } catch (error) { + console.error('[Paths] Error getting storage config path:', error) + return path.join(process.cwd(), STORAGE_CONFIG_FILE) + } +} + +interface StorageConfig { + dataDir?: string + pendingDeleteDir?: string + pendingDataDirMigration?: PendingDataDirMigration +} + +function readStorageConfig(): StorageConfig { + const configPath = getStorageConfigPath() + if (!fs.existsSync(configPath)) return {} + + try { + const content = fs.readFileSync(configPath, 'utf-8') + const data = JSON.parse(content) as StorageConfig + return data || {} + } catch (error) { + console.error('[Paths] Error reading storage config:', error) + } + + return {} +} + +function writeStorageConfig(config: StorageConfig): void { + const configPath = getStorageConfigPath() + try { + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8') + } catch (error) { + console.error('[Paths] Error writing storage config:', error) + } +} + +/** @deprecated 使用 readStorageConfig 进行迁移检测后废弃 */ +export function getCustomDataDir(): string | null { + const config = readStorageConfig() + const dataDir = config.dataDir?.trim() + if (!dataDir) return null + if (!path.isAbsolute(dataDir)) return null + return dataDir +} + +/** + * 设置用户数据目录 + * @param dataDir 目标目录(为空则恢复默认) + * @param migrate 是否迁移现有数据(合并复制,不会覆盖目标文件) + */ +export function setCustomDataDir( + dataDir: string | null, + migrate: boolean = true +): { success: boolean; error?: string; from?: string; to?: string; requiresRelaunch?: boolean } { + const normalized = typeof dataDir === 'string' ? dataDir.trim() : '' + const oldDir = getUserDataDir() + + try { + if (process.env.CHATLAB_DATA_DIR) { + return { success: false, error: 'CHATLAB_DATA_DIR 已设置,不能在界面中切换数据目录' } + } + + const newDir = normalized || getDefaultUserDataDir() + + if (!path.isAbsolute(newDir)) { + return { success: false, error: '数据目录必须是绝对路径' } + } + + if (migrate && oldDir !== newDir && isSubPath(oldDir, newDir)) { + return { success: false, error: '目标目录不能是当前数据目录的子目录' } + } + + if (!isPathSafe(newDir)) { + return { success: false, error: '不能使用系统关键目录作为数据目录' } + } + + try { + const exePath = app.getPath('exe') + if (isInsideAppInstallDir(newDir, exePath)) { + return { success: false, error: '不能将数据目录放在应用安装目录下,应用更新时该目录会被清空' } + } + } catch { + // 获取 exe 路径失败时跳过此检查 + } + + if (!isUserDataDirSafeToUse(newDir)) { + return { success: false, error: '目标目录不为空且不包含 ChatLab 数据,请选择空目录或已有数据目录' } + } + + if (path.resolve(oldDir) === path.resolve(newDir)) { + const config = readStorageConfig() + writeStorageConfig({ ...config, pendingDataDirMigration: undefined }) + return { success: true, from: oldDir, to: newDir, requiresRelaunch: false } + } + + const config = readStorageConfig() + const pending = createPendingDataDirMigration({ + from: oldDir, + to: newDir, + migrate, + targetWasEmpty: isDirectoryEmptyOrMissing(newDir), + }) + writeStorageConfig({ ...config, pendingDataDirMigration: pending }) + + return { success: true, from: oldDir, to: newDir, requiresRelaunch: true } + } catch (error) { + console.error('[Paths] Error setting custom data dir:', error) + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} + +export function applyPendingDataDirMigration(): { success: boolean; skipped?: boolean; error?: string } { + const config = readStorageConfig() + const pending = config.pendingDataDirMigration + if (!pending) return { success: true, skipped: true } + + const result = runPendingDataDirMigration(pending, { + writeUserDataDir(dir) { + writeConfigField('data', 'user_data_dir', dir) + writeConfigField('data', 'electron_migration_done', true) + _userDataDir = dir + }, + clearPendingMigration() { + const latest = readStorageConfig() + writeStorageConfig({ ...latest, pendingDataDirMigration: undefined }) + }, + markPendingDeleteDir(dir) { + const latest = readStorageConfig() + writeStorageConfig({ ...latest, pendingDeleteDir: dir }) + }, + log(message) { + writeMigrationLog(getLogsDir(), message, ensureDir) + }, + }) + + if (!result.success) { + const error = result.errors.join('; ') || '数据目录迁移失败' + console.warn('[Paths] Pending data dir migration failed:', error) + writeMigrationLog( + getLogsDir(), + `切换目录迁移失败: 从 ${pending.from} 到 ${pending.to},复制 ${result.copied} 项,跳过 ${result.skipped} 项,错误 ${result.errors.length} 项: ${error}`, + ensureDir + ) + return { success: false, error } + } + + writeMigrationLog( + getLogsDir(), + `切换目录迁移成功: 从 ${pending.from} 到 ${pending.to},复制 ${result.copied} 项,跳过 ${result.skipped} 项`, + ensureDir + ) + return { success: true } +} + +/** + * 清理待删除的旧数据目录(应用启动时调用) + */ +export function cleanupPendingDeleteDir(): void { + try { + const config = readStorageConfig() + const pendingDir = config.pendingDeleteDir + + if (!pendingDir) return + + const currentDir = getUserDataDir() + + if (pendingDir === currentDir) { + console.log('[Paths] Skipping cleanup: pending dir is same as current dir') + writeStorageConfig({ ...config, pendingDeleteDir: undefined }) + return + } + + if (!isPathSafe(pendingDir)) { + console.log('[Paths] Skipping cleanup: pending dir is a system directory:', pendingDir) + writeStorageConfig({ ...config, pendingDeleteDir: undefined }) + return + } + + if (fs.existsSync(pendingDir) && !isExistingUserDataDir(pendingDir)) { + console.log('[Paths] Skipping cleanup: pending dir is not a ChatLab data dir:', pendingDir) + writeStorageConfig({ ...config, pendingDeleteDir: undefined }) + return + } + + if (!fs.existsSync(pendingDir)) { + console.log('[Paths] Pending dir does not exist, skipping cleanup:', pendingDir) + writeStorageConfig({ ...config, pendingDeleteDir: undefined }) + return + } + + console.log('[Paths] Cleaning up old data directory:', pendingDir) + fs.rmSync(pendingDir, { recursive: true, force: true }) + console.log('[Paths] Old data directory deleted:', pendingDir) + + writeStorageConfig({ ...config, pendingDeleteDir: undefined }) + } catch (error) { + console.error('[Paths] Failed to clean up old directory:', error) + } +} + +/** + * 获取旧版数据目录(Documents/ChatLab) + * 用于数据迁移检测 + */ +export function getLegacyDataDir(): string { + if (_legacyDataDir) return _legacyDataDir + + try { + const docPath = app.getPath('documents') + _legacyDataDir = path.join(docPath, 'ChatLab') + } catch (error) { + console.error('[Paths] Error getting documents path:', error) + _legacyDataDir = path.join(process.cwd(), 'ChatLab') + } + + return _legacyDataDir +} + +/** + * 获取系统下载目录 + * 用于用户导出文件的默认位置 + */ +export function getDownloadsDir(): string { + try { + return app.getPath('downloads') + } catch (error) { + console.error('[Paths] Error getting downloads path:', error) + return path.join(process.cwd(), 'downloads') + } +} + +export function getDatabaseDir(): string { + return path.join(getUserDataDir(), 'databases') +} + +export function getVectorDir(): string { + return path.join(getUserDataDir(), 'vector') +} + +export function getAiDataDir(): string { + return path.join(getSystemDataDir(), 'ai') +} + +export function getSettingsDir(): string { + return path.join(getSystemDataDir(), 'settings') +} + +export function getCacheDir(): string { + return path.join(getSystemDataDir(), 'cache') +} + +export function getTempDir(): string { + return path.join(getSystemDataDir(), 'temp') +} + +export function getLogsDir(): string { + return path.join(getSystemDataDir(), 'logs') +} + +/** + * 确保目录存在 + */ +export function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }) + } +} + +/** + * 确保所有应用目录存在(系统数据 + 用户数据) + */ +export function ensureAppDirs(): void { + ensureDir(getSystemDataDir()) + ensureDir(getUserDataDir()) + ensureDir(getDatabaseDir()) + ensureDir(getVectorDir()) + ensureDir(getAiDataDir()) + ensureDir(getSettingsDir()) + ensureDir(getCacheDir()) + ensureDir(getTempDir()) + ensureDir(getLogsDir()) + ensureMarkerFile(getUserDataDir(), CHATLAB_MARKER_FILE) +} + +// ==================== 数据迁移 ==================== + +/** + * 检查是否需要从 Documents/ChatLab 迁移数据 + */ +export function needsLegacyMigration(): boolean { + const legacyDir = getLegacyDataDir() + + // 检查 Documents/ChatLab 是否存在 + if (fs.existsSync(legacyDir)) { + return true + } + + return false +} + +/** + * 从指定源目录迁移数据到目标目录 + * 采用合并策略:只复制不存在的文件,不覆盖已存在的文件 + */ +function migrateDirectory( + srcDir: string, + destDir: string, + subDirs: string[] +): { migratedDirs: string[]; skippedDirs: string[] } { + const migratedDirs: string[] = [] + const skippedDirs: string[] = [] + + for (const subDir of subDirs) { + const srcSubPath = path.join(srcDir, subDir) + const destSubPath = path.join(destDir, subDir) + + // 如果源子目录不存在或为空,跳过 + if (!fs.existsSync(srcSubPath)) { + continue + } + + const srcFiles = fs.readdirSync(srcSubPath).filter((f) => !f.startsWith('.')) + if (srcFiles.length === 0) { + continue + } + + // 确保目标子目录存在 + ensureDir(destSubPath) + + // 获取目标目录中已存在的文件 + const existingFiles = new Set(fs.readdirSync(destSubPath)) + + // 合并策略:只复制目标目录中不存在的文件 + let copiedCount = 0 + let skippedCount = 0 + + for (const file of srcFiles) { + const srcPath = path.join(srcSubPath, file) + const destPath = path.join(destSubPath, file) + + // 如果目标文件已存在,跳过(不覆盖) + if (existingFiles.has(file)) { + console.log(`[Paths] Skipping ${subDir}/${file}: already exists in destination`) + skippedCount++ + continue + } + + const stat = fs.statSync(srcPath) + if (stat.isDirectory()) { + copyDirRecursive(srcPath, destPath, ensureDir) + } else { + fs.copyFileSync(srcPath, destPath) + } + copiedCount++ + } + + if (copiedCount > 0) { + migratedDirs.push(subDir) + console.log(`[Paths] Migrated ${subDir}: ${copiedCount} items copied, ${skippedCount} skipped`) + } else if (skippedCount > 0) { + skippedDirs.push(subDir) + console.log(`[Paths] ${subDir}: all ${skippedCount} items already exist in destination`) + } + } + + return { migratedDirs, skippedDirs } +} + +/** + * 执行从 Documents/ChatLab 到新目录的数据迁移 + * 迁移整个目录的所有内容,采用合并策略:只复制不存在的文件,不覆盖已存在的文件 + * 只有在所有数据都成功迁移后才删除旧目录 + */ +export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string[]; error?: string } { + const legacyDir = getLegacyDataDir() + const newDir = getUserDataDir() + + try { + if (!fs.existsSync(legacyDir)) { + return { success: true, migratedDirs: [] } + } + + // 获取旧目录下的所有子目录和文件 + const entries = fs.readdirSync(legacyDir, { withFileTypes: true }) + const dirsToMigrate = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name) + const filesToMigrate = entries.filter((e) => e.isFile() && !e.name.startsWith('.')).map((e) => e.name) + + const result = migrateDirectory(legacyDir, newDir, dirsToMigrate) + + // 迁移根目录下的文件 + ensureDir(newDir) + for (const file of filesToMigrate) { + const srcPath = path.join(legacyDir, file) + const destPath = path.join(newDir, file) + if (!fs.existsSync(destPath)) { + fs.copyFileSync(srcPath, destPath) + } + } + + // 构建迁移摘要 + const summary: string[] = [] + summary.push(`Migration from ${legacyDir} to ${newDir}`) + + // 迁移成功,删除旧目录 + fs.rmSync(legacyDir, { recursive: true, force: true }) + summary.push('Status: Success, legacy directory removed') + + if (result.migratedDirs.length > 0) { + summary.push(`Migrated dirs: ${result.migratedDirs.join(', ')}`) + } + if (filesToMigrate.length > 0) { + summary.push(`Migrated files: ${filesToMigrate.length}`) + } + + // 写入迁移日志 + writeMigrationLog(getLogsDir(), summary.join(' | '), ensureDir) + + return { success: true, migratedDirs: result.migratedDirs } + } catch (error) { + console.error('[Paths] Migration failed:', error) + const errorMsg = error instanceof Error ? error.message : String(error) + writeMigrationLog(getLogsDir(), `Migration failed: ${errorMsg}`, ensureDir) + return { + success: false, + migratedDirs: [], + error: errorMsg, + } + } +} + +/** + * 删除旧版数据目录(可选,供用户确认后调用) + */ +export function removeLegacyDir(): boolean { + const legacyDir = getLegacyDataDir() + + if (!fs.existsSync(legacyDir)) { + return true + } + + try { + fs.rmSync(legacyDir, { recursive: true, force: true }) + console.log(`[Paths] Removed legacy directory: ${legacyDir}`) + return true + } catch (error) { + console.error('[Paths] Failed to remove legacy directory:', error) + return false + } +} + +// ==================== Electron 旧目录结构 → 新目录结构迁移 ==================== + +const SYSTEM_SUBDIRS = ['ai', 'settings', 'cache', 'logs', 'temp', 'nlp'] + +/** + * 检测是否需要从 Electron 旧目录结构迁移到新的双根目录结构 + * + * 判断条件: + * - 旧 Electron 数据路径存在数据库文件 + * - 且当前 user_data_dir 没有指向旧 Electron 路径(即数据库还未被纳入) + * + * 注意:不能仅靠 user_data_dir 是否存在来判断,因为 CLI 可能先于 + * Electron 启动并写入了默认值 ~/.chatlab/data,导致迁移被跳过。 + */ +export function needsUnifiedDirMigration(): boolean { + const config = loadConfig() + if (config.data.electron_migration_done) return false + + const oldDataDir = resolveOldElectronDataDir() + const oldDbDir = path.join(oldDataDir, 'databases') + if (!fs.existsSync(oldDbDir)) return false + + const hasDb = fs.readdirSync(oldDbDir).some((f) => f.endsWith('.db')) + if (!hasDb) return false + + const currentUserDataDir = config.data.user_data_dir || getDefaultUserDataDir() + if (path.resolve(currentUserDataDir) === path.resolve(oldDataDir)) return false + + return true +} + +/** + * 解析 Electron 旧数据目录(考虑 storage.json 自定义路径) + */ +function resolveOldElectronDataDir(): string { + const storageConfig = readStorageConfig() + if (storageConfig.dataDir && path.isAbsolute(storageConfig.dataDir)) { + return storageConfig.dataDir + } + return getElectronLegacyDataDir() +} + +/** + * 执行从 Electron 旧目录结构到新双根目录结构的迁移 + * + * 迁移步骤: + * 1. 创建 ~/.chatlab/ 目录 + * 2. 如果当前 user_data_dir 下有数据库,合并到旧 Electron 路径 + * 3. 将旧数据路径写入 config.toml [data] user_data_dir + * 4. 复制系统数据到 ~/.chatlab/(合并,不覆盖已有) + * 5. 验证复制成功 + * 6. 删除旧路径下的系统数据 + * 7. 留 MOVED.txt 说明文件 + */ +export function migrateToUnifiedDirs(): { success: boolean; error?: string } { + const oldDataDir = resolveOldElectronDataDir() + const systemDir = getSystemDataDir() + + console.log(`[Migration] Starting unified dir migration: ${oldDataDir} → ${systemDir}`) + + try { + // Step 1: 创建系统目录 + ensureDir(systemDir) + + // Step 2: 如果当前 user_data_dir 指向了别处(如 CLI 写入的默认路径), + // 且那里有数据库,先合并到旧 Electron 路径 + const config = loadConfig() + const prevUserDataDir = config.data.user_data_dir + if (prevUserDataDir && path.resolve(prevUserDataDir) !== path.resolve(oldDataDir)) { + const prevDbDir = path.join(prevUserDataDir, 'databases') + const oldDbDir = path.join(oldDataDir, 'databases') + if (fs.existsSync(prevDbDir)) { + const dbFiles = fs.readdirSync(prevDbDir).filter((f) => f.endsWith('.db')) + if (dbFiles.length > 0) { + ensureDir(oldDbDir) + const result = copyDirMerge(prevDbDir, oldDbDir, ensureDir) + console.log( + `[Migration] Merged ${result.copied} databases from ${prevDbDir} to ${oldDbDir} (skipped ${result.skipped})` + ) + } + } + } + + // Step 3: 写入 config.toml(数据库保留在旧 Electron 路径) + writeConfigField('data', 'user_data_dir', oldDataDir) + _userDataDir = oldDataDir + + // 如果 storage.json 有自定义 dataDir,也记录日志 + const storageConfig = readStorageConfig() + if (storageConfig.dataDir) { + console.log(`[Migration] Migrated storage.json custom path: ${storageConfig.dataDir}`) + } + + // Step 4: 复制系统数据(合并,不覆盖 ~/.chatlab/ 下已有的文件) + const movedDirs: string[] = [] + const failedDirs: string[] = [] + + for (const subDir of SYSTEM_SUBDIRS) { + const srcDir = path.join(oldDataDir, subDir) + const destDir = path.join(systemDir, subDir) + + if (!fs.existsSync(srcDir)) continue + + try { + ensureDir(destDir) + const mergeResult = copyDirMerge(srcDir, destDir, ensureDir) + + if (mergeResult.copied > 0 || mergeResult.skipped > 0) { + movedDirs.push(subDir) + console.log(`[Migration] ${subDir}: copied ${mergeResult.copied}, skipped ${mergeResult.skipped}`) + } + } catch (err) { + console.error(`[Migration] Failed to copy ${subDir}:`, err) + failedDirs.push(subDir) + } + } + + // Step 5: 删除旧路径下的系统数据(仅成功复制的目录) + for (const subDir of movedDirs) { + const srcDir = path.join(oldDataDir, subDir) + try { + fs.rmSync(srcDir, { recursive: true, force: true }) + } catch (err) { + console.warn(`[Migration] Failed to remove old ${subDir}:`, err) + } + } + + // Step 6: 留说明文件 + const movedTxt = [ + `ChatLab Data Migration - ${new Date().toISOString()}`, + '', + 'System data has been moved to: ' + systemDir, + 'User data (databases) remains in this directory.', + '', + `Moved directories: ${movedDirs.join(', ') || 'none'}`, + `Failed directories: ${failedDirs.join(', ') || 'none'}`, + ].join('\n') + fs.writeFileSync(path.join(oldDataDir, 'MOVED.txt'), movedTxt, 'utf-8') + + const summary = `Unified dir migration: ${movedDirs.length} dirs moved, ${failedDirs.length} failed` + writeMigrationLog(getLogsDir(), summary, ensureDir) + console.log(`[Migration] ${summary}`) + + if (shouldMarkUnifiedDirMigrationDone(failedDirs)) { + writeConfigField('data', 'electron_migration_done', true) + } + + return { success: failedDirs.length === 0 } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + console.error('[Migration] Unified dir migration failed:', errorMsg) + try { + writeMigrationLog(getLogsDir(), `Unified dir migration failed: ${errorMsg}`, ensureDir) + } catch { + // 日志写入失败时忽略 + } + return { success: false, error: errorMsg } + } +} + +/** + * Verify that the configured user_data_dir actually has database files. + * If the configured dir is empty but the old Electron path has databases, + * auto-correct config.toml to point to the old path. + * + * This acts as a safety net in case migration was skipped or config was overwritten. + */ +export function verifyDataPath(): void { + const currentDir = getUserDataDir() + const currentDbDir = path.join(currentDir, 'databases') + const hasCurrentDbs = fs.existsSync(currentDbDir) && fs.readdirSync(currentDbDir).some((f) => f.endsWith('.db')) + + if (hasCurrentDbs) return + + const oldDir = getElectronLegacyDataDir() + if (path.resolve(currentDir) === path.resolve(oldDir)) return + + const oldDbDir = path.join(oldDir, 'databases') + if (!fs.existsSync(oldDbDir)) return + + const hasOldDbs = fs.readdirSync(oldDbDir).some((f) => f.endsWith('.db')) + if (!hasOldDbs) return + + console.warn( + `[Paths] Data path mismatch: configured dir ${currentDir} has no databases, but old path ${oldDir} does. Auto-correcting config.toml.` + ) + writeConfigField('data', 'user_data_dir', oldDir) + _userDataDir = oldDir +} diff --git a/apps/desktop/main/runtime-compat.test.ts b/apps/desktop/main/runtime-compat.test.ts new file mode 100644 index 000000000..bf6d10bd6 --- /dev/null +++ b/apps/desktop/main/runtime-compat.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { PathProvider } from '@openchatlab/core' +import { assertDesktopDataDirCompatible, resolveDesktopAppVersion } from './runtime-compat' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-desktop-compat-')) +} + +function makePathProvider(userDataDir: string): PathProvider { + return { + getSystemDir: () => path.join(userDataDir, '..', 'system'), + getUserDataDir: () => userDataDir, + getDatabaseDir: () => path.join(userDataDir, 'databases'), + getVectorDir: () => path.join(userDataDir, 'vector'), + getAiDataDir: () => path.join(userDataDir, '..', 'system', 'ai'), + getSettingsDir: () => path.join(userDataDir, '..', 'system', 'settings'), + getCacheDir: () => path.join(userDataDir, '..', 'system', 'cache'), + getTempDir: () => path.join(userDataDir, '..', 'system', 'temp'), + getLogsDir: () => path.join(userDataDir, '..', 'system', 'logs'), + getDownloadsDir: () => path.join(userDataDir, '..', 'downloads'), + } +} + +test('resolveDesktopAppVersion falls back to bundled app version when Electron reports 0.0.0', () => { + assert.equal(resolveDesktopAppVersion('0.0.0', '0.25.1'), '0.25.1') +}) + +test('assertDesktopDataDirCompatible accepts a gated data directory with the bundled fallback version', () => { + const userDataDir = makeTempDir() + fs.writeFileSync( + path.join(userDataDir, '.chatlab-meta.json'), + JSON.stringify({ + formatVersion: 1, + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.25.1' }, + updatedAt: 1780830000, + }), + 'utf-8' + ) + + assert.doesNotThrow(() => + assertDesktopDataDirCompatible(makePathProvider(userDataDir), resolveDesktopAppVersion('0.0.0', '0.25.1')) + ) +}) + +test('assertDesktopDataDirCompatible accepts prerelease desktop versions by stable core version', () => { + const userDataDir = makeTempDir() + fs.writeFileSync( + path.join(userDataDir, '.chatlab-meta.json'), + JSON.stringify({ + formatVersion: 1, + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.25.1' }, + updatedAt: 1780830000, + }), + 'utf-8' + ) + + assert.doesNotThrow(() => assertDesktopDataDirCompatible(makePathProvider(userDataDir), '0.26.4-beta.1')) +}) + +test('assertDesktopDataDirCompatible formats a startup error for older desktop runtimes', () => { + const userDataDir = makeTempDir() + fs.writeFileSync( + path.join(userDataDir, '.chatlab-meta.json'), + JSON.stringify({ + formatVersion: 1, + minRuntimeVersion: '999.0.0', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '999.0.0' }, + updatedAt: 1780830000, + }), + 'utf-8' + ) + + assert.throws( + () => assertDesktopDataDirCompatible(makePathProvider(userDataDir), '0.25.0'), + (error) => { + assert.ok(error instanceof Error) + assert.match(error.message, /requires ChatLab 999\.0\.0 or newer/) + assert.match(error.message, /Current desktop version: 0\.25\.0/) + assert.match(error.message, new RegExp(userDataDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + assert.match(error.message, /upgrade ChatLab desktop/) + return true + } + ) +}) + +test('assertDesktopDataDirCompatible shows original prerelease version when blocked', () => { + const userDataDir = makeTempDir() + fs.writeFileSync( + path.join(userDataDir, '.chatlab-meta.json'), + JSON.stringify({ + formatVersion: 1, + minRuntimeVersion: '0.26.5', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.26.5' }, + updatedAt: 1780830000, + }), + 'utf-8' + ) + + assert.throws( + () => assertDesktopDataDirCompatible(makePathProvider(userDataDir), '0.26.4-beta.1'), + (error) => { + assert.ok(error instanceof Error) + assert.match(error.message, /requires ChatLab 0\.26\.5 or newer/) + assert.match(error.message, /Current desktop version: 0\.26\.4-beta\.1/) + return true + } + ) +}) diff --git a/apps/desktop/main/runtime-compat.ts b/apps/desktop/main/runtime-compat.ts new file mode 100644 index 000000000..6b317e90b --- /dev/null +++ b/apps/desktop/main/runtime-compat.ts @@ -0,0 +1,57 @@ +import type { PathProvider } from '@openchatlab/core' +import { + assertDataDirCompatible, + DataDirCompatibilityError, + type RuntimeIdentity, +} from '@openchatlab/node-runtime/src/data-dir-compat' + +export function resolveDesktopAppVersion(electronVersion: string | null | undefined, bundledVersion?: string): string { + const normalizedElectronVersion = normalizeVersion(electronVersion) + if (normalizedElectronVersion && normalizedElectronVersion !== '0.0.0') return normalizedElectronVersion + + const normalizedBundledVersion = normalizeVersion(bundledVersion) + if (normalizedBundledVersion) return normalizedBundledVersion + + return normalizedElectronVersion || '0.0.0' +} + +export function getDesktopAppVersion(electronVersion: string | null | undefined): string { + return resolveDesktopAppVersion(electronVersion, typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined) +} + +export function createDesktopRuntimeIdentity(version: string): RuntimeIdentity { + return { version, kind: 'desktop' } +} + +export function assertDesktopDataDirCompatible(pathProvider: PathProvider, version: string): RuntimeIdentity { + const runtime = createDesktopRuntimeIdentity(version) + + try { + assertDataDirCompatible(pathProvider, runtime) + } catch (error) { + if ( + error instanceof DataDirCompatibilityError && + error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' && + error.minRuntimeVersion + ) { + throw new Error(formatDesktopDataDirCompatibilityError(error, runtime), { cause: error }) + } + + throw error + } + + return runtime +} + +function normalizeVersion(version: string | null | undefined): string { + return typeof version === 'string' ? version.trim() : '' +} + +function formatDesktopDataDirCompatibilityError(error: DataDirCompatibilityError, runtime: RuntimeIdentity): string { + return [ + `ChatLab data directory requires ChatLab ${error.minRuntimeVersion} or newer.`, + `Current desktop version: ${runtime.version}.`, + `Data directory: ${error.userDataDir}.`, + 'Please upgrade ChatLab desktop before opening this data directory.', + ].join('\n') +} diff --git a/apps/desktop/main/semantic-index-runtime-deps.test.ts b/apps/desktop/main/semantic-index-runtime-deps.test.ts new file mode 100644 index 000000000..c3e169a62 --- /dev/null +++ b/apps/desktop/main/semantic-index-runtime-deps.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { describe, it } from 'node:test' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const desktopPackageJsonPath = path.resolve(__dirname, '../package.json') + +function readDesktopPackageJson(): { dependencies?: Record } { + return JSON.parse(fs.readFileSync(desktopPackageJsonPath, 'utf-8')) as { dependencies?: Record } +} + +describe('desktop semantic-index runtime dependencies', () => { + it('declares transformers node runtime packages as direct production dependencies', () => { + const pkg = readDesktopPackageJson() + const dependencies = pkg.dependencies ?? {} + + for (const name of ['onnxruntime-common', 'onnxruntime-node', 'sharp']) { + assert.ok(dependencies[name], `${name} must be a direct desktop dependency for packaged local embeddings`) + } + }) +}) diff --git a/apps/desktop/main/update.test.ts b/apps/desktop/main/update.test.ts new file mode 100644 index 000000000..efcc01165 --- /dev/null +++ b/apps/desktop/main/update.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { mock, test } from 'node:test' + +class FakeAutoUpdater extends EventEmitter { + autoDownload = false + autoInstallOnAppQuit = true + downloadCalls = 0 + checkCalls = 0 + quitInstallCalls = 0 + feedUrls: unknown[] = [] + + setFeedURL(url: unknown): void { + this.feedUrls.push(url) + } + + async checkForUpdates(): Promise { + this.checkCalls++ + } + + async downloadUpdate(): Promise { + this.downloadCalls++ + } + + quitAndInstall(): void { + this.quitInstallCalls++ + } +} + +type DialogCall = { + title?: string + message?: string + detail?: string + buttons?: string[] +} + +async function loadUpdaterModule() { + const autoUpdater = new FakeAutoUpdater() + const dialogCalls: DialogCall[] = [] + const logMessages: string[] = [] + + await mock.module('electron', { + namedExports: { + app: { + isPackaged: true, + }, + dialog: { + async showMessageBox(options: DialogCall) { + dialogCalls.push(options) + return { response: 1 } + }, + }, + }, + }) + await mock.module('electron-updater', { + namedExports: { autoUpdater }, + }) + await mock.module('@electron-toolkit/utils', { + namedExports: { platform: { isWindows: false } }, + }) + await mock.module('./logger', { + namedExports: { + logger: { + info(message: string) { + logMessages.push(message) + }, + error(message: string) { + logMessages.push(message) + }, + }, + }, + }) + await mock.module('./network/proxy', { + namedExports: { + getActiveProxyUrl: () => undefined, + }, + }) + await mock.module('./worker/workerManager', { + namedExports: { + closeWorkerAsync: async () => undefined, + }, + }) + await mock.module('./i18n', { + namedExports: { + t: (key: string, params?: Record) => `${key}${params?.version ? `:${params.version}` : ''}`, + }, + }) + + const mod = await import('./update.js') + return { + autoUpdater, + dialogCalls, + checkUpdate: mod.checkUpdate as (win: { webContents: { send: (...args: unknown[]) => void } }) => void, + manualCheckForUpdates: mod.manualCheckForUpdates as () => void, + } +} + +test('automatic stable updates download silently before showing install prompt', async () => { + const { autoUpdater, dialogCalls, checkUpdate, manualCheckForUpdates } = await loadUpdaterModule() + const sent: unknown[][] = [] + + checkUpdate({ + webContents: { + send: (...args: unknown[]) => sent.push(args), + }, + }) + + autoUpdater.emit('update-available', { version: '0.28.2' }) + await Promise.resolve() + + assert.equal(autoUpdater.downloadCalls, 1) + assert.equal(dialogCalls.length, 0) + assert.equal(autoUpdater.autoDownload, false) + assert.equal(autoUpdater.autoInstallOnAppQuit, false) + + autoUpdater.emit('update-downloaded', { version: '0.28.2' }) + await Promise.resolve() + + assert.equal(dialogCalls.length, 1) + assert.equal(dialogCalls[0]?.title, 'update.downloadComplete') + assert.equal(dialogCalls[0]?.message, 'update.readyToInstall') + + manualCheckForUpdates() + autoUpdater.emit('update-available', { version: '0.28.3' }) + await Promise.resolve() + + assert.equal(dialogCalls.length, 2) + assert.equal(dialogCalls[1]?.title, 'update.newVersionTitle:0.28.3') + assert.equal(dialogCalls[1]?.message, 'update.newVersionMessage:0.28.3') + assert.equal(autoUpdater.downloadCalls, 1) +}) diff --git a/apps/desktop/main/update.ts b/apps/desktop/main/update.ts new file mode 100644 index 000000000..499a0fdd6 --- /dev/null +++ b/apps/desktop/main/update.ts @@ -0,0 +1,316 @@ +import { dialog, app } from 'electron' +import { autoUpdater } from 'electron-updater' +import { platform } from '@electron-toolkit/utils' +import { logger } from './logger' +import { getActiveProxyUrl } from './network/proxy' +import { closeWorkerAsync } from './worker/workerManager' +import { t } from './i18n' + +type AppWithQuitFlag = typeof app & { isQuiting?: boolean } +// 更新安装流程会主动触发退出,这里使用类型扩展存储退出标记。 +const appWithQuitFlag = app as AppWithQuitFlag + +// R2 镜像源 URL(速度更快,作为主要更新源) +const R2_MIRROR_URL = 'https://chatlab.1app.top/releases/download' + +// 更新源类型 +type UpdateSource = 'github' | 'r2' + +// 当前使用的更新源(默认 R2 优先,GitHub 作为网络失败兜底) +let currentSource: UpdateSource = 'r2' + +// 是否已尝试过备用源 +let hasTriedFallback = false + +/** + * 配置自动更新的代理设置 + * electron-updater 通过环境变量读取代理配置 + */ +function configureUpdateProxy(): void { + const proxyUrl = getActiveProxyUrl() + + if (proxyUrl) { + // 设置环境变量,electron-updater 会自动读取 + process.env.HTTPS_PROXY = proxyUrl + process.env.HTTP_PROXY = proxyUrl + logger.info(`[Update] Using proxy: ${proxyUrl}`) + } else { + // 清除代理环境变量 + delete process.env.HTTPS_PROXY + delete process.env.HTTP_PROXY + } +} + +/** + * 切换到 R2 镜像源 + */ +function switchToR2Mirror(): void { + currentSource = 'r2' + autoUpdater.setFeedURL({ + provider: 'generic', + url: R2_MIRROR_URL, + }) +} + +/** + * 切换到 GitHub 源(备用更新源) + */ +function switchToGitHub(): void { + currentSource = 'github' + autoUpdater.setFeedURL({ + provider: 'github', + owner: 'ChatLab', + repo: 'ChatLab', + }) + logger.info('[Update] Switched to GitHub fallback source') +} + +/** + * 重置为默认更新源(R2 优先) + */ +function resetToDefaultSource(): void { + hasTriedFallback = false + switchToR2Mirror() +} + +/** + * 判断错误是否为网络相关错误 + */ +function isNetworkError(error: Error): boolean { + const networkErrorKeywords = [ + 'ECONNREFUSED', + 'ENOTFOUND', + 'ETIMEDOUT', + 'ECONNRESET', + 'ENETUNREACH', + 'EAI_AGAIN', + 'socket hang up', + 'network', + 'connect', + 'timeout', + 'getaddrinfo', + ] + const errorMessage = error.message?.toLowerCase() || '' + const errorCode = (error as NodeJS.ErrnoException).code?.toLowerCase() || '' + + return networkErrorKeywords.some( + (keyword) => errorMessage.includes(keyword.toLowerCase()) || errorCode.includes(keyword.toLowerCase()) + ) +} + +/** + * 判断版本号是否为预发布版本 + * 预发布版本格式:0.3.0-beta.1, 0.4.2-alpha.23, 1.0.0-rc.1 等 + * 标准版本格式:0.3.0, 1.0.0, 2.1.3 等 + */ +function isPreReleaseVersion(version: string): boolean { + // 预发布版本包含连字符后跟预发布标识(alpha, beta, rc, dev, canary 等) + return /-/.test(version) +} + +let isFirstShow = true +// 标记是否为手动检查更新(手动检查时即使是预发布版本也显示弹窗) +let isManualCheck = false +const checkUpdate = (win) => { + // 配置代理 + configureUpdateProxy() + + autoUpdater.autoDownload = false // 自动下载 + autoUpdater.autoInstallOnAppQuit = false // 关闭退出自动安装,必须显式确认安装 + + // 开发模式下模拟更新检测(需要创建 dev-app-update.yml 文件) + // 取消下面的注释来启用开发模式更新测试 + // if (!app.isPackaged) { + // Object.defineProperty(app, 'isPackaged', { + // get() { + // return true + // }, + // }) + // } + + let showUpdateMessageBox = false + let isDownloadingUpdate = false + + // 自动检查走静默下载;手动检查仍由用户确认后再下载。 + const startDownloadUpdate = (): void => { + if (isDownloadingUpdate) return + isDownloadingUpdate = true + autoUpdater + .downloadUpdate() + .then(() => { + console.log('wait for post download operation') + }) + .catch((downloadError) => { + // 下载失败记录到日志,不显示给用户 + logger.error(`[Update] Download update failed: ${downloadError}`) + }) + .finally(() => { + isDownloadingUpdate = false + }) + } + + autoUpdater.on('update-available', (info) => { + // win.webContents.send('show-message', 'electron:发现新版本') + if (showUpdateMessageBox) return + + // 检查是否为预发布版本 + const isPreRelease = isPreReleaseVersion(info.version) + + // 预发布版本仅在手动检查时显示更新弹窗 + if (isPreRelease && !isManualCheck) { + console.log(`[Update] Pre-release version found: ${info.version}, skipping auto-update prompt`) + logger.info( + `[Update] Pre-release version found: ${info.version}, skipping auto-update prompt (manual check required)` + ) + return + } + + if (!isManualCheck) { + logger.info(`[Update] New version ${info.version} found, downloading silently`) + startDownloadUpdate() + return + } + + showUpdateMessageBox = true + + dialog + .showMessageBox({ + title: t('update.newVersionTitle', { version: info.version }), + message: t('update.newVersionMessage', { version: info.version }), + detail: t('update.newVersionDetail'), + buttons: [t('update.downloadNow'), t('update.cancel')], + defaultId: 0, + cancelId: 1, + type: 'question', + noLink: true, + }) + .then((result) => { + showUpdateMessageBox = false + if (result.response === 0) { + startDownloadUpdate() + } + }) + }) + + // 监听下载进度事件 + autoUpdater.on('download-progress', (progressObj) => { + console.log(`Update download progress: ${progressObj.percent}%`) + win.webContents.send('update-download-progress', progressObj.percent) + }) + + // 下载完成 + autoUpdater.on('update-downloaded', () => { + dialog + .showMessageBox({ + title: t('update.downloadComplete'), + message: t('update.readyToInstall'), + buttons: [t('update.install'), t('update.remindLater')], + defaultId: 1, + cancelId: 1, + type: 'question', + }) + .then(async (result) => { + if (result.response === 0) { + win.webContents.send('begin-install') + appWithQuitFlag.isQuiting = true + + // Windows 上先关闭 Worker 线程,确保进程能正常退出 + // 否则 NSIS 安装器可能无法关闭旧进程 + if (platform.isWindows) { + logger.info('[Update] Windows: Closing worker before installing...') + try { + await closeWorkerAsync() + } catch (error) { + logger.error(`[Update] Failed to close worker: ${error}`) + } + } + + setTimeout(() => { + setImmediate(() => { + autoUpdater.quitAndInstall(true, true) + }) + }, 100) + } + }) + }) + + // 不需要更新 + autoUpdater.on('update-not-available', (_info) => { + // 客户端打开会默认弹一次,用isFirstShow来控制不弹 + if (isFirstShow) { + isFirstShow = false + } else { + win.webContents.send('show-message', { + type: 'success', + message: t('update.upToDate'), + }) + } + }) + + // 错误处理(网络失败时切换备用源) + autoUpdater.on('error', (err) => { + logger.error(`[Update] Update error (${currentSource}): ${err.message || err}`) + + // 默认 R2 源网络失败时,尝试切换到 GitHub + if (currentSource === 'r2' && !hasTriedFallback && isNetworkError(err)) { + hasTriedFallback = true + logger.info('[Update] R2 mirror failed, trying GitHub fallback...') + + switchToGitHub() + + // 延迟 1 秒后重试检查更新 + setTimeout(() => { + autoUpdater.checkForUpdates().catch((retryErr) => { + logger.error(`[Update] GitHub fallback check also failed: ${retryErr}`) + }) + }, 1000) + } + }) + + // 等待 3 秒再检查更新,确保窗口准备完成,用户进入系统 + setTimeout(() => { + isManualCheck = false // 自动检查 + resetToDefaultSource() // 重置为默认更新源(R2 优先) + + autoUpdater.checkForUpdates().catch((err) => { + console.log('[Update] Update check failed:', err) + }) + }, 3000) +} + +/** + * 手动检查更新 + * 手动检查时,即使是预发布版本也会显示更新弹窗 + */ +const manualCheckForUpdates = () => { + // 配置代理 + configureUpdateProxy() + + isManualCheck = true // 手动检查 + isFirstShow = false // 手动检查时,无论结果都显示提示 + resetToDefaultSource() // 重置为默认更新源(R2 优先) + + autoUpdater.checkForUpdates().catch((err) => { + console.log('[Update] Manual update check failed:', err) + logger.error(`[Update] Manual update check failed: ${err}`) + }) +} + +/** + * 模拟更新弹窗(仅用于开发测试) + * 控制台通过:window.api.app.simulateUpdate() 测试 + */ +const simulateUpdateDialog = (_win) => { + dialog.showMessageBox({ + title: t('update.newVersionTitle', { version: '9.9.9' }), + message: t('update.newVersionMessage', { version: '9.9.9' }), + detail: t('update.newVersionDetail'), + buttons: [t('update.downloadNow'), t('update.cancel')], + defaultId: 0, + cancelId: 1, + type: 'question', + noLink: true, + }) +} + +export { checkUpdate, simulateUpdateDialog, manualCheckForUpdates } diff --git a/apps/desktop/main/utils/dataDirSwitch.ts b/apps/desktop/main/utils/dataDirSwitch.ts new file mode 100644 index 000000000..41b69d6e5 --- /dev/null +++ b/apps/desktop/main/utils/dataDirSwitch.ts @@ -0,0 +1,8 @@ +export { + createPendingDataDirMigration, + isDirectoryEmptyOrMissing, + isExistingUserDataDir, + isUserDataDirSafeToUse, + runPendingDataDirMigration, +} from '@openchatlab/node-runtime' +export type { PendingDataDirMigration } from '@openchatlab/node-runtime' diff --git a/apps/desktop/main/utils/httpHeaders.ts b/apps/desktop/main/utils/httpHeaders.ts new file mode 100644 index 000000000..86dea81b1 --- /dev/null +++ b/apps/desktop/main/utils/httpHeaders.ts @@ -0,0 +1,29 @@ +import { app, session } from 'electron' +import { getDesktopAppVersion } from '../runtime-compat' + +export function buildChatLabUserAgentHeaders(): Record { + const chatLabVersion = (() => { + try { + return getDesktopAppVersion(app.getVersion()) || 'dev' + } catch { + return 'dev' + } + })() + + const chatLabTag = `ChatLab/${chatLabVersion}` + const runtimeUA = (() => { + try { + return session.defaultSession.getUserAgent() + } catch { + // 默认会话未就绪时使用 Electron 级别回退 UA + return app.userAgentFallback || '' + } + })() + const userAgent = runtimeUA.includes(chatLabTag) ? runtimeUA : `${runtimeUA} ${chatLabTag}`.trim() + + return { + // 使用运行时真实 UA,并附加 ChatLab 版本标识,避免网关按 UA 策略拦截。 + 'User-Agent': userAgent, + 'X-ChatLab-Client': chatLabTag, + } +} diff --git a/apps/desktop/main/utils/pathUtils.ts b/apps/desktop/main/utils/pathUtils.ts new file mode 100644 index 000000000..15fb0be9d --- /dev/null +++ b/apps/desktop/main/utils/pathUtils.ts @@ -0,0 +1,227 @@ +import * as fs from 'fs' +import * as path from 'path' + +// 系统关键目录列表(用于安全校验) +const DANGEROUS_PATHS = [ + // Windows 系统目录 + 'C:\\Windows', + 'C:\\Program Files', + 'C:\\Program Files (x86)', + 'C:\\ProgramData', + // Unix 系统目录 + '/usr', + '/etc', + '/bin', + '/sbin', + '/lib', + '/var', + '/boot', + '/root', + '/System', + '/Library', +] + +// 统一路径标准化(兼容 Windows 大小写差异) +function normalizePathForCompare(input: string): string { + const resolved = path.resolve(input) + const normalized = path.normalize(resolved) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +/** + * 判断 child 是否为 parent 的子目录 + */ +export function isSubPath(parent: string, child: string): boolean { + const parentPath = normalizePathForCompare(parent) + const childPath = normalizePathForCompare(child) + + if (parentPath === childPath) return false + return childPath.startsWith(`${parentPath}${path.sep}`) +} + +/** + * 检查路径是否安全(不在系统关键目录下) + */ +export function isPathSafe(targetPath: string): boolean { + const normalizedTarget = targetPath.toLowerCase().replace(/\//g, '\\') + + for (const dangerous of DANGEROUS_PATHS) { + const normalizedDangerous = dangerous.toLowerCase().replace(/\//g, '\\') + if (normalizedTarget.startsWith(normalizedDangerous)) { + return false + } + } + + return true +} + +/** + * 检查目录是否为空或包含 ChatLab 标记与关键结构 + */ +export function isDirectorySafeToUse(dirPath: string, markerFile: string, requiredDirs: string[]): boolean { + if (!fs.existsSync(dirPath)) { + return true // 目录不存在,可以安全使用 + } + + try { + const entries = fs.readdirSync(dirPath) + // 如果目录为空,可以安全使用 + if (entries.length === 0) return true + + return hasChatLabStructure(entries, markerFile, requiredDirs) + } catch { + return false + } +} + +/** + * 检查目录是否为已存在的 ChatLab 数据目录 + */ +export function isExistingChatLabDir(dirPath: string, markerFile: string, requiredDirs: string[]): boolean { + if (!fs.existsSync(dirPath)) return false + + try { + const entries = fs.readdirSync(dirPath) + return hasChatLabStructure(entries, markerFile, requiredDirs) + } catch { + return false + } +} + +/** + * 确保数据目录标记文件存在 + */ +export function ensureMarkerFile(dirPath: string, markerFile: string): void { + try { + const markerPath = path.join(dirPath, markerFile) + if (!fs.existsSync(markerPath)) { + fs.writeFileSync(markerPath, 'ChatLab Data Directory', 'utf-8') + } + } catch { + // 标记文件写入失败时静默处理 + } +} + +/** + * 递归复制目录 + */ +export function copyDirRecursive(src: string, dest: string, ensureDir: (dirPath: string) => void): void { + ensureDir(dest) + const entries = fs.readdirSync(src, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + + if (entry.isDirectory()) { + copyDirRecursive(srcPath, destPath, ensureDir) + } else { + fs.copyFileSync(srcPath, destPath) + } + } +} + +export interface CopyStats { + copied: number + skipped: number + errors: string[] +} + +/** + * 递归合并复制目录(仅复制目标不存在的文件) + * @returns 复制结果统计 + */ +export function copyDirMerge( + src: string, + dest: string, + ensureDir: (dirPath: string) => void, + stats: CopyStats = { copied: 0, skipped: 0, errors: [] } +): CopyStats { + if (!fs.existsSync(src)) return stats + + try { + ensureDir(dest) + const entries = fs.readdirSync(src, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + + try { + if (entry.isDirectory()) { + if (!fs.existsSync(destPath)) { + copyDirRecursive(srcPath, destPath, ensureDir) + stats.copied++ + } else { + copyDirMerge(srcPath, destPath, ensureDir, stats) + } + } else { + if (!fs.existsSync(destPath)) { + fs.copyFileSync(srcPath, destPath) + stats.copied++ + } else { + stats.skipped++ + } + } + } catch (error) { + const errorMsg = `复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}` + console.error('[Paths]', errorMsg) + stats.errors.push(errorMsg) + } + } + } catch (error) { + const errorMsg = `读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}` + console.error('[Paths]', errorMsg) + stats.errors.push(errorMsg) + } + + return stats +} + +/** + * 写入迁移日志到 app.log + */ +export function writeMigrationLog(logDir: string, message: string, ensureDir: (dirPath: string) => void): void { + try { + ensureDir(logDir) + const logPath = path.join(logDir, 'app.log') + const now = new Date() + const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}` + const logLine = `[${timestamp}] [MIGRATION] ${message}\n` + fs.appendFileSync(logPath, logLine, 'utf-8') + } catch { + // 日志写入失败时静默处理 + } +} + +function hasChatLabStructure(entries: string[], markerFile: string, requiredDirs: string[]): boolean { + const hasMarker = entries.includes(markerFile) + const hasRequiredDirs = requiredDirs.every((dir) => entries.includes(dir)) + return hasMarker && hasRequiredDirs +} + +/** + * 获取应用安装根目录 + * macOS: .app 包路径(如 /Applications/ChatLab.app) + * Windows/Linux: 可执行文件所在目录 + */ +export function getAppInstallDir(exePath: string): string { + if (process.platform === 'darwin') { + const appBundleMatch = exePath.match(/^(.+?\.app)(\/|$)/) + if (appBundleMatch) { + return appBundleMatch[1] + } + } + return path.dirname(exePath) +} + +/** + * 检查目标路径是否位于应用安装目录内(或等于安装目录) + */ +export function isInsideAppInstallDir(targetPath: string, exePath: string): boolean { + const installDir = getAppInstallDir(exePath) + const normalizedTarget = normalizePathForCompare(targetPath) + const normalizedInstall = normalizePathForCompare(installDir) + + return normalizedTarget === normalizedInstall || normalizedTarget.startsWith(`${normalizedInstall}${path.sep}`) +} diff --git a/apps/desktop/main/utils/unifiedDirMigration.test.ts b/apps/desktop/main/utils/unifiedDirMigration.test.ts new file mode 100644 index 000000000..da71cd82b --- /dev/null +++ b/apps/desktop/main/utils/unifiedDirMigration.test.ts @@ -0,0 +1,10 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { shouldMarkUnifiedDirMigrationDone } from './unifiedDirMigration' + +describe('desktop unified directory migration', () => { + it('marks migration done only when no directory failed', () => { + assert.equal(shouldMarkUnifiedDirMigrationDone([]), true) + assert.equal(shouldMarkUnifiedDirMigrationDone(['settings']), false) + }) +}) diff --git a/apps/desktop/main/utils/unifiedDirMigration.ts b/apps/desktop/main/utils/unifiedDirMigration.ts new file mode 100644 index 000000000..91274fd84 --- /dev/null +++ b/apps/desktop/main/utils/unifiedDirMigration.ts @@ -0,0 +1,3 @@ +export function shouldMarkUnifiedDirMigrationDone(failedDirs: string[]): boolean { + return failedDirs.length === 0 +} diff --git a/apps/desktop/main/window-titlebar.test.ts b/apps/desktop/main/window-titlebar.test.ts new file mode 100644 index 000000000..32f8989f8 --- /dev/null +++ b/apps/desktop/main/window-titlebar.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import type { BrowserWindow, TitleBarOverlayOptions } from 'electron' +import { + applyCurrentTitleBarOverlay, + applyTitleBarOverlayColor, + getTitleBarOverlayOptionsForColor, + getTitleBarOverlayOptions, + resetCurrentTitleBarOverlayColor, +} from './window-titlebar' + +describe('Windows title bar overlay options', () => { + it('keeps the native overlay background transparent in normal mode', () => { + assert.deepEqual(getTitleBarOverlayOptions(false), { + color: 'rgba(0, 0, 0, 0)', + symbolColor: '#52525b', + height: 32, + }) + assert.deepEqual(getTitleBarOverlayOptions(true), { + color: 'rgba(0, 0, 0, 0)', + symbolColor: '#d4d4d8', + height: 32, + }) + }) + + it('uses readable symbols for sampled custom colors', () => { + assert.deepEqual(getTitleBarOverlayOptionsForColor('#ffffff'), { + color: 'rgba(0, 0, 0, 0)', + symbolColor: '#3f3f46', + height: 32, + }) + assert.deepEqual(getTitleBarOverlayOptionsForColor('#111827'), { + color: 'rgba(0, 0, 0, 0)', + symbolColor: '#e4e4e7', + height: 32, + }) + }) + + it('can drop a sampled color when the effective theme changes', () => { + const calls: TitleBarOverlayOptions[] = [] + const win = { + setTitleBarOverlay: (options: TitleBarOverlayOptions) => { + calls.push(options) + }, + } as Pick as BrowserWindow + + applyTitleBarOverlayColor(win, '#111827') + resetCurrentTitleBarOverlayColor() + applyCurrentTitleBarOverlay(win, false) + + assert.deepEqual(calls.at(-1), { + color: 'rgba(0, 0, 0, 0)', + symbolColor: '#52525b', + height: 32, + }) + }) +}) diff --git a/apps/desktop/main/window-titlebar.ts b/apps/desktop/main/window-titlebar.ts new file mode 100644 index 000000000..14ec94753 --- /dev/null +++ b/apps/desktop/main/window-titlebar.ts @@ -0,0 +1,62 @@ +import type { BrowserWindow, TitleBarOverlayOptions } from 'electron' + +const TITLE_BAR_OVERLAY_HEIGHT = 32 +const TITLE_BAR_OVERLAY_COLOR = 'rgba(0, 0, 0, 0)' +let currentTitleBarOverlayColor: string | null = null + +const TITLE_BAR_OVERLAY_PALETTE = { + light: { symbolColor: '#52525b' }, + dark: { symbolColor: '#d4d4d8' }, +} as const + +export function getTitleBarOverlayOptions(isDark: boolean): TitleBarOverlayOptions { + const mode = isDark ? 'dark' : 'light' + return { + color: TITLE_BAR_OVERLAY_COLOR, + symbolColor: TITLE_BAR_OVERLAY_PALETTE[mode].symbolColor, + height: TITLE_BAR_OVERLAY_HEIGHT, + } +} + +export function getTitleBarOverlayOptionsForColor(color: string): TitleBarOverlayOptions { + return { + color: TITLE_BAR_OVERLAY_COLOR, + symbolColor: getReadableSymbolColor(color), + height: TITLE_BAR_OVERLAY_HEIGHT, + } +} + +export function applyCurrentTitleBarOverlay(win: BrowserWindow | null | undefined, isDark: boolean): void { + if (currentTitleBarOverlayColor) { + win?.setTitleBarOverlay(getTitleBarOverlayOptionsForColor(currentTitleBarOverlayColor)) + return + } + + win?.setTitleBarOverlay(getTitleBarOverlayOptions(isDark)) +} + +export function applyTitleBarOverlayColor(win: BrowserWindow | null | undefined, color: string): void { + currentTitleBarOverlayColor = color + win?.setTitleBarOverlay(getTitleBarOverlayOptionsForColor(color)) +} + +export function resetCurrentTitleBarOverlayColor(): void { + currentTitleBarOverlayColor = null +} + +function getReadableSymbolColor(hexColor: string): string { + const match = /^#([0-9a-f]{6})$/i.exec(hexColor) + if (!match) return '#52525b' + + const value = match[1] + const r = Number.parseInt(value.slice(0, 2), 16) / 255 + const g = Number.parseInt(value.slice(2, 4), 16) / 255 + const b = Number.parseInt(value.slice(4, 6), 16) / 255 + const luminance = 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b) + + return luminance < 0.45 ? '#e4e4e7' : '#3f3f46' +} + +function toLinear(value: number): number { + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 +} diff --git a/apps/desktop/main/worker/core/dbCore.ts b/apps/desktop/main/worker/core/dbCore.ts new file mode 100644 index 000000000..4c03da27d --- /dev/null +++ b/apps/desktop/main/worker/core/dbCore.ts @@ -0,0 +1,189 @@ +/** + * 数据库核心工具模块 + * 提供数据库连接管理和通用工具函数 + */ + +import Database from 'better-sqlite3' +import * as fs from 'fs' +import * as path from 'path' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '@openchatlab/core' + +let DB_DIR: string = '' +let CACHE_DIR: string = '' +let TEMP_DIR: string = '' + +// 数据库连接缓存 +const dbCache = new Map() + +export function initDbDir(dir: string, cacheDir?: string, tempDir?: string): void { + DB_DIR = dir + if (cacheDir) CACHE_DIR = cacheDir + if (tempDir) TEMP_DIR = tempDir +} + +/** + * 获取数据库文件路径 + */ +export function getDbPath(sessionId: string): string { + return path.join(DB_DIR, `${sessionId}.db`) +} + +/** + * 打开数据库(带缓存) + */ +export function openDatabase(sessionId: string): Database.Database | null { + // 检查缓存 + if (dbCache.has(sessionId)) { + return dbCache.get(sessionId)! + } + + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) { + return null + } + + const db = new Database(dbPath, { readonly: true }) + db.pragma('journal_mode = WAL') + + // 缓存连接 + dbCache.set(sessionId, db) + return db +} + +/** + * 关闭指定会话的数据库连接 + */ +export function closeDatabase(sessionId: string): void { + const db = dbCache.get(sessionId) + if (db) { + db.close() + dbCache.delete(sessionId) + } +} + +/** + * 关闭所有数据库连接 + */ +export function closeAllDatabases(): void { + for (const [sessionId, db] of dbCache.entries()) { + db.close() + dbCache.delete(sessionId) + } +} + +/** + * 获取数据库目录 + */ +export function getDbDir(): string { + return DB_DIR +} + +export function getCacheDir(): string { + return CACHE_DIR +} + +export function getTempDir(): string { + return TEMP_DIR +} + +// ==================== 时间过滤工具 ==================== + +// Re-export from shared-types +export type { TimeFilter } from '@openchatlab/shared-types' +import type { TimeFilter } from '@openchatlab/shared-types' + +/** + * 构建时间过滤 WHERE 子句 + * @param filter 时间过滤器(包含时间范围和成员筛选) + * @param tableAlias 表别名,用于多表 JOIN 场景避免列名歧义(如 'msg') + */ +export function buildTimeFilter( + filter?: TimeFilter, + tableAlias?: string +): { clause: string; params: (number | string)[] } { + const conditions: string[] = [] + const params: (number | string)[] = [] + + // 构建带别名的列名(如 'msg.ts' 或 'ts') + const tsColumn = tableAlias ? `${tableAlias}.ts` : 'ts' + const senderIdColumn = tableAlias ? `${tableAlias}.sender_id` : 'sender_id' + + if (filter?.startTs !== undefined) { + conditions.push(`${tsColumn} >= ?`) + params.push(filter.startTs) + } + if (filter?.endTs !== undefined) { + conditions.push(`${tsColumn} <= ?`) + params.push(filter.endTs) + } + // 成员筛选 + if (filter?.memberId !== undefined && filter?.memberId !== null) { + conditions.push(`${senderIdColumn} = ?`) + params.push(filter.memberId) + } + + return { + clause: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '', + params, + } +} + +/** + * 构建排除系统消息的过滤条件 + */ +export function buildSystemMessageFilter(existingClause: string): string { + // 系统消息过滤:account_name 不等于 '系统消息' + const systemFilter = "COALESCE(m.account_name, '') != '系统消息'" + + if (existingClause.includes('WHERE')) { + return existingClause + ' AND ' + systemFilter + } else { + return ' WHERE ' + systemFilter + } +} + +/** + * 将 better-sqlite3.Database 实例包装为 DatabaseAdapter 接口 + */ +export function wrapAsDatabaseAdapter(db: Database.Database): DatabaseAdapter { + return { + readonly: db.readonly, + exec(sql: string) { + db.exec(sql) + }, + prepare(sql: string): PreparedStatement { + const stmt = db.prepare(sql) + return { + readonly: stmt.readonly, + get(...params: unknown[]) { + return stmt.get(...params) as Record | undefined + }, + all(...params: unknown[]) { + return stmt.all(...params) as Record[] + }, + run(...params: unknown[]): RunResult { + const result = stmt.run(...params) + return { changes: result.changes, lastInsertRowid: result.lastInsertRowid } + }, + } + }, + transaction(fn: () => T): T { + return db.transaction(fn)() + }, + pragma(pragma: string) { + return db.pragma(pragma) + }, + close() { + db.close() + }, + } +} + +/** + * 打开数据库并返回 DatabaseAdapter(用于调用 @openchatlab/core 查询函数) + */ +export function openDatabaseAdapter(sessionId: string): DatabaseAdapter | null { + const db = openDatabase(sessionId) + if (!db) return null + return wrapAsDatabaseAdapter(db) +} diff --git a/electron/main/worker/core/index.ts b/apps/desktop/main/worker/core/index.ts similarity index 85% rename from electron/main/worker/core/index.ts rename to apps/desktop/main/worker/core/index.ts index c424383a8..6efaa151a 100644 --- a/electron/main/worker/core/index.ts +++ b/apps/desktop/main/worker/core/index.ts @@ -10,8 +10,12 @@ export { closeDatabase, closeAllDatabases, getDbDir, + getCacheDir, + getTempDir, buildTimeFilter, buildSystemMessageFilter, + wrapAsDatabaseAdapter, + openDatabaseAdapter, type TimeFilter, } from './dbCore' @@ -27,4 +31,3 @@ export { getErrorCount, LogLevel, } from './perfLogger' - diff --git a/apps/desktop/main/worker/core/perfLogger.ts b/apps/desktop/main/worker/core/perfLogger.ts new file mode 100644 index 000000000..dd2fff861 --- /dev/null +++ b/apps/desktop/main/worker/core/perfLogger.ts @@ -0,0 +1,28 @@ +/** + * Import performance logger — Electron adapter. + * + * Delegates to @openchatlab/node-runtime perf logger. + * Provides the log directory from Electron's path system. + */ + +import * as path from 'path' +import { initPerfLog as coreInitPerfLog } from '@openchatlab/node-runtime' +import { getDbDir } from './dbCore' + +export { + LogLevel, + logPerf, + logPerfDetail, + resetPerfLog, + getCurrentLogFile, + logError, + logInfo, + getErrorCount, + logSummary, +} from '@openchatlab/node-runtime' + +export function initPerfLog(sessionId: string): void { + const dbDir = getDbDir() + const logDir = path.join(path.dirname(dbDir), 'logs', 'import') + coreInitPerfLog(sessionId, logDir) +} diff --git a/apps/desktop/main/worker/dbWorker.ts b/apps/desktop/main/worker/dbWorker.ts new file mode 100644 index 000000000..0daf50ebb --- /dev/null +++ b/apps/desktop/main/worker/dbWorker.ts @@ -0,0 +1,305 @@ +/** + * 数据库 Worker 线程 + * 在独立线程中执行数据库操作,避免阻塞主进程 + * + * 本文件作为 Worker 入口,负责: + * 1. 初始化数据库目录 + * 2. 接收主进程消息 + * 3. 分发到对应的查询模块 + * 4. 返回结果 + */ + +import * as path from 'path' +import { parentPort, workerData } from 'worker_threads' +import { initDbDir, closeDatabase, closeAllDatabases, getCacheDir, getDbPath } from './core' +import { getDbFileVersion, getOrComputeAnalysisCache } from '@openchatlab/node-runtime' +import { + getAvailableYears, + getMemberActivity, + getHourlyActivity, + getDailyActivity, + getWeekdayActivity, + getMonthlyActivity, + getYearlyActivity, + getMessageLengthDistribution, + getMessageTypeDistribution, + getTimeRange, + getMemberNameHistory, + getAllSessions, + getSession, + getChatOverview, + getCatchphraseAnalysis, + getLanguagePreferenceAnalysis, + getMentionAnalysis, + getMentionGraph, + getLaughAnalysis, + getClusterGraph, + getRelationshipStats, + searchMessages, + deepSearchMessages, + getMessageContext, + getSearchMessageContext, + getRecentMessages, + getAllRecentMessages, + getConversationBetween, + getMessagesBefore, + getMessagesAfter, + // 成员管理 + getMembers, + getMembersPaginated, + updateMemberAliases, + mergeMembers, + deleteMember, + // SQL 实验室 + executeRawSQL, + getSchema, + executePluginQuery, + // 会话索引 + generateSessions, + generateIncrementalSessions, + clearSessions, + hasSessionIndex, + getSessionStats, + getAllIndexStats, + updateSessionGapThreshold, + getSessions, + getSessionsByTimeRange, + getRecentChatSessions, + getSegmentSummariesInWorker, + getSegmentMessages, + // 导出 + exportFilterResultToFile, + // NLP 查询 + getWordFrequency, + segmentText, + getPosTags, +} from './query' +import { + streamImport, + streamParseFileInfo, + analyzeIncrementalImport, + incrementalImport, + analyzeNewImport, +} from './import' +import { initNlpDir } from '@openchatlab/node-runtime' + +initDbDir(workerData.dbDir, workerData.cacheDir, workerData.tempDir) + +// 初始化 NLP 词库目录 +if (workerData.nlpDir) { + initNlpDir(workerData.nlpDir) +} + +// ==================== 分析结果缓存 ==================== + +const ANALYSIS_CACHE_PREFIX = 'analysis:' + +// 缓存版本指纹的代码维度:随发布版本变化,使查询逻辑变更后的旧缓存失效。 +const APP_VERSION: string = workerData?.appVersion ?? '' + +function getQueryCacheDir(): string { + const cacheDir = getCacheDir() + return cacheDir ? path.join(cacheDir, 'query') : '' +} + +const CACHEABLE_QUERIES = new Set([ + 'getAvailableYears', + 'getMemberActivity', + 'getHourlyActivity', + 'getDailyActivity', + 'getWeekdayActivity', + 'getMonthlyActivity', + 'getYearlyActivity', + 'getMessageLengthDistribution', + 'getMessageTypeDistribution', + 'getTimeRange', + 'getCatchphraseAnalysis', + 'getLanguagePreferenceAnalysis', + 'getMentionAnalysis', + 'getMentionGraph', + 'getLaughAnalysis', + 'getClusterGraph', + 'getWordFrequency', +]) + +function buildAnalysisCacheKey(type: string, payload: any): string { + const parts = [ANALYSIS_CACHE_PREFIX + type] + // 标准 filter 对象(大多数分析查询) + const filter = payload.filter || payload.timeFilter + if (filter) { + if (filter.startTs !== undefined) parts.push(`s${filter.startTs}`) + if (filter.endTs !== undefined) parts.push(`e${filter.endTs}`) + if (filter.memberId !== undefined && filter.memberId !== null) { + parts.push(`m${filter.memberId}`) + } + } + // 顶层 memberId(如 getWordFrequency 直接传 memberId) + if (payload.memberId !== undefined && payload.memberId !== null) parts.push(`m${payload.memberId}`) + if (payload.keywords) parts.push(`k${JSON.stringify(payload.keywords)}`) + if (payload.options) parts.push(`o${JSON.stringify(payload.options)}`) + // getWordFrequency 特有参数 + if (payload.locale) parts.push(`l${payload.locale}`) + if (payload.topN) parts.push(`n${payload.topN}`) + if (payload.minLength) parts.push(`ml${payload.minLength}`) + if (payload.posFilterMode) parts.push(`pfm${payload.posFilterMode}`) + if (payload.customPosTags?.length) parts.push(`cpt${JSON.stringify(payload.customPosTags)}`) + if (payload.posTags) parts.push(`pt${JSON.stringify(payload.posTags)}`) + if (payload.enableStopwords === false) parts.push('sw0') + if (payload.dictType && payload.dictType !== 'default') parts.push(`dt${payload.dictType}`) + if (payload.excludeWords?.length) parts.push(`ew${JSON.stringify(payload.excludeWords)}`) + return parts.join(':') +} + +// ==================== 消息处理 ==================== + +interface WorkerMessage { + id: string + type: string + payload: any +} + +// 同步消息处理器 +const syncHandlers: Record any> = { + // 基础查询 + getAvailableYears: (p) => getAvailableYears(p.sessionId), + getMemberActivity: (p) => getMemberActivity(p.sessionId, p.filter), + getHourlyActivity: (p) => getHourlyActivity(p.sessionId, p.filter), + getDailyActivity: (p) => getDailyActivity(p.sessionId, p.filter), + getWeekdayActivity: (p) => getWeekdayActivity(p.sessionId, p.filter), + getMonthlyActivity: (p) => getMonthlyActivity(p.sessionId, p.filter), + getYearlyActivity: (p) => getYearlyActivity(p.sessionId, p.filter), + getMessageLengthDistribution: (p) => getMessageLengthDistribution(p.sessionId, p.filter), + getMessageTypeDistribution: (p) => getMessageTypeDistribution(p.sessionId, p.filter), + getTimeRange: (p) => getTimeRange(p.sessionId), + getMemberNameHistory: (p) => getMemberNameHistory(p.sessionId, p.memberId), + + // 会话管理 + getAllSessions: () => getAllSessions(), + getSession: (p) => getSession(p.sessionId), + getChatOverview: (p) => getChatOverview(p.sessionId, p.topN), + closeDatabase: (p) => { + closeDatabase(p.sessionId) + return true + }, + closeAll: () => { + closeAllDatabases() + return true + }, + + // 成员管理 + getMembers: (p) => getMembers(p.sessionId), + getMembersPaginated: (p) => getMembersPaginated(p.sessionId, p.params), + updateMemberAliases: (p) => updateMemberAliases(p.sessionId, p.memberId, p.aliases), + mergeMembers: (p) => mergeMembers(p.sessionId, p.memberId1, p.memberId2), + deleteMember: (p) => deleteMember(p.sessionId, p.memberId), + + // 高级分析 + getCatchphraseAnalysis: (p) => getCatchphraseAnalysis(p.sessionId, p.filter), + getLanguagePreferenceAnalysis: (p) => getLanguagePreferenceAnalysis(p), + getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter), + getMentionGraph: (p) => getMentionGraph(p.sessionId, p.filter), + getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords), + getClusterGraph: (p) => getClusterGraph(p.sessionId, p.filter, p.options), + getRelationshipStats: (p) => getRelationshipStats(p.sessionId, p.filter, p.options), + + // AI 查询 + searchMessages: (p) => searchMessages(p.sessionId, p.keywords, p.filter, p.limit, p.offset, p.senderId), + getMessageContext: (p) => getMessageContext(p.sessionId, p.messageIds, p.contextSize), + getSearchMessageContext: (p) => getSearchMessageContext(p.sessionId, p.messageIds, p.contextBefore, p.contextAfter), + getRecentMessages: (p) => getRecentMessages(p.sessionId, p.filter, p.limit), + getAllRecentMessages: (p) => getAllRecentMessages(p.sessionId, p.filter, p.limit), + getConversationBetween: (p) => getConversationBetween(p.sessionId, p.memberId1, p.memberId2, p.filter, p.limit), + getMessagesBefore: (p) => getMessagesBefore(p.sessionId, p.beforeId, p.limit, p.filter, p.senderId, p.keywords), + getMessagesAfter: (p) => getMessagesAfter(p.sessionId, p.afterId, p.limit, p.filter, p.senderId, p.keywords), + + // SQL 实验室 + executeRawSQL: (p) => executeRawSQL(p.sessionId, p.sql), + getSchema: (p) => getSchema(p.sessionId), + + // 通用 SQL 查询 + pluginQuery: (p) => executePluginQuery(p.sessionId, p.sql, p.params), + + // 会话索引 + generateSessions: (p) => generateSessions(p.sessionId, p.gapThreshold), + generateIncrementalSessions: (p) => generateIncrementalSessions(p.sessionId, p.gapThreshold), + clearSessions: (p) => clearSessions(p.sessionId), + hasSessionIndex: (p) => hasSessionIndex(p.sessionId), + getSessionStats: (p) => getSessionStats(p.sessionId), + getAllIndexStats: (p) => getAllIndexStats(p.sessionIds), + updateSessionGapThreshold: (p) => updateSessionGapThreshold(p.sessionId, p.gapThreshold), + getSessions: (p) => getSessions(p.sessionId), + getSessionsByTimeRange: (p) => getSessionsByTimeRange(p.sessionId, p.startTs, p.endTs), + getRecentChatSessions: (p) => getRecentChatSessions(p.sessionId, p.limit), + getSegmentSummaries: (p) => getSegmentSummariesInWorker(p.sessionId, p.options), + getSegmentMessages: (p) => getSegmentMessages(p.sessionId, p.segmentId, p.limit), + + // NLP 查询 + getWordFrequency: (p) => getWordFrequency(p), + segmentText: (p) => segmentText(p.text, p.locale, p.minLength), + getPosTags: () => getPosTags(), + + // 深度搜索(LIKE 子串匹配) + deepSearchMessages: (p) => deepSearchMessages(p.sessionId, p.keywords, p.filter, p.limit, p.offset, p.senderId), +} + +// 异步消息处理器(流式操作) +const asyncHandlers: Record Promise> = { + // 流式导入 + streamImport: (p, id) => streamImport(p.filePath, id, p.formatOptions, p.externalSessionId), + // 流式解析文件信息(用于合并预览) + streamParseFileInfo: (p, id) => streamParseFileInfo(p.filePath, id), + // 增量导入 + analyzeIncrementalImport: (p, id) => analyzeIncrementalImport(p.sessionId, p.filePath, id), + incrementalImport: (p, id) => incrementalImport(p.sessionId, p.filePath, id, p.options), + // Dry-run 分析(新会话) + analyzeNewImport: (p, id) => analyzeNewImport(p.filePath, id), + exportFilterResultToFile: async (p) => exportFilterResultToFile(p), +} + +// 处理消息 +parentPort?.on('message', async (message: WorkerMessage) => { + const { id, type, payload } = message + + try { + // 检查是否是异步处理器 + const asyncHandler = asyncHandlers[type] + if (asyncHandler) { + const result = await asyncHandler(payload, id) + parentPort?.postMessage({ id, success: true, result }) + return + } + + // 同步处理器 + const syncHandler = syncHandlers[type] + if (!syncHandler) { + throw new Error(`Unknown message type: ${type}`) + } + + // 可缓存查询:按「产品版本 + DB 文件状态」派生版本指纹,命中即返回,未命中则计算并写回。 + // 与共享 HTTP 路由共用同一份 cacheDir/query 文件与文件版本失效机制,数据变更后自动失效。 + const queryCacheDir = getQueryCacheDir() + if (queryCacheDir && CACHEABLE_QUERIES.has(type) && payload.sessionId) { + const cacheKey = buildAnalysisCacheKey(type, payload) + const version = `${APP_VERSION}|${getDbFileVersion(getDbPath(payload.sessionId))}` + const result = getOrComputeAnalysisCache(payload.sessionId, cacheKey, queryCacheDir, version, () => + syncHandler(payload) + ) + parentPort?.postMessage({ id, success: true, result }) + return + } + + const result = await syncHandler(payload) + parentPort?.postMessage({ id, success: true, result }) + } catch (error) { + parentPort?.postMessage({ + id, + success: false, + error: error instanceof Error ? error.message : String(error), + }) + } +}) + +// 进程退出时关闭所有数据库连接 +process.on('exit', () => { + closeAllDatabases() +}) diff --git a/apps/desktop/main/worker/import/incrementalImport.ts b/apps/desktop/main/worker/import/incrementalImport.ts new file mode 100644 index 000000000..598b6cffd --- /dev/null +++ b/apps/desktop/main/worker/import/incrementalImport.ts @@ -0,0 +1,78 @@ +/** + * Incremental import — Electron worker adapter. + * + * Thin wrapper around @openchatlab/node-runtime IncrementalImporter. + * Provides Electron-specific wiring: worker progress IPC, better-sqlite3 + * DB open, and overview cache hook. + */ + +import * as path from 'path' +import Database from 'better-sqlite3' +import { + BetterSqliteAdapter, + analyzeIncrementalImport as sharedAnalyze, + incrementalImport as sharedImport, + computeAndSetOverviewCache, + deleteSessionCache, +} from '@openchatlab/node-runtime' +import type { + IncrementalImportDeps, + IncrementalImportResult, + IncrementalAnalyzeResult, + ImportOptions, +} from '@openchatlab/node-runtime' +import { sendProgress, getDbPath } from './utils' +import { getCacheDir } from '../core' +import * as fs from 'fs' + +export type { ImportOptions, IncrementalAnalyzeResult, IncrementalImportResult } + +function buildDeps(requestId: string): IncrementalImportDeps { + return { + openDatabase(sessionId: string, readonly?: boolean) { + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) { + throw new Error(`Session database not found: ${sessionId}`) + } + const db = new Database(dbPath, { readonly }) + db.pragma('journal_mode = WAL') + if (!readonly) db.pragma('synchronous = NORMAL') + return new BetterSqliteAdapter(db) + }, + onProgress(progress) { + sendProgress(requestId, progress) + }, + postImportHook(_db, sessionId) { + const cacheDir = getCacheDir() + try { + const dbPath = getDbPath(sessionId) + const rawDb = new Database(dbPath) + computeAndSetOverviewCache(new BetterSqliteAdapter(rawDb), sessionId, cacheDir) + rawDb.close() + } catch (err) { + // Non-fatal: getValidatedOverviewCache will recompute on next read. + console.warn('[Worker] postImportHook: failed to refresh overview cache', err) + } + if (cacheDir) { + deleteSessionCache(sessionId, path.join(cacheDir, 'query')) + } + }, + } +} + +export async function analyzeIncrementalImport( + sessionId: string, + filePath: string, + requestId: string +): Promise { + return sharedAnalyze(sessionId, filePath, buildDeps(requestId)) +} + +export async function incrementalImport( + sessionId: string, + filePath: string, + requestId: string, + options?: ImportOptions +): Promise { + return sharedImport(sessionId, filePath, buildDeps(requestId), options) +} diff --git a/apps/desktop/main/worker/import/index.ts b/apps/desktop/main/worker/import/index.ts new file mode 100644 index 000000000..b266133a0 --- /dev/null +++ b/apps/desktop/main/worker/import/index.ts @@ -0,0 +1,29 @@ +/** + * 导入模块入口 + * 统一导出流式导入相关函数和类型 + */ + +// 流式导入(核心导入功能) +export { + streamImport, + streamParseFileInfo, + analyzeNewImport, + type StreamImportResult, + type StreamParseFileInfoResult, + type AnalyzeNewImportResult, +} from './streamImport' + +// 增量导入 +export { + analyzeIncrementalImport, + incrementalImport, + type ImportOptions, + type IncrementalAnalyzeResult, + type IncrementalImportResult, +} from './incrementalImport' + +// 工具函数(供其他模块使用) +export { sendProgress, generateSessionId, getDbPath, createDatabaseWithoutIndexes, createIndexes } from './utils' + +// 临时数据库(供合并功能使用) +export { createTempDatabase, cleanupTempDatabase, generateMessageKey } from './tempDb' diff --git a/apps/desktop/main/worker/import/streamImport.ts b/apps/desktop/main/worker/import/streamImport.ts new file mode 100644 index 000000000..8145feae1 --- /dev/null +++ b/apps/desktop/main/worker/import/streamImport.ts @@ -0,0 +1,162 @@ +/** + * Streaming import — Electron worker adapter. + * + * Thin wrapper around @openchatlab/node-runtime StreamingImporter. + * Provides Electron-specific wiring: worker progress IPC, paths, + * better-sqlite3 DB creation, and overview cache hook. + */ + +import * as fs from 'fs' +import * as path from 'path' +import Database from 'better-sqlite3' +import { + BetterSqliteAdapter, + streamingImport, + analyzeNewImport as sharedAnalyzeNewImport, + streamParseFileInfo as sharedStreamParseFileInfo, + TEMP_DB_SCHEMA, + computeAndSetOverviewCache, + deleteSessionCache, +} from '@openchatlab/node-runtime' +import type { StreamImportDeps, StreamImportResult, ImportLogger } from '@openchatlab/node-runtime' +import { sendProgress, generateSessionId, getDbPath, createDatabaseWithoutIndexes } from './utils' +import { + getCacheDir, + getTempDir, + initPerfLog, + logPerf, + logPerfDetail, + resetPerfLog, + getCurrentLogFile, + logError, + logInfo, + logSummary, +} from '../core' + +export type { StreamImportResult } +export type { AnalyzeNewImportResult, StreamParseFileInfoResult } from '@openchatlab/node-runtime' +export type { SkipReasons, ImportDiagnostics } from '@openchatlab/node-runtime' + +function generateTempDbPath(sourceFilePath: string): string { + const timestamp = Date.now() + const random = Math.random().toString(36).substring(2, 8) + const baseName = path.basename(sourceFilePath, path.extname(sourceFilePath)) + const safeName = baseName.replace(/[/\\?%*:|"<>]/g, '_').substring(0, 50) + const tempDir = getTempDir() + if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true }) + return path.join(tempDir, `merge_${safeName}_${timestamp}_${random}.db`) +} + +function createTempDatabase(dbPath: string): Database.Database { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + db.pragma('synchronous = NORMAL') + db.exec(TEMP_DB_SCHEMA) + return db +} + +function buildElectronLogger(): ImportLogger { + return { + info: logInfo, + error: (message: string, err?: Error) => logError(message, err), + perf: (label: string, messageCount: number, batchSize?: number) => logPerf(label, messageCount, batchSize), + perfDetail: logPerfDetail, + summary: logSummary, + reset: resetPerfLog, + init: initPerfLog, + getCurrentLogFile, + } +} + +function buildStreamImportDeps(requestId: string): StreamImportDeps { + return { + openDatabase(sessionId: string) { + const db = createDatabaseWithoutIndexes(sessionId) + return new BetterSqliteAdapter(db) + }, + deleteDatabase(sessionId: string) { + const dbPath = getDbPath(sessionId) + for (const suffix of ['', '-wal', '-shm']) { + try { + const p = dbPath + suffix + if (fs.existsSync(p)) fs.unlinkSync(p) + } catch { + /* ignore */ + } + } + }, + onProgress(progress) { + sendProgress(requestId, progress) + }, + logger: buildElectronLogger(), + postImportHook(_db, sessionId) { + const cacheDir = getCacheDir() + try { + const dbPath = getDbPath(sessionId) + const rawDb = new Database(dbPath) + computeAndSetOverviewCache(new BetterSqliteAdapter(rawDb), sessionId, cacheDir) + rawDb.close() + } catch (err) { + console.warn('[Worker] postImportHook: failed to refresh overview cache', err) + } + if (cacheDir) { + deleteSessionCache(sessionId, path.join(cacheDir, 'query')) + } + }, + generateSessionId, + } +} + +/** + * Stream import: parse a file and write to DB with batched transactions. + */ +export async function streamImport( + filePath: string, + requestId: string, + formatOptions?: Record, + externalSessionId?: string +): Promise { + return streamingImport(filePath, buildStreamImportDeps(requestId), formatOptions, externalSessionId) +} + +/** + * Dry-run analysis: parse without writing to DB. + */ +export async function analyzeNewImport( + filePath: string, + requestId: string +): Promise<{ + totalMessages: number + totalMembers: number + meta: { name: string; platform: string; type: string } | null + error?: string +}> { + return sharedAnalyzeNewImport(filePath, (progress) => sendProgress(requestId, progress)) +} + +/** + * Parse file info into a temp DB (for merge preview). + */ +export async function streamParseFileInfo( + filePath: string, + requestId: string +): Promise<{ + name: string + format: string + platform: string + messageCount: number + memberCount: number + fileSize: number + tempDbPath: string +}> { + return sharedStreamParseFileInfo(filePath, { + createTempDatabase(sourceFilePath: string) { + const tempDbPath = generateTempDbPath(sourceFilePath) + const rawDb = createTempDatabase(tempDbPath) + return { db: new BetterSqliteAdapter(rawDb), tempDbPath } + }, + onProgress(progress) { + sendProgress(requestId, progress) + }, + }) +} diff --git a/apps/desktop/main/worker/import/tempDb.test.ts b/apps/desktop/main/worker/import/tempDb.test.ts new file mode 100644 index 000000000..769cfaa23 --- /dev/null +++ b/apps/desktop/main/worker/import/tempDb.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { generateMessageKey } from './tempDb' + +test('空字符串内容在写库归一化前后应生成同一个去重 key', () => { + const timestamp = 1710000000 + const senderPlatformId = 'user-1' + const parsedContent = '' + + const keyBeforePersist = generateMessageKey(timestamp, senderPlatformId, parsedContent) + const keyAfterPersist = generateMessageKey(timestamp, senderPlatformId, parsedContent || null) + + assert.equal(keyAfterPersist, keyBeforePersist) +}) diff --git a/apps/desktop/main/worker/import/tempDb.ts b/apps/desktop/main/worker/import/tempDb.ts new file mode 100644 index 000000000..3a9c80cba --- /dev/null +++ b/apps/desktop/main/worker/import/tempDb.ts @@ -0,0 +1,86 @@ +/** + * 临时数据库模块 + * 用于合并导入时的临时文件处理 + */ + +import Database from 'better-sqlite3' +import * as fs from 'fs' +import * as path from 'path' + +// 在 Worker 线程中,无法直接使用 electron 的 app 模块 +// 需要通过其他方式获取临时目录 +function getTempDir(): string { + // 在 worker 中,使用系统临时目录 + const tmpDir = process.env.TMPDIR || process.env.TMP || '/tmp' + const chatLabTmp = path.join(tmpDir, 'chatlab-temp') + if (!fs.existsSync(chatLabTmp)) { + fs.mkdirSync(chatLabTmp, { recursive: true }) + } + return chatLabTmp +} + +/** + * 创建临时数据库用于合并 + */ +export function createTempDatabase(): { db: Database.Database; path: string } { + const tempDir = getTempDir() + const tempPath = path.join(tempDir, `merge_${Date.now()}_${Math.random().toString(36).slice(2)}.db`) + const db = new Database(tempPath) + + db.pragma('journal_mode = WAL') + db.pragma('synchronous = NORMAL') + db.pragma('cache_size = -64000') + + // 创建临时表结构 + db.exec(` + CREATE TABLE IF NOT EXISTS member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT, + group_nickname TEXT, + aliases TEXT DEFAULT '[]', + avatar TEXT, + roles TEXT DEFAULT '[]' + ); + + CREATE TABLE IF NOT EXISTS message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_platform_id TEXT NOT NULL, + sender_account_name TEXT, + sender_group_nickname TEXT, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT, + reply_to_message_id TEXT DEFAULT NULL, + platform_message_id TEXT DEFAULT NULL + ); + `) + + return { db, path: tempPath } +} + +/** + * 关闭并清理临时数据库 + */ +export function cleanupTempDatabase(dbPath: string): void { + try { + // 尝试删除 WAL 和 SHM 文件 + const walPath = `${dbPath}-wal` + const shmPath = `${dbPath}-shm` + + if (fs.existsSync(walPath)) { + fs.unlinkSync(walPath) + } + if (fs.existsSync(shmPath)) { + fs.unlinkSync(shmPath) + } + if (fs.existsSync(dbPath)) { + fs.unlinkSync(dbPath) + } + } catch (err) { + console.error('Failed to clean up temp databases:', err) + } +} + +// Re-export the canonical dedup key generator from core +export { generateMessageKey } from '@openchatlab/core' diff --git a/apps/desktop/main/worker/import/utils.ts b/apps/desktop/main/worker/import/utils.ts new file mode 100644 index 000000000..756c49cef --- /dev/null +++ b/apps/desktop/main/worker/import/utils.ts @@ -0,0 +1,59 @@ +/** + * Import module utilities shared across stream/incremental import. + */ + +import Database from 'better-sqlite3' +import * as fs from 'fs' +import * as path from 'path' +import { parentPort } from 'worker_threads' +import { getDbDir } from '../core' +import { CHAT_DB_TABLES, CHAT_DB_INDEXES } from '@openchatlab/core' +import type { ParseProgress } from '../../parser' + +export function sendProgress(requestId: string, progress: ParseProgress): void { + parentPort?.postMessage({ + id: requestId, + type: 'progress', + payload: progress, + }) +} + +export function generateSessionId(): string { + const timestamp = Date.now() + const random = Math.random().toString(36).substring(2, 8) + return `chat_${timestamp}_${random}` +} + +export function getDbPath(sessionId: string): string { + return path.join(getDbDir(), `${sessionId}.db`) +} + +/** + * Create a database with tables only (no indexes) for fast bulk import. + * Indexes should be created via createIndexes() after import completes. + */ +export function createDatabaseWithoutIndexes(sessionId: string): Database.Database { + const dbDir = getDbDir() + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }) + } + + const dbPath = getDbPath(sessionId) + const db = new Database(dbPath) + + db.pragma('journal_mode = WAL') + db.pragma('synchronous = NORMAL') + db.pragma('cache_size = -64000') // 64MB cache for write performance + + db.exec(CHAT_DB_TABLES) + + return db +} + +/** + * Create indexes after bulk import completes. + * Uses the canonical index definitions from @openchatlab/core. + */ +export function createIndexes(db: Database.Database): void { + db.exec(CHAT_DB_INDEXES) +} diff --git a/apps/desktop/main/worker/query/advanced/index.ts b/apps/desktop/main/worker/query/advanced/index.ts new file mode 100644 index 000000000..d5c677e55 --- /dev/null +++ b/apps/desktop/main/worker/query/advanced/index.ts @@ -0,0 +1,28 @@ +/** + * 高级分析模块入口 + * 所有查询逻辑委托给 @openchatlab/core + */ + +export { getCatchphraseAnalysis } from './repeat' + +export { getMentionAnalysis, getMentionGraph, getLaughAnalysis, getClusterGraph } from './social' +export type { + MentionGraphData, + MentionGraphNode, + MentionGraphLink, + ClusterGraphData, + ClusterGraphNode, + ClusterGraphLink, + ClusterGraphOptions, +} from './social' + +export { getLanguagePreferenceAnalysis } from './languagePreference' + +export { getRelationshipStats } from './relationship' +export type { + RelationshipStats, + RelationshipMonthStats, + IceBreakerItem, + ResponseLatencyMember, + PerseveranceMember, +} from './relationship' diff --git a/apps/desktop/main/worker/query/advanced/languagePreference.ts b/apps/desktop/main/worker/query/advanced/languagePreference.ts new file mode 100644 index 000000000..2314516b1 --- /dev/null +++ b/apps/desktop/main/worker/query/advanced/languagePreference.ts @@ -0,0 +1,41 @@ +/** + * 语言偏好分析模块(委托给 @openchatlab/core) + * + * NLP 能力通过 electron/main/nlp 提供的 jieba 实例构建 NlpProvider。 + */ + +import { openDatabaseAdapter, type TimeFilter } from '../../core' +import { getLanguagePreferenceAnalysis as coreGetLanguagePreferenceAnalysis } from '@openchatlab/core' +import type { NlpProvider, PosTagResult } from '@openchatlab/core' +import { getJieba } from '@openchatlab/node-runtime' +import { isStopword, MEANINGFUL_POS_TAGS } from '@openchatlab/core' +import type { DictType } from '@openchatlab/core' + +function createWorkerNlpProvider(dictType: DictType = 'default'): NlpProvider { + return { + tag(text: string): PosTagResult[] { + const jieba = getJieba(dictType) + return jieba.tag(text) + }, + isStopword(word: string, locale: string): boolean { + return isStopword(word, locale) + }, + meaningfulPosTags: MEANINGFUL_POS_TAGS, + } +} + +interface LanguagePreferenceParams { + sessionId: string + locale: string + timeFilter?: TimeFilter + dictType?: string +} + +export function getLanguagePreferenceAnalysis(params: LanguagePreferenceParams): any { + const { sessionId, locale, timeFilter, dictType = 'default' } = params + const db = openDatabaseAdapter(sessionId) + if (!db) return { members: [], sharedWords: [], similarityScore: 0 } + + const nlpProvider = createWorkerNlpProvider(dictType as DictType) + return coreGetLanguagePreferenceAnalysis(db, { locale, timeFilter, nlpProvider }) +} diff --git a/apps/desktop/main/worker/query/advanced/relationship.ts b/apps/desktop/main/worker/query/advanced/relationship.ts new file mode 100644 index 000000000..a03caae2f --- /dev/null +++ b/apps/desktop/main/worker/query/advanced/relationship.ts @@ -0,0 +1,42 @@ +/** + * 关系分析模块(委托给 @openchatlab/core) + */ + +import { openDatabaseAdapter, type TimeFilter } from '../../core' +import { getRelationshipStats as coreGetRelationshipStats } from '@openchatlab/core' +import type { + RelationshipStats, + RelationshipMonthStats, + IceBreakerItem, + ResponseLatencyMember, + PerseveranceMember, + RelationshipOptions, +} from '@openchatlab/core' + +export type { RelationshipStats, RelationshipMonthStats, IceBreakerItem, ResponseLatencyMember, PerseveranceMember } + +export function getRelationshipStats( + sessionId: string, + filter?: TimeFilter, + options?: RelationshipOptions +): RelationshipStats { + const db = openDatabaseAdapter(sessionId) + if (!db) { + const perseveranceThreshold = options?.perseveranceThreshold ?? 300 + return { + months: [], + members: [], + totalSessions: 0, + hasSessionIndex: false, + iceBreakers: [], + totalIceBreaks: 0, + responseLatency: [], + perseverance: [], + totalDoubleTexts: 0, + monthlyResponseLatency: [], + monthlyPerseverance: [], + perseveranceThreshold, + } + } + return coreGetRelationshipStats(db, filter, options) +} diff --git a/apps/desktop/main/worker/query/advanced/repeat.ts b/apps/desktop/main/worker/query/advanced/repeat.ts new file mode 100644 index 000000000..31357dc03 --- /dev/null +++ b/apps/desktop/main/worker/query/advanced/repeat.ts @@ -0,0 +1,12 @@ +/** + * 口头禅分析模块(委托给 @openchatlab/core) + */ + +import { openDatabaseAdapter, type TimeFilter } from '../../core' +import { getCatchphraseAnalysis as coreGetCatchphraseAnalysis } from '@openchatlab/core' + +export function getCatchphraseAnalysis(sessionId: string, filter?: TimeFilter): any { + const db = openDatabaseAdapter(sessionId) + if (!db) return { members: [] } + return coreGetCatchphraseAnalysis(db, filter) +} diff --git a/apps/desktop/main/worker/query/advanced/social.ts b/apps/desktop/main/worker/query/advanced/social.ts new file mode 100644 index 000000000..79c5365f1 --- /dev/null +++ b/apps/desktop/main/worker/query/advanced/social.ts @@ -0,0 +1,74 @@ +/** + * 社交分析模块(委托给 @openchatlab/core) + */ + +import { openDatabaseAdapter, type TimeFilter } from '../../core' +import { + getMentionAnalysis as coreGetMentionAnalysis, + getMentionGraph as coreGetMentionGraph, + getLaughAnalysis as coreGetLaughAnalysis, + getClusterGraph as coreGetClusterGraph, +} from '@openchatlab/core' +import type { + MentionGraphData, + MentionGraphNode, + MentionGraphLink, + ClusterGraphData, + ClusterGraphNode, + ClusterGraphLink, + ClusterGraphOptions, +} from '@openchatlab/core' + +export type { + MentionGraphData, + MentionGraphNode, + MentionGraphLink, + ClusterGraphData, + ClusterGraphNode, + ClusterGraphLink, + ClusterGraphOptions, +} + +export function getMentionAnalysis(sessionId: string, filter?: TimeFilter): any { + const db = openDatabaseAdapter(sessionId) + if (!db) return { topMentioners: [], topMentioned: [], oneWay: [], twoWay: [], totalMentions: 0, memberDetails: [] } + return coreGetMentionAnalysis(db, filter) +} + +export function getMentionGraph(sessionId: string, filter?: TimeFilter): MentionGraphData { + const db = openDatabaseAdapter(sessionId) + if (!db) return { nodes: [], links: [], maxLinkValue: 0 } + return coreGetMentionGraph(db, filter) +} + +export function getLaughAnalysis(sessionId: string, filter?: TimeFilter, keywords?: string[]): any { + const db = openDatabaseAdapter(sessionId) + if (!db) + return { + rankByRate: [], + rankByCount: [], + typeDistribution: [], + totalLaughs: 0, + totalMessages: 0, + groupLaughRate: 0, + } + return coreGetLaughAnalysis(db, filter, keywords) +} + +export function getClusterGraph( + sessionId: string, + filter?: TimeFilter, + options?: ClusterGraphOptions +): ClusterGraphData { + const db = openDatabaseAdapter(sessionId) + if (!db) { + return { + nodes: [], + links: [], + maxLinkValue: 0, + communities: [], + stats: { totalMembers: 0, totalMessages: 0, involvedMembers: 0, edgeCount: 0, communityCount: 0 }, + } + } + return coreGetClusterGraph(db, filter, options) +} diff --git a/apps/desktop/main/worker/query/basic.ts b/apps/desktop/main/worker/query/basic.ts new file mode 100644 index 000000000..3ef309c90 --- /dev/null +++ b/apps/desktop/main/worker/query/basic.ts @@ -0,0 +1,234 @@ +/** + * 基础查询模块 + * 查询逻辑委托给 @openchatlab/core,本模块负责数据库连接管理和成员 DDL 迁移 + */ + +import Database from 'better-sqlite3' +import * as fs from 'fs' +import { closeDatabase, getDbPath, getCacheDir, openDatabaseAdapter, type TimeFilter } from '../core' +import { getCache, CACHE_KEY_OVERVIEW, type OverviewCache } from '@openchatlab/node-runtime' +import { + getAvailableYears as coreGetAvailableYears, + getMemberActivity as coreGetMemberActivity, + getHourlyActivity as coreGetHourlyActivity, + getDailyActivity as coreGetDailyActivity, + getWeekdayActivity as coreGetWeekdayActivity, + getMonthlyActivity as coreGetMonthlyActivity, + getYearlyActivity as coreGetYearlyActivity, + getMessageTypeStats as coreGetMessageTypeStats, + getMessageLengthDistribution as coreGetMessageLengthDistribution, + getTimeRange as coreGetTimeRange, + getMemberNameHistory as coreGetMemberNameHistory, + getMembersWithAliases as coreGetMembersWithAliases, + getMembersPaginated as coreGetMembersPaginated, + updateMemberAliases as coreUpdateMemberAliases, + mergeMembers as coreMergeMembers, + deleteMember as coreDeleteMember, + ensureAliasesColumn as coreEnsureAliasesColumn, + ensureAvatarColumn as coreEnsureAvatarColumn, +} from '@openchatlab/core' +import type { MembersPaginationParams, MembersPaginatedResult, MemberWithAliases } from '@openchatlab/core' +import { BetterSqliteAdapter } from '@openchatlab/node-runtime' + +// ==================== 基础查询(委托给 core) ==================== + +export function getAvailableYears(sessionId: string): number[] { + const db = openDatabaseAdapter(sessionId) + if (!db) return [] + return coreGetAvailableYears(db) +} + +export function getMemberActivity(sessionId: string, filter?: TimeFilter): any[] { + ensureAvatarColumn(sessionId) + const db = openDatabaseAdapter(sessionId) + if (!db) return [] + return coreGetMemberActivity(db, filter) +} + +export function getHourlyActivity(sessionId: string, filter?: TimeFilter): any[] { + const db = openDatabaseAdapter(sessionId) + if (!db) return [] + return coreGetHourlyActivity(db, filter) +} + +export function getDailyActivity(sessionId: string, filter?: TimeFilter): any[] { + const db = openDatabaseAdapter(sessionId) + if (!db) return [] + return coreGetDailyActivity(db, filter) +} + +export function getWeekdayActivity(sessionId: string, filter?: TimeFilter): any[] { + const db = openDatabaseAdapter(sessionId) + if (!db) return [] + return coreGetWeekdayActivity(db, filter) +} + +export function getMonthlyActivity(sessionId: string, filter?: TimeFilter): any[] { + const db = openDatabaseAdapter(sessionId) + if (!db) return [] + return coreGetMonthlyActivity(db, filter) +} + +export function getYearlyActivity(sessionId: string, filter?: TimeFilter): any[] { + const db = openDatabaseAdapter(sessionId) + if (!db) return [] + return coreGetYearlyActivity(db, filter) +} + +export function getMessageTypeDistribution(sessionId: string, filter?: TimeFilter): any[] { + const db = openDatabaseAdapter(sessionId) + if (!db) return [] + return coreGetMessageTypeStats(db, filter) +} + +export function getMessageLengthDistribution( + sessionId: string, + filter?: TimeFilter +): { + detail: Array<{ len: number; count: number }> + grouped: Array<{ range: string; count: number }> +} { + const db = openDatabaseAdapter(sessionId) + if (!db) return { detail: [], grouped: [] } + return coreGetMessageLengthDistribution(db, filter) +} + +export function getTimeRange(sessionId: string): { start: number; end: number } | null { + const overview = getCache(sessionId, CACHE_KEY_OVERVIEW, getCacheDir()) + if (overview?.firstMessageTs != null && overview?.lastMessageTs != null) { + return { start: overview.firstMessageTs, end: overview.lastMessageTs } + } + const db = openDatabaseAdapter(sessionId) + if (!db) return null + return coreGetTimeRange(db) +} + +/** + * 获取成员的历史昵称记录 (delegates to core) + */ +export function getMemberNameHistory(sessionId: string, memberId: number): any[] { + const db = openDatabaseAdapter(sessionId) + if (!db) return [] + return coreGetMemberNameHistory(db, memberId) +} + +// ==================== 成员管理 ==================== + +const aliasesCheckedSessions = new Set() +const avatarCheckedSessions = new Set() + +function ensureAliasesColumn(sessionId: string): void { + if (aliasesCheckedSessions.has(sessionId)) return + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) return + closeDatabase(sessionId) + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + try { + const adapter = new BetterSqliteAdapter(db) + if (coreEnsureAliasesColumn(adapter)) { + console.log(`[Worker] Added aliases column to member table in session ${sessionId}`) + } + aliasesCheckedSessions.add(sessionId) + } finally { + db.close() + } +} + +export function ensureAvatarColumn(sessionId: string): void { + if (avatarCheckedSessions.has(sessionId)) return + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) return + closeDatabase(sessionId) + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + try { + const adapter = new BetterSqliteAdapter(db) + if (coreEnsureAvatarColumn(adapter)) { + console.log(`[Worker] Added avatar column to member table in session ${sessionId}`) + } + avatarCheckedSessions.add(sessionId) + } finally { + db.close() + } +} + +/** + * 获取所有成员列表(含消息数、别名和头像) + * Delegates to core getMembersWithAliases after ensuring schema columns exist. + */ +export function getMembers(sessionId: string): MemberWithAliases[] { + ensureAliasesColumn(sessionId) + ensureAvatarColumn(sessionId) + + const db = openDatabaseAdapter(sessionId) + if (!db) return [] + return coreGetMembersWithAliases(db) +} + +export type { MembersPaginationParams, MembersPaginatedResult } + +/** + * 获取成员列表(分页版本,支持搜索和排序) + * Delegates to core getMembersPaginated after ensuring schema columns exist. + */ +export function getMembersPaginated(sessionId: string, params: MembersPaginationParams): MembersPaginatedResult { + ensureAliasesColumn(sessionId) + ensureAvatarColumn(sessionId) + + const db = openDatabaseAdapter(sessionId) + if (!db) { + const page = params.page ?? 1 + const pageSize = params.pageSize ?? 20 + return { members: [], total: 0, page, pageSize, totalPages: 0 } + } + return coreGetMembersPaginated(db, params) +} + +export function updateMemberAliases(sessionId: string, memberId: number, aliases: string[]): boolean { + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) return false + try { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + const adapter = new BetterSqliteAdapter(db) + const result = coreUpdateMemberAliases(adapter, memberId, aliases) + db.close() + return result + } catch (error) { + console.error('[Worker] Failed to update member aliases:', error) + return false + } +} + +export function mergeMembers(sessionId: string, memberId1: number, memberId2: number): boolean { + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) return false + try { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + const adapter = new BetterSqliteAdapter(db) + const result = coreMergeMembers(adapter, memberId1, memberId2) + db.close() + return result + } catch (error) { + console.error('[Worker] Failed to merge members:', error) + return false + } +} + +export function deleteMember(sessionId: string, memberId: number): boolean { + const dbPath = getDbPath(sessionId) + if (!fs.existsSync(dbPath)) return false + try { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + const adapter = new BetterSqliteAdapter(db) + const result = coreDeleteMember(adapter, memberId) + db.close() + return result + } catch (error) { + console.error('[Worker] Failed to delete member:', error) + return false + } +} diff --git a/apps/desktop/main/worker/query/fts.ts b/apps/desktop/main/worker/query/fts.ts new file mode 100644 index 000000000..0c68ed3b2 --- /dev/null +++ b/apps/desktop/main/worker/query/fts.ts @@ -0,0 +1,90 @@ +/** + * FTS5 full-text search index management — Electron adapter layer. + * + * Core FTS operations are delegated to @openchatlab/node-runtime. + * This module adds session-level DB connection management. + */ + +import Database from 'better-sqlite3' +import { BetterSqliteAdapter } from '@openchatlab/node-runtime' +import { + hasFtsTable, + createFtsTable as coreCreateFtsTable, + buildFtsIndex as coreBuildFtsIndex, + rebuildFtsIndex as coreRebuildFtsIndex, + insertFtsEntries as coreInsertFtsEntries, + searchByFts as coreSearchByFts, +} from '@openchatlab/node-runtime' +import { getDbPath, openDatabase } from '../core' + +function openWritableDb(sessionId: string): Database.Database | null { + const dbPath = getDbPath(sessionId) + try { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + return db + } catch { + return null + } +} + +export function hasFtsIndex(sessionId: string): boolean { + const db = openDatabase(sessionId) + if (!db) return false + try { + return hasFtsTable(new BetterSqliteAdapter(db)) + } catch { + return false + } +} + +export function createFtsTable(db: Database.Database): void { + coreCreateFtsTable(new BetterSqliteAdapter(db)) +} + +export function buildFtsIndex(sessionId: string): { indexed: number } { + const db = openWritableDb(sessionId) + if (!db) return { indexed: 0 } + + try { + return coreBuildFtsIndex(new BetterSqliteAdapter(db)) + } finally { + db.close() + } +} + +export function rebuildFtsIndex(sessionId: string): { indexed: number } { + const db = openWritableDb(sessionId) + if (!db) return { indexed: 0 } + + try { + return coreRebuildFtsIndex(new BetterSqliteAdapter(db)) + } finally { + db.close() + } +} + +export function insertFtsEntries(sessionId: string, entries: Array<{ id: number; content: string | null }>): void { + const db = openWritableDb(sessionId) + if (!db) return + + try { + coreInsertFtsEntries(new BetterSqliteAdapter(db), entries) + } finally { + db.close() + } +} + +export function searchByFts( + sessionId: string, + keywords: string[], + limit = 1000, + offset = 0 +): { rowids: number[]; total: number } { + if (keywords.length === 0) return { rowids: [], total: 0 } + + const db = openDatabase(sessionId) + if (!db) return { rowids: [], total: 0 } + + return coreSearchByFts(new BetterSqliteAdapter(db), keywords, limit, offset) +} diff --git a/apps/desktop/main/worker/query/index.ts b/apps/desktop/main/worker/query/index.ts new file mode 100644 index 000000000..050adb20b --- /dev/null +++ b/apps/desktop/main/worker/query/index.ts @@ -0,0 +1,102 @@ +/** + * 查询模块入口 + * 统一导出基础查询和高级分析函数 + */ + +// 基础查询 +export { + getAvailableYears, + getMemberActivity, + getHourlyActivity, + getDailyActivity, + getWeekdayActivity, + getMonthlyActivity, + getYearlyActivity, + getMessageLengthDistribution, + getMessageTypeDistribution, + getTimeRange, + getMemberNameHistory, + // 成员管理 + getMembers, + getMembersPaginated, + updateMemberAliases, + mergeMembers, + deleteMember, +} from './basic' + +// 会话管理(会话列表与基础信息) +export { getAllSessions, getSession, getChatOverview } from './sessions' + +// 成员分页类型 +export type { MembersPaginationParams, MembersPaginatedResult } from './basic' + +// 高级分析 +export { + getCatchphraseAnalysis, + getLanguagePreferenceAnalysis, + getMentionAnalysis, + getMentionGraph, + getLaughAnalysis, + getClusterGraph, + getRelationshipStats, +} from './advanced' + +// 小团体图类型 +export type { ClusterGraphData, ClusterGraphNode, ClusterGraphLink, ClusterGraphOptions } from './advanced' + +// 关系分析类型 +export type { + RelationshipStats, + RelationshipMonthStats, + IceBreakerItem, + ResponseLatencyMember, + PerseveranceMember, +} from './advanced' + +// 聊天记录查询 +export { + searchMessages, + deepSearchMessages, + getMessageContext, + getSearchMessageContext, + getRecentMessages, + getAllRecentMessages, + getConversationBetween, + getMessagesBefore, + getMessagesAfter, +} from './messages' + +// 聊天记录查询类型 +export type { MessageResult, PaginatedMessages, MessagesWithTotal } from './messages' + +// SQL 实验室 +export { executeRawSQL, getSchema, executePluginQuery } from './sql' +export type { SQLResult, TableSchema } from './sql' + +// 会话索引 +export { + generateSessions, + clearSessions, + hasSessionIndex, + getSessionStats, + getAllIndexStats, + updateSessionGapThreshold, + getSessions, + getSessionsByTimeRange, + getRecentChatSessions, + getSegmentSummariesInWorker, + generateIncrementalSessions, + saveSessionSummary, + getSessionSummary, + getSegmentMessages, + DEFAULT_SESSION_GAP_THRESHOLD, + // 导出功能 + exportFilterResultToFile, +} from './session' +export type { ChatSessionItem, SessionMessagesResult } from './session' + +// NLP 查询 +export { getWordFrequency, segmentText, getPosTags } from './nlp' + +// FTS 索引管理 +export { hasFtsIndex, buildFtsIndex, rebuildFtsIndex } from './fts' diff --git a/apps/desktop/main/worker/query/messages.ts b/apps/desktop/main/worker/query/messages.ts new file mode 100644 index 000000000..076a444e4 --- /dev/null +++ b/apps/desktop/main/worker/query/messages.ts @@ -0,0 +1,214 @@ +/** + * Message query module — Electron Worker adapter + * + * Thin wrapper that delegates to shared async query functions from @openchatlab/core. + * The better-sqlite3 sync calls are wrapped as an AsyncSqlExecutor. + * FTS tokenization depends on @node-rs/jieba (platform-specific); + * the SQL query itself is shared via core's searchMessagesWithFtsAsync. + */ + +import { openDatabase, type TimeFilter } from '../core' +import { ensureAvatarColumn } from './basic' +import { hasFtsIndex } from './fts' +import { tokenizeQueryForFts } from '@openchatlab/node-runtime' +import { + type MappedMessage, + type AsyncSqlExecutor, + fetchMessagesBefore, + fetchMessagesAfter, + searchMessagesLikeAsync, + searchMessagesWithFtsAsync, + fetchMessageContext, + fetchSearchMessageContext, + fetchAllRecentMessages, + fetchRecentTextMessages, + fetchConversationBetween, +} from '@openchatlab/core' + +// ==================== Types ==================== + +export type MessageResult = MappedMessage + +export interface PaginatedMessages { + messages: MessageResult[] + hasMore: boolean +} + +export interface MessagesWithTotal { + messages: MessageResult[] + total: number +} + +// ==================== Executor adapter ==================== + +function createSyncExecutor(sessionId: string): AsyncSqlExecutor | null { + const db = openDatabase(sessionId) + if (!db) return null + return { + all(sql: string, params: unknown[] = []): Promise { + return Promise.resolve(db.prepare(sql).all(...params) as T[]) + }, + get(sql: string, params: unknown[] = []): Promise { + return Promise.resolve(db.prepare(sql).get(...params) as T | undefined) + }, + } +} + +// ==================== Query functions ==================== + +/** + * Get recent text-only messages (AI Agent use — excludes system and non-text). + */ +export async function getRecentMessages( + sessionId: string, + filter?: TimeFilter, + limit: number = 100 +): Promise { + ensureAvatarColumn(sessionId) + const executor = createSyncExecutor(sessionId) + if (!executor) return { messages: [], total: 0 } + return fetchRecentTextMessages(executor, filter, limit) +} + +/** + * Get all recent messages (message viewer — includes all types). + */ +export async function getAllRecentMessages( + sessionId: string, + filter?: TimeFilter, + limit: number = 100 +): Promise { + ensureAvatarColumn(sessionId) + const executor = createSyncExecutor(sessionId) + if (!executor) return { messages: [], total: 0 } + return fetchAllRecentMessages(executor, filter, limit) +} + +/** + * Keyword search with optional FTS5 acceleration. + * FTS path remains Electron-specific; LIKE fallback delegates to core. + */ +export async function searchMessages( + sessionId: string, + keywords: string[], + filter?: TimeFilter, + limit: number = 20, + offset: number = 0, + senderId?: number +): Promise { + ensureAvatarColumn(sessionId) + const executor = createSyncExecutor(sessionId) + if (!executor) return { messages: [], total: 0 } + + const useFts = keywords.length > 0 && hasFtsIndex(sessionId) + let matchQuery = '' + if (useFts) { + matchQuery = tokenizeQueryForFts(keywords) + } + + if (useFts && matchQuery) { + try { + return await searchMessagesWithFtsAsync(executor, matchQuery, filter, limit, offset, senderId) + } catch (error) { + console.error('[FTS] searchMessages FTS path failed, falling back to LIKE:', error) + } + } + + return searchMessagesLikeAsync(executor, keywords, filter, limit, offset, senderId) +} + +/** + * Deep search (always LIKE, no FTS). + */ +export async function deepSearchMessages( + sessionId: string, + keywords: string[], + filter?: TimeFilter, + limit: number = 20, + offset: number = 0, + senderId?: number +): Promise { + ensureAvatarColumn(sessionId) + const executor = createSyncExecutor(sessionId) + if (!executor) return { messages: [], total: 0 } + return searchMessagesLikeAsync(executor, keywords, filter, limit, offset, senderId) +} + +/** + * Get message context (surrounding messages by id). + */ +export async function getMessageContext( + sessionId: string, + messageIds: number | number[], + contextSize: number = 20 +): Promise { + ensureAvatarColumn(sessionId) + const executor = createSyncExecutor(sessionId) + if (!executor) return [] + return fetchMessageContext(executor, messageIds, contextSize) +} + +/** + * Get search message context (session-aware with fallback). + */ +export async function getSearchMessageContext( + sessionId: string, + messageIds: number[], + contextBefore: number = 2, + contextAfter: number = 2 +): Promise { + ensureAvatarColumn(sessionId) + const executor = createSyncExecutor(sessionId) + if (!executor) return [] + return fetchSearchMessageContext(executor, messageIds, contextBefore, contextAfter) +} + +/** + * Fetch N messages before a given id (infinite scroll up). + */ +export async function getMessagesBefore( + sessionId: string, + beforeId: number, + limit: number = 50, + filter?: TimeFilter, + senderId?: number, + keywords?: string[] +): Promise { + ensureAvatarColumn(sessionId) + const executor = createSyncExecutor(sessionId) + if (!executor) return { messages: [], hasMore: false } + return fetchMessagesBefore(executor, beforeId, limit, filter, senderId, keywords) +} + +/** + * Fetch N messages after a given id (infinite scroll down). + */ +export async function getMessagesAfter( + sessionId: string, + afterId: number, + limit: number = 50, + filter?: TimeFilter, + senderId?: number, + keywords?: string[] +): Promise { + ensureAvatarColumn(sessionId) + const executor = createSyncExecutor(sessionId) + if (!executor) return { messages: [], hasMore: false } + return fetchMessagesAfter(executor, afterId, limit, filter, senderId, keywords) +} + +/** + * Get conversation between two members. + */ +export async function getConversationBetween( + sessionId: string, + memberId1: number, + memberId2: number, + filter?: TimeFilter, + limit: number = 100 +): Promise { + ensureAvatarColumn(sessionId) + const executor = createSyncExecutor(sessionId) + if (!executor) return { messages: [], total: 0, member1Name: '', member2Name: '' } + return fetchConversationBetween(executor, memberId1, memberId2, filter, limit) +} diff --git a/apps/desktop/main/worker/query/nlp.ts b/apps/desktop/main/worker/query/nlp.ts new file mode 100644 index 000000000..5c649ae7f --- /dev/null +++ b/apps/desktop/main/worker/query/nlp.ts @@ -0,0 +1,26 @@ +/** + * NLP 查询模块 + * + * Electron Worker 的 NLP 入口。 + * 负责从 worker DB 池获取数据库实例,实际计算委托给 @openchatlab/node-runtime。 + */ + +import { openDatabaseAdapter } from '../core' +import { computeWordFrequency, segmentText as _segmentText, getPosTagDefinitions } from '@openchatlab/node-runtime' +import type { SupportedLocale, WordFrequencyResult, WordFrequencyParams, PosTagInfo } from '@openchatlab/core' + +export function getWordFrequency(params: WordFrequencyParams): WordFrequencyResult { + const db = openDatabaseAdapter(params.sessionId) + if (!db) { + return { words: [], totalWords: 0, totalMessages: 0, uniqueWords: 0 } + } + return computeWordFrequency(db, params) +} + +export function segmentText(text: string, locale: SupportedLocale, minLength?: number): string[] { + return _segmentText(text, locale, minLength) +} + +export function getPosTags(): PosTagInfo[] { + return getPosTagDefinitions() +} diff --git a/apps/desktop/main/worker/query/session/aiTools.ts b/apps/desktop/main/worker/query/session/aiTools.ts new file mode 100644 index 000000000..f113fee2c --- /dev/null +++ b/apps/desktop/main/worker/query/session/aiTools.ts @@ -0,0 +1,32 @@ +/** + * AI tool session queries — Electron worker wrappers. + * Core search/messages logic lives in @openchatlab/core; + * Electron adds FTS tokenization and DB lifecycle. + */ + +import { getSegmentMessages as coreGetSessionMessages } from '@openchatlab/core' +import type { SegmentMessagesData } from '@openchatlab/core' +import { openReadonlyDatabase } from './core' +import { wrapAsDatabaseAdapter } from '../../core' + +// Re-export core types under Electron-local aliases +export type { SegmentMessagesData as SessionMessagesResult } + +export function getSegmentMessages( + sessionId: string, + segmentId: number, + limit: number = 500 +): SegmentMessagesData | null { + const db = openReadonlyDatabase(sessionId) + if (!db) return null + + try { + const adapter = wrapAsDatabaseAdapter(db) + return coreGetSessionMessages(adapter, segmentId, limit) + } catch (error) { + console.error('getSegmentMessages error:', error) + return null + } finally { + db.close() + } +} diff --git a/apps/desktop/main/worker/query/session/core.ts b/apps/desktop/main/worker/query/session/core.ts new file mode 100644 index 000000000..53cf17091 --- /dev/null +++ b/apps/desktop/main/worker/query/session/core.ts @@ -0,0 +1,39 @@ +/** + * 会话模块核心工具函数 + * 提供数据库连接等共享功能 + */ + +import Database from 'better-sqlite3' +import { getDbPath, closeDatabase } from '../../core' + +// 重新导出 closeDatabase 供其他模块使用 +export { closeDatabase } + +/** + * 打开数据库(可写模式,不使用缓存) + * 会话索引需要写入数据 + */ +export function openWritableDatabase(sessionId: string): Database.Database | null { + const dbPath = getDbPath(sessionId) + try { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + return db + } catch { + return null + } +} + +/** + * 打开数据库(只读模式,不使用缓存) + */ +export function openReadonlyDatabase(sessionId: string): Database.Database | null { + const dbPath = getDbPath(sessionId) + try { + const db = new Database(dbPath, { readonly: true }) + db.pragma('journal_mode = WAL') + return db + } catch { + return null + } +} diff --git a/apps/desktop/main/worker/query/session/export.ts b/apps/desktop/main/worker/query/session/export.ts new file mode 100644 index 000000000..ad6f1deac --- /dev/null +++ b/apps/desktop/main/worker/query/session/export.ts @@ -0,0 +1,45 @@ +/** + * Export module — Electron worker adapter. + * + * Uses @openchatlab/node-runtime format exporter for multi-format output. + * Provides Electron-specific wiring: filesystem write and readonly DB opening. + */ + +import * as fs from 'fs' +import * as path from 'path' +import { BetterSqliteAdapter } from '@openchatlab/node-runtime' +import { exportWithFormat } from '@openchatlab/node-runtime' +import type { ExportFormat } from '@openchatlab/node-runtime' +import { openReadonlyDatabase } from './core' + +export function exportFilterResultToFile(params: { + sessionId: string + sessionName: string + outputDir: string + format?: ExportFormat + timeFilter?: { startTs: number; endTs: number } +}): { success: boolean; filePath?: string; error?: string } { + const format: ExportFormat = params.format || 'txt' + + const result = exportWithFormat( + { + sessionId: params.sessionId, + sessionName: params.sessionName, + format, + timeFilter: params.timeFilter, + }, + (sessionId) => { + const rawDb = openReadonlyDatabase(sessionId) + if (!rawDb) return null + return new BetterSqliteAdapter(rawDb) + } + ) + + if (!result.success) { + return { success: false, error: result.error } + } + + const filePath = path.join(params.outputDir, result.filename) + fs.writeFileSync(filePath, result.content, 'utf8') + return { success: true, filePath } +} diff --git a/apps/desktop/main/worker/query/session/index.ts b/apps/desktop/main/worker/query/session/index.ts new file mode 100644 index 000000000..db5b733c1 --- /dev/null +++ b/apps/desktop/main/worker/query/session/index.ts @@ -0,0 +1,32 @@ +/** + * 会话模块统一导出 + * 提供会话索引管理、AI 工具查询、自定义筛选和导出等功能 + */ + +// Types — core types re-exported via ./types, Electron-only types kept there +export type { ChatSessionItem, SessionIndexStats, SessionMessagesResult } from './types' + +export { DEFAULT_SESSION_GAP_THRESHOLD } from './types' + +// 会话索引管理 +export { + generateSessions, + generateIncrementalSessions, + clearSessions, + hasSessionIndex, + getSessionStats, + getAllIndexStats, + updateSessionGapThreshold, + getSessions, + getSessionsByTimeRange, + getRecentChatSessions, + getSegmentSummariesInWorker, + saveSessionSummary, + getSessionSummary, +} from './sessionIndex' + +// AI 工具专用查询 +export { getSegmentMessages } from './aiTools' + +// 导出功能 +export { exportFilterResultToFile } from './export' diff --git a/apps/desktop/main/worker/query/session/sessionIndex.ts b/apps/desktop/main/worker/query/session/sessionIndex.ts new file mode 100644 index 000000000..89c7338be --- /dev/null +++ b/apps/desktop/main/worker/query/session/sessionIndex.ts @@ -0,0 +1,127 @@ +/** + * Session index management — Electron worker wrappers. + * Pure SQL logic lives in @openchatlab/core; this module handles + * DB connection lifecycle (open / close / cache invalidation). + */ + +import { + DEFAULT_SESSION_GAP_THRESHOLD, + hasSessionIndex as coreHasSessionIndex, + getSessionIndexStats as coreGetSessionIndexStats, + getChatSessionList as coreGetChatSessionList, + getSessionsByTimeRange as coreGetSessionsByTimeRange, + getRecentChatSessions as coreGetRecentChatSessions, + getSegmentSummary as coreGetChatSessionSummary, + saveSegmentSummary as coreSaveChatSessionSummary, + updateSessionGapThreshold as coreUpdateSessionGapThreshold, + clearSessionIndex as coreClearSessionIndex, + generateSessionIndex as coreGenerateSessionIndex, + generateIncrementalSessionIndex as coreGenerateIncrementalSessionIndex, + getSegmentSummaries as coreGetSessionSummaries, +} from '@openchatlab/core' +import type { ChatSessionItem, SessionIndexStats, SegmentSummaryData } from '@openchatlab/core' +import { openWritableDatabase, openReadonlyDatabase, closeDatabase } from './core' +import { wrapAsDatabaseAdapter } from '../../core' + +// Re-export types so existing Electron imports keep working +export type { ChatSessionItem, SessionIndexStats } + +function withReadonlyAdapter( + sessionId: string, + fn: (adapter: import('@openchatlab/core').DatabaseAdapter) => T, + fallback: T +): T { + const db = openReadonlyDatabase(sessionId) + if (!db) return fallback + try { + return fn(wrapAsDatabaseAdapter(db)) + } finally { + db.close() + } +} + +function withWritableAdapter(sessionId: string, fn: (adapter: import('@openchatlab/core').DatabaseAdapter) => T): T { + closeDatabase(sessionId) + const db = openWritableDatabase(sessionId) + if (!db) throw new Error(`Cannot open writable database: ${sessionId}`) + try { + return fn(wrapAsDatabaseAdapter(db)) + } finally { + db.close() + } +} + +export function generateSessions( + sessionId: string, + gapThreshold: number = DEFAULT_SESSION_GAP_THRESHOLD, + onProgress?: (current: number, total: number) => void +): number { + return withWritableAdapter(sessionId, (adapter) => coreGenerateSessionIndex(adapter, gapThreshold, onProgress)) +} + +export function generateIncrementalSessions( + sessionId: string, + gapThreshold: number = DEFAULT_SESSION_GAP_THRESHOLD +): number { + return withWritableAdapter(sessionId, (adapter) => coreGenerateIncrementalSessionIndex(adapter, gapThreshold)) +} + +export function clearSessions(sessionId: string): void { + withWritableAdapter(sessionId, (adapter) => coreClearSessionIndex(adapter)) +} + +export function hasSessionIndex(sessionId: string): boolean { + return withReadonlyAdapter(sessionId, (adapter) => coreHasSessionIndex(adapter), false) +} + +export function getSessionStats(sessionId: string): SessionIndexStats { + return withReadonlyAdapter(sessionId, (adapter) => coreGetSessionIndexStats(adapter), { + sessionCount: 0, + hasIndex: false, + gapThreshold: DEFAULT_SESSION_GAP_THRESHOLD, + }) +} + +export interface SessionIndexStatusItem { + sessionId: string + hasIndex: boolean + sessionCount: number +} + +export function getAllIndexStats(sessionIds: string[]): SessionIndexStatusItem[] { + return sessionIds.map((sessionId) => { + const stats = getSessionStats(sessionId) + return { sessionId, hasIndex: stats.hasIndex, sessionCount: stats.sessionCount } + }) +} + +export function updateSessionGapThreshold(sessionId: string, gapThreshold: number | null): void { + withWritableAdapter(sessionId, (adapter) => coreUpdateSessionGapThreshold(adapter, gapThreshold)) +} + +export function getSessions(sessionId: string): ChatSessionItem[] { + return withReadonlyAdapter(sessionId, (adapter) => coreGetChatSessionList(adapter), []) +} + +export function getSessionsByTimeRange(sessionId: string, startTs: number, endTs: number): ChatSessionItem[] { + return withReadonlyAdapter(sessionId, (adapter) => coreGetSessionsByTimeRange(adapter, startTs, endTs), []) +} + +export function getRecentChatSessions(sessionId: string, limit: number): ChatSessionItem[] { + return withReadonlyAdapter(sessionId, (adapter) => coreGetRecentChatSessions(adapter, limit), []) +} + +export function getSegmentSummariesInWorker( + sessionId: string, + options?: { limit?: number; timeFilter?: { startTs: number; endTs: number } } +): SegmentSummaryData[] { + return withReadonlyAdapter(sessionId, (adapter) => coreGetSessionSummaries(adapter, options), []) +} + +export function saveSessionSummary(sessionId: string, segmentId: number, summary: string): void { + withWritableAdapter(sessionId, (adapter) => coreSaveChatSessionSummary(adapter, segmentId, summary)) +} + +export function getSessionSummary(sessionId: string, segmentId: number): string | null { + return withReadonlyAdapter(sessionId, (adapter) => coreGetChatSessionSummary(adapter, segmentId), null) +} diff --git a/apps/desktop/main/worker/query/session/types.ts b/apps/desktop/main/worker/query/session/types.ts new file mode 100644 index 000000000..14c982ed4 --- /dev/null +++ b/apps/desktop/main/worker/query/session/types.ts @@ -0,0 +1,11 @@ +/** + * Session module type definitions. + * Core types (ChatSessionItem, DEFAULT_SESSION_GAP_THRESHOLD) are re-exported + * from @openchatlab/core; Electron-only types remain here. + */ + +export { DEFAULT_SESSION_GAP_THRESHOLD } from '@openchatlab/core' +export type { ChatSessionItem, SessionIndexStats } from '@openchatlab/core' + +// AI tool types — re-exported from core via aiTools.ts +export type { SessionMessagesResult } from './aiTools' diff --git a/apps/desktop/main/worker/query/sessions.ts b/apps/desktop/main/worker/query/sessions.ts new file mode 100644 index 000000000..52e71fdef --- /dev/null +++ b/apps/desktop/main/worker/query/sessions.ts @@ -0,0 +1,193 @@ +/** + * 会话管理查询模块 + * 负责会话列表与单会话基础信息查询 + * + * Core session info logic delegated to @openchatlab/core (buildSessionInfo, getSessionMeta, etc.). + * This file retains Electron-specific concerns: filesystem scanning, caching, private chat avatar, + * dbPath, aiConversationCount, and the getChatOverview cache-first pattern. + */ + +import Database from 'better-sqlite3' +import { BetterSqliteAdapter } from '@openchatlab/node-runtime' +import * as fs from 'fs' +import * as path from 'path' +import { openDatabase, getDbDir, getDbPath, getCacheDir } from '../core' +import { + getCache, + getValidatedOverviewCache, + computeAndSetMembersCache, + CACHE_KEY_MEMBERS, + type MembersCache, +} from '@openchatlab/node-runtime' +import { + getSessionMeta, + getSessionOverview, + buildSessionInfo, + getSummaryCount, + getLastPlatformMessageId, + getPrivateChatMemberAvatar, + type SessionOverview, +} from '@openchatlab/core' +import type { DatabaseAdapter } from '@openchatlab/core' + +/** + * Wrap a better-sqlite3 Database as a DatabaseAdapter for core query functions. + */ +function asCoreDb(db: Database.Database): DatabaseAdapter { + return db as unknown as DatabaseAdapter +} + +/** + * 判断是否为聊天会话数据库(local fast check, avoids core import overhead for non-session DBs) + */ +function isChatSessionDb(db: Database.Database): boolean { + const requiredTableCount = db + .prepare("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name IN ('meta', 'member', 'message')") + .get() as { cnt: number } + return requiredTableCount.cnt === 3 +} + +/** + * Resolve overview with fingerprint-validated cache, falling back to live SQL. + * + * Uses getValidatedOverviewCache which checks MAX(message.id) so the cache is + * automatically recomputed whenever new messages are inserted (e.g. after + * incremental import), without relying on the import hook always succeeding. + */ +function resolveOverview(db: Database.Database, sessionId: string, cacheDir: string | null): SessionOverview { + if (cacheDir) { + try { + return getValidatedOverviewCache(new BetterSqliteAdapter(db), sessionId, cacheDir) + } catch { + // cache compute failure — fall through to live query + } + } + return getSessionOverview(asCoreDb(db)) +} + +/** + * 获取所有会话列表 + */ +export function getAllSessions(): any[] { + const dbDir = getDbDir() + if (!fs.existsSync(dbDir)) { + return [] + } + + const sessions: any[] = [] + const files = fs.readdirSync(dbDir).filter((f) => f.endsWith('.db')) + + for (const file of files) { + const sessionId = file.replace('.db', '') + const dbPath = path.join(dbDir, file) + + try { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + + if (!isChatSessionDb(db)) { + db.close() + continue + } + + const coreDb = asCoreDb(db) + const meta = getSessionMeta(coreDb) + + if (meta) { + const cacheDir = getCacheDir() + const overview = resolveOverview(db, sessionId, cacheDir) + const summaryCount = getSummaryCount(coreDb) + const info = buildSessionInfo(meta, overview, summaryCount) + + let memberAvatar: string | null = null + if (meta.type === 'private') { + memberAvatar = getPrivateChatMemberAvatar(asCoreDb(db), meta.name, meta.ownerId) + } + + sessions.push({ + ...info, + id: sessionId, + dbPath, + memberAvatar, + aiConversationCount: 0, + }) + } + + db.close() + } catch (error) { + console.error(`[Worker] Failed to read database ${file}:`, error) + } + } + + return sessions.sort((a, b) => b.importedAt - a.importedAt) +} + +/** + * 获取单个会话信息 + */ +export function getSession(sessionId: string): any | null { + const db = openDatabase(sessionId) + if (!db) return null + + const coreDb = asCoreDb(db) + const meta = getSessionMeta(coreDb) + if (!meta) return null + + const overview = resolveOverview(db, sessionId, getCacheDir()) + const info = buildSessionInfo(meta, overview) + + return { + ...info, + id: sessionId, + dbPath: getDbPath(sessionId), + firstTimestamp: overview.firstMessageTs, + lastTimestamp: overview.lastMessageTs, + lastPlatformMessageId: getLastPlatformMessageId(coreDb), + } +} + +/** + * 获取聊天概览(AI 工具使用) + * Cache-first: overview cache + members cache, fallback to live SQL via core. + */ +export function getChatOverview(sessionId: string, topN: number = 10) { + const db = openDatabase(sessionId) + if (!db) return null + + const coreDb = asCoreDb(db) + const meta = getSessionMeta(coreDb) + if (!meta) return null + + const cacheDir = getCacheDir() + const overview = resolveOverview(db, sessionId, cacheDir) + const summaryCount = getSummaryCount(coreDb) + + let membersCache = getCache(sessionId, CACHE_KEY_MEMBERS, cacheDir) + if (!membersCache) { + try { + membersCache = computeAndSetMembersCache(new BetterSqliteAdapter(db), sessionId, cacheDir) + } catch { + // fallback: no member data + } + } + + let topMembers: Array<{ id: number; name: string; count: number }> = [] + if (membersCache?.members) { + topMembers = Object.entries(membersCache.members) + .map(([id, stat]) => ({ id: Number(id), name: stat.name, count: stat.count })) + .sort((a, b) => b.count - a.count) + .slice(0, topN) + } + + return { + name: meta.name, + platform: meta.platform, + type: meta.type, + totalMessages: overview.totalMessages, + totalMembers: overview.totalMembers, + firstMessageTs: overview.firstMessageTs, + lastMessageTs: overview.lastMessageTs, + topMembers, + summaryCount, + } +} diff --git a/apps/desktop/main/worker/query/sql.ts b/apps/desktop/main/worker/query/sql.ts new file mode 100644 index 000000000..7a3c3d3c1 --- /dev/null +++ b/apps/desktop/main/worker/query/sql.ts @@ -0,0 +1,77 @@ +/** + * SQL Lab query module — Electron adapter. + * + * Delegates to @openchatlab/core's unified executeSql and getSchemaDetailed. + * Keeps the legacy SQLResult shape for backward-compatible IPC responses. + */ + +import { executeSql, getSchemaDetailed } from '@openchatlab/core' +import type { TableSchema } from '@openchatlab/core' +import { openDatabaseAdapter } from '../core' + +export type { TableSchema } + +export interface SQLResult { + columns: string[] + rows: unknown[][] + rowCount: number + duration: number + limited: boolean +} + +function ensureAdapter(sessionId: string) { + const adapter = openDatabaseAdapter(sessionId) + if (!adapter) throw new Error('Database not found') + return adapter +} + +export function getSchema(sessionId: string): TableSchema[] { + return getSchemaDetailed(ensureAdapter(sessionId)) +} + +/** + * Plugin-style parameterized readonly query. + * Uses stmt.readonly via the adapter for safety. + */ +export function executePluginQuery>( + sessionId: string, + sql: string, + params: unknown[] | Record = [] +): T[] { + const adapter = ensureAdapter(sessionId) + const stmt = adapter.prepare(sql.trim()) + + if (stmt.readonly === false) { + throw new Error('Plugin Security Violation: Only READ-ONLY statements are allowed.') + } + + if (Array.isArray(params)) { + return stmt.all(...params) as T[] + } + return stmt.all(params) as T[] +} + +/** + * Execute user SQL (SQL Lab). + * Returns columnar format with timing for the legacy IPC contract. + */ +export function executeRawSQL(sessionId: string, sql: string): SQLResult { + const adapter = ensureAdapter(sessionId) + + try { + const result = executeSql(adapter, sql, { columnar: true, timing: true, maxRows: 0 }) + return { + columns: result.columns, + rows: result.rows as unknown[][], + rowCount: result.rowCount, + duration: result.duration ?? 0, + limited: result.truncated, + } + } catch (error) { + if (error instanceof Error) { + const message = error.message.replace(/^SQLITE_ERROR: /, '').replace(/^SQLITE_READONLY: /, '') + throw new Error(message) + } + throw error + } +} diff --git a/apps/desktop/main/worker/workerManager.ts b/apps/desktop/main/worker/workerManager.ts new file mode 100644 index 000000000..7a826d9c5 --- /dev/null +++ b/apps/desktop/main/worker/workerManager.ts @@ -0,0 +1,968 @@ +/** + * Worker 管理器 + * 负责创建、管理 Worker 线程,并处理与主进程的通信 + */ + +import { Worker } from 'worker_threads' +import { app } from 'electron' +import * as path from 'path' +import * as fs from 'fs' +import type { ParseProgress } from '../parser' +import type { StreamImportResult } from './import' + +import { getDatabaseDir, getCacheDir, getTempDir, ensureDir } from '../paths' +import { getNlpDir } from '../nlp/dictManager' +import { assertDesktopDataDirCompatible, getDesktopAppVersion } from '../runtime-compat' +import { getPathProvider } from '../path-context' +import { raiseChatDbCompatibilityGate } from '@openchatlab/node-runtime' +import { isRestartableReadOnlyRequestType } from './workerTimeoutPolicy' + +interface WorkerRequestOptions { + timeoutMs?: number + restartOnTimeout?: boolean +} + +// Worker 实例 +let worker: Worker | null = null + +// 等待中的请求 Map +const pendingRequests = new Map< + string, + { + resolve: (value: any) => void + reject: (error: Error) => void + timeout: ReturnType + restartOnTimeout: boolean + onProgress?: (progress: ParseProgress) => void // 进度回调 + } +>() + +// 请求 ID 计数器 +let requestIdCounter = 0 + +function hasProgressRequest(): boolean { + for (const pending of pendingRequests.values()) { + if (pending.onProgress) return true + } + return false +} + +function rejectAllPending(error: Error): void { + for (const [id, pending] of pendingRequests.entries()) { + clearTimeout(pending.timeout) + pending.reject(error) + pendingRequests.delete(id) + } +} + +function hasNonRestartableRequest(): boolean { + for (const pending of pendingRequests.values()) { + if (!pending.restartOnTimeout) return true + } + return false +} + +function terminateWorkerAfterQueryTimeout(type: string, timedOutWorker: Worker): void { + if (!worker || worker !== timedOutWorker) return + + if (hasProgressRequest()) { + console.warn(`[WorkerManager] Query timed out while a progress task is active; keeping worker alive: ${type}`) + return + } + + if (hasNonRestartableRequest()) { + console.warn(`[WorkerManager] Query timed out while non-restartable work is active; keeping worker alive: ${type}`) + return + } + + console.warn(`[WorkerManager] Restarting worker after request timeout: ${type}`) + worker = null + rejectAllPending(new Error(`Worker restarted after request timeout: ${type}`)) + timedOutWorker.terminate().catch((error) => { + console.error('[WorkerManager] Failed to terminate timed-out worker:', error) + }) +} + +/** + * 获取数据库目录 + */ +function getDbDir(): string { + const dir = getDatabaseDir() + ensureDir(dir) + return dir +} + +function assertDataDirCompatibleNow(): void { + assertDesktopDataDirCompatible(getPathProvider(), getDesktopAppVersion(app.getVersion())) +} + +/** + * 获取 Worker 文件路径 + * 开发环境和生产环境路径不同 + */ +function getWorkerPath(): string { + // 检查是否在开发环境 + const isDev = !app.isPackaged + + if (isDev) { + // 开发环境:编译后的 JS 文件在 out/main 目录 + return path.join(__dirname, 'worker', 'dbWorker.js') + } else { + // 生产环境:打包后的路径 + return path.join(__dirname, 'worker', 'dbWorker.js') + } +} + +/** + * 初始化 Worker + */ +export function initWorker(): void { + if (worker) { + console.log('[WorkerManager] Worker already initialized') + return + } + + const workerPath = getWorkerPath() + console.log('[WorkerManager] Initializing worker at:', workerPath) + + try { + const initializedWorker = new Worker(workerPath, { + workerData: { + dbDir: getDbDir(), + cacheDir: getCacheDir(), + tempDir: getTempDir(), + nlpDir: getNlpDir(), + appVersion: getDesktopAppVersion(app.getVersion()), + }, + }) + worker = initializedWorker + + // 监听 Worker 消息 + initializedWorker.on('message', (message) => { + const { id, type, success, result, error, payload } = message + + const pending = pendingRequests.get(id) + if (!pending) return + + // 处理进度消息(不删除 pending,因为还没完成) + if (type === 'progress') { + if (pending.onProgress) { + pending.onProgress(payload) + } + return + } + + // 处理完成或错误消息 + pendingRequests.delete(id) + clearTimeout(pending.timeout) + + if (success) { + pending.resolve(result) + } else { + pending.reject(new Error(error)) + } + }) + + // 监听 Worker 错误 + initializedWorker.on('error', (error) => { + console.error('[WorkerManager] Worker error:', error) + }) + + // 监听 Worker 退出 + initializedWorker.on('exit', (code) => { + console.log('[WorkerManager] Worker exited with code:', code) + if (worker !== initializedWorker) { + console.log('[WorkerManager] Ignoring exit from replaced worker') + return + } + worker = null + + // 拒绝所有等待中的请求 + rejectAllPending(new Error('Worker exited unexpectedly')) + }) + + console.log('[WorkerManager] Worker initialized successfully') + } catch (error) { + console.error('[WorkerManager] Failed to initialize worker:', error) + throw error + } +} + +/** + * 发送消息到 Worker 并等待响应 + */ +function sendToWorker(type: string, payload: any, options: number | WorkerRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + if (!worker) { + try { + initWorker() + } catch (error) { + reject(new Error('Worker not initialized')) + return + } + } + + const timeoutMs = typeof options === 'number' ? options : (options.timeoutMs ?? 60000) + const restartOnTimeout = + typeof options === 'number' + ? isRestartableReadOnlyRequestType(type) + : (options.restartOnTimeout ?? isRestartableReadOnlyRequestType(type)) + const requestWorker = worker! + const id = `req_${++requestIdCounter}` + + const timeout = setTimeout(() => { + if (pendingRequests.has(id)) { + pendingRequests.delete(id) + reject(new Error(`Worker request timeout: ${type}`)) + if (restartOnTimeout) { + terminateWorkerAfterQueryTimeout(type, requestWorker) + } + } + }, timeoutMs) + + pendingRequests.set(id, { resolve, reject, timeout, restartOnTimeout }) + + requestWorker.postMessage({ id, type, payload }) + }) +} + +/** + * 发送消息到 Worker 并等待响应(带进度回调) + * 用于流式导入等长时间操作 + */ +function sendToWorkerWithProgress( + type: string, + payload: any, + onProgress?: (progress: ParseProgress) => void, + timeoutMs: number = 600000 // 默认 10 分钟超时 +): Promise { + return new Promise((resolve, reject) => { + if (!worker) { + try { + initWorker() + } catch (error) { + reject(new Error('Worker not initialized')) + return + } + } + + const requestWorker = worker! + const id = `req_${++requestIdCounter}` + + const timeout = setTimeout(() => { + if (pendingRequests.has(id)) { + pendingRequests.delete(id) + reject(new Error(`Worker request timeout: ${type}`)) + } + }, timeoutMs) + + pendingRequests.set(id, { resolve, reject, timeout, restartOnTimeout: false, onProgress }) + + requestWorker.postMessage({ id, type, payload }) + }) +} + +/** + * 关闭 Worker(同步版本,用于一般场景) + */ +export function closeWorker(): void { + if (worker) { + // 先关闭所有数据库连接 + sendToWorker('closeAll', {}).catch(() => {}) + + worker.terminate() + worker = null + rejectAllPending(new Error('Worker terminated')) + console.log('[WorkerManager] Worker terminated') + } +} + +/** + * 关闭 Worker(异步版本,确保数据库连接关闭后再终止) + * 用于应用退出前的清理,确保 Worker 完全关闭 + */ +export async function closeWorkerAsync(): Promise { + if (worker) { + console.log('[WorkerManager] Closing worker async...') + try { + // 等待关闭所有数据库连接(最多等待 3 秒) + await Promise.race([sendToWorker('closeAll', {}), new Promise((resolve) => setTimeout(resolve, 3000))]) + } catch { + // 忽略错误,继续终止 + } + + worker.terminate() + worker = null + rejectAllPending(new Error('Worker terminated')) + console.log('[WorkerManager] Worker terminated (async)') + } +} + +// ==================== 通用查询 API ==================== + +/** + * 通用查询函数(用于新增的查询类型) + */ +export async function query(type: string, payload: any): Promise { + return sendToWorker(type, payload) +} + +// ==================== 插件系统 API ==================== + +/** + * 插件参数化只读 SQL 查询 + * 超时设为 120s,因为多个 pluginQuery 可能在 Worker 队列中排队等待 + */ +export async function pluginQuery>( + sessionId: string, + sql: string, + params: any[] | Record = [] +): Promise { + return sendToWorker('pluginQuery', { sessionId, sql, params }, 120000) +} + +// ==================== 导出的异步 API ==================== + +export async function getAvailableYears(sessionId: string): Promise { + return sendToWorker('getAvailableYears', { sessionId }) +} + +export async function getMemberActivity(sessionId: string, filter?: any): Promise { + return sendToWorker('getMemberActivity', { sessionId, filter }) +} + +export async function getHourlyActivity(sessionId: string, filter?: any): Promise { + return sendToWorker('getHourlyActivity', { sessionId, filter }) +} + +export async function getDailyActivity(sessionId: string, filter?: any): Promise { + return sendToWorker('getDailyActivity', { sessionId, filter }) +} + +export async function getWeekdayActivity(sessionId: string, filter?: any): Promise { + return sendToWorker('getWeekdayActivity', { sessionId, filter }) +} + +export async function getMonthlyActivity(sessionId: string, filter?: any): Promise { + return sendToWorker('getMonthlyActivity', { sessionId, filter }) +} + +export async function getYearlyActivity(sessionId: string, filter?: any): Promise { + return sendToWorker('getYearlyActivity', { sessionId, filter }) +} + +export async function getMessageLengthDistribution(sessionId: string, filter?: any): Promise { + return sendToWorker('getMessageLengthDistribution', { sessionId, filter }) +} + +export async function getMessageTypeDistribution(sessionId: string, filter?: any): Promise { + return sendToWorker('getMessageTypeDistribution', { sessionId, filter }) +} + +export async function getTimeRange(sessionId: string): Promise<{ start: number; end: number } | null> { + return sendToWorker('getTimeRange', { sessionId }) +} + +export async function getMemberNameHistory(sessionId: string, memberId: number): Promise { + return sendToWorker('getMemberNameHistory', { sessionId, memberId }) +} + +export async function getCatchphraseAnalysis(sessionId: string, filter?: any): Promise { + return sendToWorker('getCatchphraseAnalysis', { sessionId, filter }) +} + +export async function getLanguagePreferenceAnalysis(params: { + sessionId: string + locale: string + timeFilter?: any + dictType?: string +}): Promise { + return sendToWorker('getLanguagePreferenceAnalysis', params) +} + +export async function getMentionAnalysis(sessionId: string, filter?: any): Promise { + return sendToWorker('getMentionAnalysis', { sessionId, filter }) +} + +export async function getMentionGraph(sessionId: string, filter?: any): Promise { + return sendToWorker('getMentionGraph', { sessionId, filter }) +} + +export async function getLaughAnalysis(sessionId: string, filter?: any, keywords?: string[]): Promise { + return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords }) +} + +export async function getClusterGraph(sessionId: string, filter?: any, options?: any): Promise { + return sendToWorker('getClusterGraph', { sessionId, filter, options }) +} + +export async function getRelationshipStats(sessionId: string, filter?: any, options?: any): Promise { + return sendToWorker('getRelationshipStats', { sessionId, filter, options }) +} + +export async function getAllSessions(): Promise { + return sendToWorker('getAllSessions', {}) +} + +export async function getSession(sessionId: string): Promise { + return sendToWorker('getSession', { sessionId }) +} + +export async function getChatOverview( + sessionId: string, + topN?: number +): Promise<{ + name: string + platform: string + type: string + totalMessages: number + totalMembers: number + firstMessageTs: number | null + lastMessageTs: number | null + topMembers: Array<{ id: number; name: string; count: number }> + summaryCount: number +} | null> { + return sendToWorker('getChatOverview', { sessionId, topN }) +} + +export async function closeDatabase(sessionId: string): Promise { + return sendToWorker('closeDatabase', { sessionId }) +} + +// ==================== 成员管理 API ==================== + +export interface MemberWithStats { + id: number + platformId: string + accountName: string | null + groupNickname: string | null + aliases: string[] + messageCount: number + avatar?: string | null +} + +/** + * 获取所有成员列表(含消息数和别名) + */ +export async function getMembers(sessionId: string): Promise { + return sendToWorker('getMembers', { sessionId }) +} + +/** + * 获取成员列表(分页版本) + */ +export async function getMembersPaginated( + sessionId: string, + params: { + page: number + pageSize: number + search?: string + sortOrder?: 'asc' | 'desc' + } +): Promise<{ + members: MemberWithStats[] + total: number + page: number + pageSize: number + totalPages: number +}> { + return sendToWorker('getMembersPaginated', { sessionId, params }) +} + +/** + * 更新成员别名 + */ +export async function updateMemberAliases(sessionId: string, memberId: number, aliases: string[]): Promise { + return sendToWorker('updateMemberAliases', { sessionId, memberId, aliases }) +} + +/** + * 合并两个成员(保留消息数更多的一方) + */ +export async function mergeMembers(sessionId: string, memberId1: number, memberId2: number): Promise { + return sendToWorker('mergeMembers', { sessionId, memberId1, memberId2 }) +} + +/** + * 删除成员及其所有消息 + */ +export async function deleteMember(sessionId: string, memberId: number): Promise { + return sendToWorker('deleteMember', { sessionId, memberId }) +} + +/** + * 流式解析文件,写入临时数据库(用于合并功能) + * 返回基本信息和临时数据库路径 + */ +export async function streamParseFileInfo( + filePath: string, + onProgress?: (progress: ParseProgress) => void +): Promise<{ + name: string + format: string + platform: string + messageCount: number + memberCount: number + fileSize: number + tempDbPath: string +}> { + return sendToWorkerWithProgress('streamParseFileInfo', { filePath }, onProgress) +} + +/** + * 流式导入聊天记录 + * @param filePath 文件路径 + * @param onProgress 进度回调 + * @param formatOptions 格式特定选项(如 Telegram 的 chatIndex) + */ +export async function streamImport( + filePath: string, + onProgress?: (progress: ParseProgress) => void, + formatOptions?: Record, + externalSessionId?: string +): Promise { + assertDataDirCompatibleNow() + + const result = await sendToWorkerWithProgress( + 'streamImport', + { filePath, formatOptions, externalSessionId }, + onProgress + ) + if (!result.success || !result.sessionId) return result + + try { + raiseChatDbCompatibilityGate(getPathProvider(), { + version: getDesktopAppVersion(app.getVersion()), + kind: 'desktop', + }) + } catch (error) { + deleteImportedSessionFiles(result.sessionId) + return { + success: false, + error: error instanceof Error ? error.message : String(error), + diagnostics: result.diagnostics, + } + } + + return result +} + +function deleteImportedSessionFiles(sessionId: string): void { + const dbPath = path.join(getDbDir(), `${sessionId}.db`) + for (const suffix of ['', '-wal', '-shm']) { + try { + const p = dbPath + suffix + if (fs.existsSync(p)) fs.unlinkSync(p) + } catch { + /* ignore */ + } + } +} + +/** + * 获取数据库目录(供外部使用) + */ +export function getDbDirectory(): string { + return getDbDir() +} + +// ==================== AI 查询 API ==================== + +export interface SearchMessageResult { + id: number + senderId: number + senderName: string + senderPlatformId: string + senderAliases: string[] + senderAvatar: string | null + content: string + timestamp: number + type: number + replyToMessageId: string | null + replyToContent: string | null + replyToSenderName: string | null +} + +/** + * 关键词搜索消息 + */ +export async function searchMessages( + sessionId: string, + keywords: string[], + filter?: any, + limit?: number, + offset?: number, + senderId?: number +): Promise<{ messages: SearchMessageResult[]; total: number }> { + return sendToWorker('searchMessages', { sessionId, keywords, filter, limit, offset, senderId }) +} + +/** + * 深度搜索消息(LIKE 子串匹配,速度较慢但不会遗漏) + */ +export async function deepSearchMessages( + sessionId: string, + keywords: string[], + filter?: any, + limit?: number, + offset?: number, + senderId?: number +): Promise<{ messages: SearchMessageResult[]; total: number }> { + return sendToWorker('deepSearchMessages', { sessionId, keywords, filter, limit, offset, senderId }) +} + +/** + * 获取消息上下文 + * 支持单个或批量消息 ID,返回合并去重后的上下文消息 + */ +export async function getMessageContext( + sessionId: string, + messageIds: number | number[], + contextSize?: number +): Promise { + return sendToWorker('getMessageContext', { sessionId, messageIds, contextSize }) +} + +/** + * 获取搜索结果的上下文消息(会话感知 + 区间合并去重) + */ +export async function getSearchMessageContext( + sessionId: string, + messageIds: number[], + contextBefore?: number, + contextAfter?: number +): Promise { + return sendToWorker('getSearchMessageContext', { sessionId, messageIds, contextBefore, contextAfter }) +} + +/** + * 获取最近消息(用于概览性问题) + */ +export async function getRecentMessages( + sessionId: string, + filter?: any, + limit?: number +): Promise<{ messages: SearchMessageResult[]; total: number }> { + return sendToWorker('getRecentMessages', { sessionId, filter, limit }) +} + +/** + * 获取所有最近消息(消息查看器专用,包含所有类型消息) + */ +export async function getAllRecentMessages( + sessionId: string, + filter?: any, + limit?: number +): Promise<{ messages: SearchMessageResult[]; total: number }> { + return sendToWorker('getAllRecentMessages', { sessionId, filter, limit }) +} + +/** + * 获取两个成员之间的对话 + */ +export async function getConversationBetween( + sessionId: string, + memberId1: number, + memberId2: number, + filter?: any, + limit?: number +): Promise<{ messages: SearchMessageResult[]; total: number; member1Name: string; member2Name: string }> { + return sendToWorker('getConversationBetween', { sessionId, memberId1, memberId2, filter, limit }) +} + +/** + * 获取指定消息之前的 N 条消息(用于向上无限滚动) + */ +export async function getMessagesBefore( + sessionId: string, + beforeId: number, + limit?: number, + filter?: any, + senderId?: number, + keywords?: string[] +): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> { + return sendToWorker('getMessagesBefore', { sessionId, beforeId, limit, filter, senderId, keywords }) +} + +/** + * 获取指定消息之后的 N 条消息(用于向下无限滚动) + */ +export async function getMessagesAfter( + sessionId: string, + afterId: number, + limit?: number, + filter?: any, + senderId?: number, + keywords?: string[] +): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> { + return sendToWorker('getMessagesAfter', { sessionId, afterId, limit, filter, senderId, keywords }) +} + +// ==================== SQL 实验室 API ==================== + +export interface SQLResult { + columns: string[] + rows: any[][] + rowCount: number + duration: number + limited: boolean +} + +export interface TableSchema { + name: string + columns: { + name: string + type: string + notnull: boolean + pk: boolean + }[] +} + +/** + * 执行用户 SQL 查询 + */ +export async function executeRawSQL(sessionId: string, sql: string): Promise { + return sendToWorker('executeRawSQL', { sessionId, sql }) +} + +/** + * 获取数据库 Schema + */ +export async function getSchema(sessionId: string): Promise { + return sendToWorker('getSchema', { sessionId }) +} + +// ==================== 会话索引 API ==================== + +export interface SessionStats { + sessionCount: number + hasIndex: boolean + gapThreshold: number +} + +/** + * 生成会话索引 + * @param sessionId 数据库会话ID + * @param gapThreshold 时间间隔阈值(秒) + */ +export async function generateSessions(sessionId: string, gapThreshold?: number): Promise { + return sendToWorker('generateSessions', { sessionId, gapThreshold }) +} + +/** + * 增量生成会话索引(仅处理未索引的新消息,保留已有会话和摘要) + */ +export async function generateIncrementalSessions(sessionId: string, gapThreshold?: number): Promise { + return sendToWorker('generateIncrementalSessions', { sessionId, gapThreshold }) +} + +/** + * 清空会话索引 + */ +export async function clearSessions(sessionId: string): Promise { + return sendToWorker('clearSessions', { sessionId }) +} + +/** + * 检查是否已生成会话索引 + */ +export async function hasSessionIndex(sessionId: string): Promise { + return sendToWorker('hasSessionIndex', { sessionId }) +} + +/** + * 获取会话索引统计信息 + */ +export async function getSessionStats(sessionId: string): Promise { + return sendToWorker('getSessionStats', { sessionId }) +} + +export async function getAllIndexStats(): Promise< + Array<{ sessionId: string; hasIndex: boolean; sessionCount: number }> +> { + const sessions = await getAllSessions() + const sessionIds = sessions.map((s: any) => s.id) + return sendToWorker('getAllIndexStats', { sessionIds }) +} + +/** + * 更新单个聊天的会话切分阈值 + */ +export async function updateSessionGapThreshold(sessionId: string, gapThreshold: number | null): Promise { + return sendToWorker('updateSessionGapThreshold', { sessionId, gapThreshold }) +} + +/** + * 会话列表项类型 + */ +export interface ChatSessionItem { + id: number + startTs: number + endTs: number + messageCount: number + firstMessageId: number +} + +/** + * 获取会话列表(用于时间线导航) + */ +export async function getSessions(sessionId: string): Promise { + return sendToWorker('getSessions', { sessionId }) +} + +/** + * 根据时间范围查询会话列表 + */ +export async function getSessionsByTimeRange( + sessionId: string, + startTs: number, + endTs: number +): Promise { + return sendToWorker('getSessionsByTimeRange', { sessionId, startTs, endTs }) +} + +/** + * 获取最近 N 条会话 + */ +export async function getRecentChatSessions(sessionId: string, limit: number): Promise { + return sendToWorker('getRecentChatSessions', { sessionId, limit }) +} + +// ==================== AI 工具专用查询函数 ==================== + +export type { SessionMessagesResult } from './query/session' + +export async function getSegmentMessages( + sessionId: string, + segmentId: number, + limit?: number +): Promise { + return sendToWorker('getSegmentMessages', { sessionId, segmentId, limit }) +} + +/** + * 会话摘要结果类型(用于 AI 工具) + */ +export interface SessionSummaryItem { + id: number + startTs: number + endTs: number + messageCount: number + participants: string[] + summary: string | null +} + +/** + * 获取带摘要的会话列表(用于 AI 工具) + */ +export async function getSegmentSummaries( + sessionId: string, + options: { + limit?: number + timeFilter?: { startTs: number; endTs: number } + } +): Promise { + return sendToWorker('getSegmentSummaries', { sessionId, options }) +} + +// ==================== 导出 API ==================== + +export interface ExportFileParams { + sessionId: string + sessionName: string + outputDir: string + format?: 'txt' | 'json' | 'markdown' + timeFilter?: { startTs: number; endTs: number } +} + +export async function exportFilterResultToFile( + params: ExportFileParams +): Promise<{ success: boolean; filePath?: string; error?: string }> { + return sendToWorker('exportFilterResultToFile', params, 600000) +} + +// ==================== 增量导入 ==================== + +/** + * 增量导入分析结果 + */ +export interface IncrementalAnalyzeResult { + newMessageCount: number + duplicateCount: number + totalInFile: number + error?: string +} + +/** + * 分析增量导入(检测去重后能新增多少消息) + */ +export async function analyzeIncrementalImport(sessionId: string, filePath: string): Promise { + return sendToWorker('analyzeIncrementalImport', { sessionId, filePath }) +} + +/** + * 导入选项(控制 meta/members 更新行为) + */ +export interface ImportOptions { + metaUpdateMode?: 'patch' | 'none' + memberUpdateMode?: 'upsert' | 'none' +} + +/** + * 增量导入结果 + */ +export interface IncrementalImportResult { + success: boolean + newMessageCount: number + error?: string + batch?: { + receivedCount: number + writtenCount: number + duplicateCount: number + errorCount: number + errorReasonCounts: Record + errorSample: Array<{ index: number; reason: string; detail: string }> + } + session?: { + totalCount: number + memberCount: number + firstTimestamp: number + lastTimestamp: number + } + updates?: { + metaUpdated: boolean + membersAdded: number + membersUpdated: number + } +} + +/** + * 执行增量导入 + */ +export async function incrementalImport( + sessionId: string, + filePath: string, + onProgress?: (progress: ParseProgress) => void, + options?: ImportOptions +): Promise { + assertDataDirCompatibleNow() + + return sendToWorkerWithProgress('incrementalImport', { sessionId, filePath, options }, onProgress) +} + +/** + * Dry-run analysis result for new sessions + */ +export interface AnalyzeNewImportResult { + totalMessages: number + totalMembers: number + meta: { name: string; platform: string; type: string } | null + error?: string +} + +/** + * Analyze a new import file without writing to DB (dry-run) + */ +export async function analyzeNewImport(filePath: string): Promise { + return sendToWorker('analyzeNewImport', { filePath }) +} diff --git a/apps/desktop/main/worker/workerTimeoutPolicy.test.ts b/apps/desktop/main/worker/workerTimeoutPolicy.test.ts new file mode 100644 index 000000000..c9aea90cb --- /dev/null +++ b/apps/desktop/main/worker/workerTimeoutPolicy.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { isRestartableReadOnlyRequestType } from './workerTimeoutPolicy' + +describe('worker timeout policy', () => { + it('allows timed-out read-only AI/debug requests to restart the worker', () => { + assert.equal(isRestartableReadOnlyRequestType('pluginQuery'), true) + assert.equal(isRestartableReadOnlyRequestType('executeRawSQL'), true) + assert.equal(isRestartableReadOnlyRequestType('getSchema'), true) + assert.equal(isRestartableReadOnlyRequestType('getChatOverview'), true) + assert.equal(isRestartableReadOnlyRequestType('searchMessages'), true) + assert.equal(isRestartableReadOnlyRequestType('getSegmentSummaries'), true) + }) + + it('keeps mutating, indexing, import, export, and unknown requests non-restartable', () => { + assert.equal(isRestartableReadOnlyRequestType('generateSessions'), false) + assert.equal(isRestartableReadOnlyRequestType('generateIncrementalSessions'), false) + assert.equal(isRestartableReadOnlyRequestType('clearSessions'), false) + assert.equal(isRestartableReadOnlyRequestType('updateMemberAliases'), false) + assert.equal(isRestartableReadOnlyRequestType('mergeMembers'), false) + assert.equal(isRestartableReadOnlyRequestType('deleteMember'), false) + assert.equal(isRestartableReadOnlyRequestType('streamImport'), false) + assert.equal(isRestartableReadOnlyRequestType('incrementalImport'), false) + assert.equal(isRestartableReadOnlyRequestType('exportFilterResultToFile'), false) + assert.equal(isRestartableReadOnlyRequestType('unknownFutureWorkerRequest'), false) + }) +}) diff --git a/apps/desktop/main/worker/workerTimeoutPolicy.ts b/apps/desktop/main/worker/workerTimeoutPolicy.ts new file mode 100644 index 000000000..2649366b5 --- /dev/null +++ b/apps/desktop/main/worker/workerTimeoutPolicy.ts @@ -0,0 +1,47 @@ +const RESTARTABLE_READ_ONLY_REQUEST_TYPES = new Set([ + 'pluginQuery', + 'executeRawSQL', + 'getSchema', + 'getAvailableYears', + 'getMemberActivity', + 'getHourlyActivity', + 'getDailyActivity', + 'getWeekdayActivity', + 'getMonthlyActivity', + 'getYearlyActivity', + 'getMessageLengthDistribution', + 'getMessageTypeDistribution', + 'getTimeRange', + 'getMemberNameHistory', + 'getCatchphraseAnalysis', + 'getLanguagePreferenceAnalysis', + 'getMentionAnalysis', + 'getMentionGraph', + 'getLaughAnalysis', + 'getClusterGraph', + 'getRelationshipStats', + 'getAllSessions', + 'getSession', + 'getChatOverview', + 'getMembers', + 'getMembersPaginated', + 'searchMessages', + 'deepSearchMessages', + 'getMessageContext', + 'getSearchMessageContext', + 'getRecentMessages', + 'getAllRecentMessages', + 'getConversationBetween', + 'hasSessionIndex', + 'getSessionStats', + 'getAllIndexStats', + 'getSessions', + 'getSessionsByTimeRange', + 'getRecentChatSessions', + 'getSegmentMessages', + 'getSegmentSummaries', +]) + +export function isRestartableReadOnlyRequestType(type: string): boolean { + return RESTARTABLE_READ_ONLY_REQUEST_TYPES.has(type) +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 000000000..96382d59c --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,39 @@ +{ + "name": "@openchatlab/desktop", + "version": "0.0.0", + "productName": "ChatLab", + "private": true, + "description": "ChatLab Desktop — Electron 桌面客户端", + "main": "./out/main/index.js", + "scripts": { + "postinstall": "electron-rebuild -f -w better-sqlite3", + "dev": "electron-vite dev", + "preview": "electron-vite preview", + "build": "electron-vite build", + "build:mac": "pnpm run build && electron-builder --mac --config electron-builder.yml -p never", + "build:win": "pnpm run build && electron-builder --win --config electron-builder.yml -p never" + }, + "dependencies": { + "@electron-toolkit/preload": "^3.0.1", + "@electron-toolkit/utils": "^4.0.0", + "@fastify/multipart": "^10.0.0", + "@huggingface/transformers": "^4.2.0", + "@node-rs/jieba": "^2.0.1", + "better-sqlite3": "^12.4.6", + "electron-updater": "^6.6.2", + "i18next": "^25.8.5", + "onnxruntime-common": "1.24.3", + "onnxruntime-node": "1.24.3", + "sharp": "0.34.5", + "sqlite-vec": "^0.1.9", + "undici": "^6.25.0" + }, + "devDependencies": { + "@electron-toolkit/tsconfig": "^1.0.1", + "@electron/rebuild": "^4.0.4", + "@types/better-sqlite3": "^7.6.13", + "electron": "35.7.5", + "electron-builder": "^26.0.12", + "electron-vite": "^5.0.0" + } +} diff --git a/apps/desktop/preload/apis/ai.ts b/apps/desktop/preload/apis/ai.ts new file mode 100644 index 000000000..0e8073190 --- /dev/null +++ b/apps/desktop/preload/apis/ai.ts @@ -0,0 +1,67 @@ +/** + * AI 相关 API — 仅保留 IPC 必须的能力 + * + * 此处只保留需要 worker、native shell、工具注册表等 IPC 才能提供的功能。 + */ +import { ipcRenderer } from 'electron' +import type { ExportProgress } from '../../../../src/types/base' + +export type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../../shared/types' + +// ==================== 类型定义 ==================== + +export interface DesensitizeRule { + id: string + label: string + pattern: string + replacement: string + enabled: boolean + builtin: boolean + locales: string[] + group?: string +} + +export interface PreprocessConfig { + dataCleaning: boolean + mergeConsecutive: boolean + mergeWindowSeconds?: number + blacklistKeywords: string[] + denoise: boolean + desensitize: boolean + desensitizeRulesSchemaVersion?: number + desensitizeBuiltinRuleOverrides?: Record + desensitizeRules: DesensitizeRule[] + anonymizeNames: boolean +} + +// ==================== AI API (IPC-only subset) ==================== + +export const aiApi = { + exportFilterResultToFile: (params: { + sessionId: string + sessionName: string + outputDir: string + format?: 'txt' | 'json' | 'markdown' + timeFilter?: { startTs: number; endTs: number } + }): Promise<{ success: boolean; filePath?: string; error?: string }> => { + return ipcRenderer.invoke('ai:exportFilterResultToFile', params) + }, + + onExportProgress: (callback: (progress: ExportProgress) => void) => { + const handler = (_event: Electron.IpcRendererEvent, progress: ExportProgress) => { + callback(progress) + } + ipcRenderer.on('ai:exportProgress', handler) + return () => { + ipcRenderer.removeListener('ai:exportProgress', handler) + } + }, + + // ===== 日志(native shell) ===== + + showAiLogFile: (): Promise<{ success: boolean; path?: string; error?: string }> => { + return ipcRenderer.invoke('ai:showLogFile') + }, + + // Desensitize rules, tool testing, estimateContextTokens have been migrated to shared HTTP routes. +} diff --git a/apps/desktop/preload/apis/api-server.ts b/apps/desktop/preload/apis/api-server.ts new file mode 100644 index 000000000..df53086b8 --- /dev/null +++ b/apps/desktop/preload/apis/api-server.ts @@ -0,0 +1,165 @@ +/** + * ChatLab API 服务 Preload API (hierarchical data source model) + */ + +import { ipcRenderer } from 'electron' + +export interface ApiServerConfig { + enabled: boolean + port: number + token: string + createdAt: number +} + +export interface ApiServerStatus { + running: boolean + port: number | null + startedAt: number | null + error: string | null +} + +export interface ImportSession { + id: string + name: string + remoteSessionId: string + targetSessionId: string + lastPullAt: number + lastStatus: 'idle' | 'success' | 'error' + lastError: string + lastNewMessages: number +} + +export interface DataSource { + id: string + name: string + baseUrl: string + token: string + intervalMinutes: number + pullLimit: number + enabled: boolean + createdAt: number + sessions: ImportSession[] +} + +export interface RemoteSession { + id: string + name: string + platform: string + type: string + messageCount?: number + memberCount?: number + lastMessageAt?: number +} + +export interface RemoteSessionDiscoveryPage { + hasMore: boolean + nextCursor?: string +} + +export interface RemoteSessionDiscoveryResult { + sessions: RemoteSession[] + page?: RemoteSessionDiscoveryPage +} + +export const apiServerApi = { + // ==================== API 服务管理 ==================== + + getConfig: (): Promise => { + return ipcRenderer.invoke('api:getConfig') + }, + + getStatus: (): Promise => { + return ipcRenderer.invoke('api:getStatus') + }, + + setEnabled: (enabled: boolean): Promise => { + return ipcRenderer.invoke('api:setEnabled', enabled) + }, + + setPort: (port: number): Promise => { + return ipcRenderer.invoke('api:setPort', port) + }, + + regenerateToken: (): Promise => { + return ipcRenderer.invoke('api:regenerateToken') + }, + + onStartupError: (callback: (data: { error: string }) => void): (() => void) => { + const handler = (_event: any, data: { error: string }) => callback(data) + ipcRenderer.on('api:startupError', handler) + return () => ipcRenderer.removeListener('api:startupError', handler) + }, + + // ==================== 数据源管理 ==================== + + getDataSources: (): Promise => { + return ipcRenderer.invoke('api:getDataSources') + }, + + addDataSource: (partial: { + name?: string + baseUrl: string + token: string + intervalMinutes: number + pullLimit?: number + }): Promise => { + return ipcRenderer.invoke('api:addDataSource', partial) + }, + + updateDataSource: ( + id: string, + updates: Partial> + ): Promise => { + return ipcRenderer.invoke('api:updateDataSource', id, updates) + }, + + deleteDataSource: (id: string): Promise => { + return ipcRenderer.invoke('api:deleteDataSource', id) + }, + + // ==================== 导入会话管理 ==================== + + addImportSessions: ( + sourceId: string, + sessions: Array<{ name: string; remoteSessionId: string }> + ): Promise => { + return ipcRenderer.invoke('api:addImportSessions', sourceId, sessions) + }, + + removeImportSession: (sourceId: string, sessionId: string, deleteData?: boolean): Promise => { + return ipcRenderer.invoke('api:removeImportSession', sourceId, sessionId, deleteData) + }, + + // ==================== 同步 ==================== + + triggerPull: (sourceId: string, sessionId?: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('api:triggerPull', sourceId, sessionId) + }, + + triggerPullAll: (sourceId: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('api:triggerPullAll', sourceId) + }, + + fetchRemoteSessions: ( + baseUrl: string, + token?: string, + query?: { keyword?: string; limit?: number; cursor?: string } + ): Promise => { + return ipcRenderer.invoke('api:fetchRemoteSessions', baseUrl, token || '', query) + }, + + onPullResult: ( + callback: (data: { sourceId: string; sessionId?: string; status: string; detail: string }) => void + ): (() => void) => { + const handler = (_event: any, data: { sourceId: string; sessionId?: string; status: string; detail: string }) => + callback(data) + ipcRenderer.on('api:pullResult', handler) + return () => ipcRenderer.removeListener('api:pullResult', handler) + }, + + onImportCompleted: (callback: () => void): (() => void) => { + const handler = () => callback() + ipcRenderer.on('api:importCompleted', handler) + return () => ipcRenderer.removeListener('api:importCompleted', handler) + }, +} diff --git a/apps/desktop/preload/apis/chat.ts b/apps/desktop/preload/apis/chat.ts new file mode 100644 index 000000000..ba7b36694 --- /dev/null +++ b/apps/desktop/preload/apis/chat.ts @@ -0,0 +1,105 @@ +/** + * 聊天记录 Preload API — 导入、迁移、Demo、合并 + * + * 数据查询/分析/成员管理/SQL/NLP/偏好设置已迁移到 + * Internal HTTP Server (FetchDataAdapter, FetchPreferencesAdapter 等)。 + */ +import { ipcRenderer } from 'electron' +import type { ImportProgress } from '../../../../src/types/base' + +export const chatApi = { + // ==================== 数据库迁移 ==================== + + checkMigration: (): Promise<{ + needsMigration: boolean + count: number + currentVersion: number + pendingMigrations: Array<{ version: number; userMessage: string }> + }> => ipcRenderer.invoke('chat:checkMigration'), + + runMigration: (): Promise<{ success: boolean; migratedCount: number; error?: string }> => + ipcRenderer.invoke('chat:runMigration'), + + // ==================== 文件选择与导入 ==================== + + selectFile: (): Promise<{ filePath?: string; format?: string; error?: string } | null> => + ipcRenderer.invoke('chat:selectFile'), + + import: (filePath: string): Promise<{ success: boolean; sessionId?: string; error?: string }> => + ipcRenderer.invoke('chat:import', filePath), + + importDirectory: (dirPath: string): Promise<{ success: boolean; sessionId?: string; error?: string }> => + ipcRenderer.invoke('chat:importDirectory', dirPath), + + detectFormat: ( + filePath: string + ): Promise<{ id: string; name: string; platform: string; multiChat: boolean } | null> => + ipcRenderer.invoke('chat:detectFormat', filePath), + + importWithOptions: ( + filePath: string, + formatOptions: Record + ): Promise<{ success: boolean; sessionId?: string; error?: string }> => + ipcRenderer.invoke('chat:importWithOptions', filePath, formatOptions), + + scanMultiChatFile: ( + filePath: string + ): Promise<{ + success: boolean + chats: Array<{ index: number; name: string; type: string; id: number; messageCount: number }> + error?: string + }> => ipcRenderer.invoke('chat:scanMultiChatFile', filePath), + + prepareImportSource: (filePath: string) => ipcRenderer.invoke('chat:prepareImportSource', filePath), + + importPreparedChat: (sourceId: string, chatId: string) => + ipcRenderer.invoke('chat:importPreparedChat', sourceId, chatId), + + releaseImportSource: (sourceId: string) => ipcRenderer.invoke('chat:releaseImportSource', sourceId), + + getSupportedFormats: (): Promise> => + ipcRenderer.invoke('chat:getSupportedFormats'), + + onImportProgress: (callback: (progress: ImportProgress) => void) => { + const handler = (_event: Electron.IpcRendererEvent, progress: ImportProgress) => callback(progress) + ipcRenderer.on('chat:importProgress', handler) + return () => ipcRenderer.removeListener('chat:importProgress', handler) + }, + + // ==================== 增量导入 ==================== + + analyzeIncrementalImport: ( + sessionId: string, + filePath: string + ): Promise<{ + newMessageCount: number + duplicateCount: number + totalInFile: number + error?: string + diagnosis?: { suggestion?: string } + }> => ipcRenderer.invoke('chat:analyzeIncrementalImport', sessionId, filePath), + + incrementalImport: ( + sessionId: string, + filePath: string + ): Promise<{ success: boolean; newMessageCount: number; error?: string }> => + ipcRenderer.invoke('chat:incrementalImport', sessionId, filePath), + + // ==================== Demo ==================== + + importDemo: ( + locale: string + ): Promise<{ success: boolean; groupSessionId?: string; privateSessionIds?: string[]; error?: string }> => + ipcRenderer.invoke('demo:downloadAndImport', locale), + + onDemoProgress: ( + callback: (progress: { stage: string; current: number; total: number; message?: string }) => void + ) => { + const handler = ( + _event: Electron.IpcRendererEvent, + progress: { stage: string; current: number; total: number; message?: string } + ) => callback(progress) + ipcRenderer.on('demo:progress', handler) + return () => ipcRenderer.removeListener('demo:progress', handler) + }, +} diff --git a/apps/desktop/preload/apis/core.ts b/apps/desktop/preload/apis/core.ts new file mode 100644 index 000000000..7719ed494 --- /dev/null +++ b/apps/desktop/preload/apis/core.ts @@ -0,0 +1,117 @@ +/** + * 核心 API - 基础 IPC 通信和系统功能 + */ +import { ipcRenderer } from 'electron' + +// Custom APIs for renderer +export const api = { + send: (channel: string, data?: unknown) => { + // whitelist channels + const validChannels = [ + 'show-message', + 'check-update', + 'simulate-update', + 'get-gpu-acceleration', + 'set-gpu-acceleration', + 'save-gpu-acceleration', + 'window-close', // 用户协议拒绝时退出应用 + ] + if (validChannels.includes(channel)) { + ipcRenderer.send(channel, data) + } + }, + receive: (channel: string, func: (...args: unknown[]) => void) => { + const validChannels = ['show-message', 'chat:importProgress', 'merge:parseProgress'] + if (validChannels.includes(channel)) { + // Deliberately strip event as it includes `sender` + ipcRenderer.on(channel, (_event, ...args) => func(...args)) + } + }, + removeListener: (channel: string, func: (...args: unknown[]) => void) => { + ipcRenderer.removeListener(channel, func) + }, + setThemeSource: (mode: 'system' | 'light' | 'dark') => { + ipcRenderer.send('window:setThemeSource', mode) + }, + setTitleBarOverlayColor: (color: string) => { + ipcRenderer.send('window:setTitleBarOverlayColor', color) + }, +} + +// 扩展 api,添加 dialog、clipboard 和应用功能 +export const extendedApi = { + ...api, + dialog: { + showOpenDialog: (options: Electron.OpenDialogOptions): Promise => { + return ipcRenderer.invoke('dialog:showOpenDialog', options) + }, + }, + clipboard: { + /** + * 复制图片到系统剪贴板 + * @param dataUrl 图片的 base64 data URL + */ + copyImage: (dataUrl: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('copyImage', dataUrl) + }, + }, + app: { + /** + * 获取应用版本号 + */ + getVersion: (): Promise => { + return ipcRenderer.invoke('app:getVersion') + }, + /** + * 检查更新 + */ + checkUpdate: (): void => { + ipcRenderer.send('check-update') + }, + /** + * 模拟更新弹窗(仅开发模式) + */ + simulateUpdate: (): void => { + ipcRenderer.send('simulate-update') + }, + /** + * 获取远程配置(通过主进程请求,绕过 CORS) + */ + fetchRemoteConfig: (url: string): Promise<{ success: boolean; data?: unknown; error?: string }> => { + return ipcRenderer.invoke('app:fetchRemoteConfig', url) + }, + /** + * 获取匿名统计开关状态 + */ + getAnalyticsEnabled: (): Promise => { + return ipcRenderer.invoke('analytics:getEnabled') + }, + /** + * 设置匿名统计开关状态 + */ + setAnalyticsEnabled: (enabled: boolean): Promise<{ success: boolean }> => { + return ipcRenderer.invoke('analytics:setEnabled', enabled) + }, + trackDailyActive: (locale: string): Promise => { + return ipcRenderer.invoke('analytics:trackDailyActive', locale) + }, + /** + * 重启应用 + */ + relaunch: (): Promise => { + return ipcRenderer.invoke('app:relaunch') + }, + /** + * 获取开机自启动状态 + */ + getOpenAtLogin: (): Promise => { + return ipcRenderer.invoke('app:getOpenAtLogin') + }, + /** + * 设置开机自启动 + */ + setOpenAtLogin: (enabled: boolean): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('app:setOpenAtLogin', enabled) + }, + }, +} diff --git a/apps/desktop/preload/apis/internal-api.ts b/apps/desktop/preload/apis/internal-api.ts new file mode 100644 index 000000000..b1719f639 --- /dev/null +++ b/apps/desktop/preload/apis/internal-api.ts @@ -0,0 +1,17 @@ +/** + * Internal API Server endpoint — Preload API + * + * Exposes the Internal Server's baseUrl and ephemeral token to the Renderer. + * Token is held in memory only (never persisted to localStorage/sessionStorage). + */ + +import { ipcRenderer } from 'electron' + +export interface InternalEndpoint { + baseUrl: string + token: string +} + +export const internalApi = { + getEndpoint: (): Promise => ipcRenderer.invoke('internal-api:getEndpoint'), +} diff --git a/apps/desktop/preload/apis/utils.ts b/apps/desktop/preload/apis/utils.ts new file mode 100644 index 000000000..d0b3f3e9a --- /dev/null +++ b/apps/desktop/preload/apis/utils.ts @@ -0,0 +1,65 @@ +/** + * 工具类 API - 网络、缓存、会话索引 + */ +import { ipcRenderer } from 'electron' + +// ==================== 类型定义 ==================== + +// Network API 类型 +export type ProxyMode = 'off' | 'system' | 'manual' + +export interface ProxyConfig { + mode: ProxyMode // 代理模式:关闭、跟随系统、手动配置 + url: string // 仅 manual 模式使用 +} + +// ==================== Network API ==================== + +export const networkApi = { + /** + * 获取代理配置 + */ + getProxyConfig: (): Promise => { + return ipcRenderer.invoke('network:getProxyConfig') + }, + + /** + * 保存代理配置 + */ + saveProxyConfig: (config: ProxyConfig): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('network:saveProxyConfig', config) + }, + + /** + * 测试代理连接 + */ + testProxyConnection: (proxyUrl: string): Promise<{ success: boolean; error?: string }> => { + return ipcRenderer.invoke('network:testProxyConnection', proxyUrl) + }, +} + +// ==================== Cache API ==================== + +/** + * CacheApi — IPC-only subset + * + * Most cache operations (getInfo, clear, openDir, saveToDownloads, etc.) + * have been migrated to HTTP shared routes (FetchCacheAdapter). + * Only selectDataDir and setDataDir remain on IPC because they require + * native Electron dialogs and app restart capabilities. + */ +export const cacheApi = { + selectDataDir: (): Promise<{ success: boolean; path?: string; error?: string }> => { + return ipcRenderer.invoke('cache:selectDataDir') + }, + + setDataDir: ( + path: string | null, + migrate: boolean = true + ): Promise<{ success: boolean; error?: string; from?: string; to?: string; requiresRelaunch?: boolean }> => { + return ipcRenderer.invoke('cache:setDataDir', { path, migrate }) + }, +} + +// Session index API has been migrated to shared HTTP routes (FetchSessionIndexAdapter). +// All session:* IPC handlers have been removed. diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts new file mode 100644 index 000000000..36c066167 --- /dev/null +++ b/apps/desktop/preload/index.d.ts @@ -0,0 +1,403 @@ +import { ElectronAPI } from '@electron-toolkit/preload' +import type { ImportProgress, ExportProgress } from '../../../src/types/base' +import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../shared/types' +import type { TimeFilter } from '@openchatlab/shared-types' + +// 迁移相关类型 +interface MigrationInfo { + version: number + description: string + userMessage: string +} + +interface MigrationCheckResult { + needsMigration: boolean + count: number + currentVersion: number + pendingMigrations: MigrationInfo[] +} + +// 导入诊断信息 +interface ImportDiagnostics { + /** 日志文件路径 */ + logFile: string | null + /** 检测到的格式 */ + detectedFormat: string | null + /** 收到的消息数 */ + messagesReceived: number + /** 写入的消息数 */ + messagesWritten: number + /** 跳过的消息数 */ + messagesSkipped: number + /** 跳过原因统计 */ + skipReasons: { + noSenderId: number + noAccountName: number + invalidTimestamp: number + noType: number + } +} + +/** + * ChatApi — 导入、迁移、Demo(数据查询/分析/成员/SQL 已迁移到 HTTP) + */ +interface ChatApi { + selectFile: () => Promise<{ filePath?: string; format?: string; error?: string } | null> + detectFormat: (filePath: string) => Promise<{ id: string; name: string; platform: string; multiChat: boolean } | null> + import: (filePath: string) => Promise<{ success: boolean; sessionId?: string; error?: string }> + importDirectory: (dirPath: string) => Promise<{ success: boolean; sessionId?: string; error?: string }> + importWithOptions: ( + filePath: string, + formatOptions: Record + ) => Promise<{ success: boolean; sessionId?: string; error?: string }> + scanMultiChatFile: (filePath: string) => Promise<{ + success: boolean + chats: Array<{ index: number; name: string; type: string; id: number; messageCount: number }> + error?: string + }> + prepareImportSource: (filePath: string) => Promise<{ + success: boolean + source?: { + sourceId: string + formatId: string + platform: string + chats: Array<{ + chatId: string + name: string + type: 'private' | 'group' + messageCount: number + memberCount: number + }> + expiresAt: number + } + error?: string + }> + importPreparedChat: ( + sourceId: string, + chatId: string + ) => Promise<{ success: boolean; sessionId?: string; error?: string; diagnostics?: ImportDiagnosticsInfo }> + releaseImportSource: (sourceId: string) => Promise<{ success: boolean }> + checkMigration: () => Promise + runMigration: () => Promise<{ success: boolean; error?: string }> + getSupportedFormats: () => Promise> + onImportProgress: (callback: (progress: ImportProgress) => void) => () => void + analyzeIncrementalImport: ( + sessionId: string, + filePath: string + ) => Promise<{ + newMessageCount: number + duplicateCount: number + totalInFile: number + error?: string + diagnosis?: { suggestion?: string } + }> + incrementalImport: ( + sessionId: string, + filePath: string + ) => Promise<{ success: boolean; newMessageCount: number; error?: string }> + importDemo: (locale: string) => Promise<{ + success: boolean + groupSessionId?: string + privateSessionIds?: string[] + error?: string + }> + onDemoProgress: ( + callback: (progress: { stage: string; current: number; total: number; message?: string }) => void + ) => () => void +} + +interface Api { + send: (channel: string, data?: unknown) => void + receive: (channel: string, func: (...args: unknown[]) => void) => void + removeListener: (channel: string, func: (...args: unknown[]) => void) => void + setThemeSource: (mode: 'system' | 'light' | 'dark') => void + setTitleBarOverlayColor: (color: string) => void + dialog: { + showOpenDialog: (options: Electron.OpenDialogOptions) => Promise + } + clipboard: { + copyImage: (dataUrl: string) => Promise<{ success: boolean; error?: string }> + } + app: { + getVersion: () => Promise + checkUpdate: () => void + simulateUpdate: () => void + fetchRemoteConfig: (url: string) => Promise<{ success: boolean; data?: unknown; error?: string }> + getAnalyticsEnabled: () => Promise + setAnalyticsEnabled: (enabled: boolean) => Promise<{ success: boolean }> + trackDailyActive: (locale: string) => Promise + relaunch: () => Promise + getOpenAtLogin: () => Promise + setOpenAtLogin: (enabled: boolean) => Promise<{ success: boolean; error?: string }> + } +} + +/** + * AiApi — IPC-only subset + * + * Most AI functionality has been migrated to HTTP shared routes. + * Only export (fs write + progress push) and native shell remain on IPC. + */ +interface AiApi { + exportFilterResultToFile: (params: { + sessionId: string + sessionName: string + outputDir: string + format?: 'txt' | 'json' | 'markdown' + timeFilter?: TimeFilter + }) => Promise<{ success: boolean; filePath?: string; error?: string }> + onExportProgress: (callback: (progress: ExportProgress) => void) => () => void + showAiLogFile: () => Promise<{ success: boolean; path?: string; error?: string }> +} + +// ==================== 新模型系统类型 ==================== + +type ProviderKind = 'official' | 'aggregator' | 'openai-compatible' + +interface ProviderDefinition { + id: string + name: string + kind: ProviderKind + website?: string + consoleUrl?: string + defaultBaseUrl: string + authMode: 'api-key' + supportsCustomModels: boolean + builtin: boolean + enabledByDefault: boolean + modelIds: string[] +} + +type ModelCapability = 'chat' | 'reasoning' | 'vision' | 'function_calling' | 'embedding' | 'ranking' +type ModelStatus = 'stable' | 'preview' | 'deprecated' +type ModelRecommendedFor = 'chat' | 'embedding' | 'rerank' + +interface ModelDefinition { + id: string + providerId: string + name: string + description?: string + contextWindow?: number + capabilities: ModelCapability[] + recommendedFor: ModelRecommendedFor[] + status: ModelStatus + builtin: boolean + editable: boolean +} + +// LLM API has been fully migrated to shared HTTP/SSE routes. +// LlmApi interface removed — no IPC consumers remain. + +/** Owner 信息(当前用户在对话中的身份) */ +interface OwnerInfo { + /** Owner 的 platformId */ + platformId: string + /** Owner 的显示名称 */ + displayName: string +} + +/** 单条脱敏规则 */ +interface DesensitizeRule { + id: string + label: string + pattern: string + replacement: string + enabled: boolean + builtin: boolean + locales: string[] + group?: string +} + +/** 聊天记录预处理配置 */ +interface PreprocessConfig { + dataCleaning: boolean + mergeConsecutive: boolean + mergeWindowSeconds?: number + blacklistKeywords: string[] + denoise: boolean + desensitize: boolean + desensitizeRulesSchemaVersion?: number + desensitizeBuiltinRuleOverrides?: Record + desensitizeRules: DesensitizeRule[] + anonymizeNames: boolean +} + +// Agent streaming migrated to shared SSE route (useAgentStreamService) +// Assistant CRUD migrated to HTTP service layer (FetchAssistantAdapter) +// Skill CRUD migrated to HTTP service layer (FetchSkillAdapter) + +/** + * CacheApi — IPC-only subset + * + * Most cache operations have been migrated to HTTP shared routes + * (FetchCacheAdapter). Only selectDataDir and setDataDir remain on IPC + * because they require native Electron dialogs and app restart. + */ +interface CacheApi { + selectDataDir: () => Promise<{ success: boolean; path?: string; error?: string }> + setDataDir: ( + path: string | null, + migrate?: boolean + ) => Promise<{ success: boolean; error?: string; from?: string; to?: string; requiresRelaunch?: boolean }> +} + +// Network API 类型 - 网络代理配置 +type ProxyMode = 'off' | 'system' | 'manual' + +interface ProxyConfig { + mode: ProxyMode // 代理模式:关闭、跟随系统、手动配置 + url: string // 仅 manual 模式使用 +} + +interface NetworkApi { + getProxyConfig: () => Promise + saveProxyConfig: (config: ProxyConfig) => Promise<{ success: boolean; error?: string }> + testProxyConnection: (proxyUrl: string) => Promise<{ success: boolean; error?: string }> +} + +// ChatLab API 服务类型 +interface ApiServerConfig { + enabled: boolean + port: number + token: string + createdAt: number +} + +interface ApiServerStatus { + running: boolean + port: number | null + startedAt: number | null + error: string | null +} + +interface ImportSession { + id: string + name: string + remoteSessionId: string + targetSessionId: string + lastPullAt: number + lastStatus: 'idle' | 'success' | 'error' + lastError: string + lastNewMessages: number +} + +interface DataSource { + id: string + name: string + baseUrl: string + token: string + intervalMinutes: number + pullLimit: number + enabled: boolean + createdAt: number + sessions: ImportSession[] +} + +interface RemoteSession { + id: string + name: string + platform: string + type: string + messageCount?: number + memberCount?: number + lastMessageAt?: number +} + +interface RemoteSessionDiscoveryPage { + hasMore: boolean + nextCursor?: string +} + +interface RemoteSessionDiscoveryResult { + sessions: RemoteSession[] + page?: RemoteSessionDiscoveryPage +} + +interface ApiServerApi { + getConfig: () => Promise + getStatus: () => Promise + setEnabled: (enabled: boolean) => Promise + setPort: (port: number) => Promise + regenerateToken: () => Promise + onStartupError: (callback: (data: { error: string }) => void) => () => void + getDataSources: () => Promise + addDataSource: (partial: { + name?: string + baseUrl: string + token: string + intervalMinutes: number + pullLimit?: number + }) => Promise + updateDataSource: ( + id: string, + updates: Partial> + ) => Promise + deleteDataSource: (id: string) => Promise + addImportSessions: ( + sourceId: string, + sessions: Array<{ name: string; remoteSessionId: string }> + ) => Promise + removeImportSession: (sourceId: string, sessionId: string, deleteData?: boolean) => Promise + triggerPull: (sourceId: string, sessionId?: string) => Promise<{ success: boolean; error?: string }> + triggerPullAll: (sourceId: string) => Promise<{ success: boolean; error?: string }> + fetchRemoteSessions: ( + baseUrl: string, + token?: string, + query?: { keyword?: string; limit?: number; cursor?: string } + ) => Promise + onPullResult: ( + callback: (data: { sourceId: string; sessionId?: string; status: string; detail: string }) => void + ) => () => void + onImportCompleted: (callback: () => void) => () => void +} + +// Session index API has been migrated to shared HTTP routes (FetchSessionIndexAdapter). + +declare global { + interface Window { + electron: ElectronAPI + api: Api + chatApi: ChatApi + aiApi: AiApi + cacheApi: CacheApi + networkApi: NetworkApi + apiServerApi: ApiServerApi + internalApi: InternalApi + } +} + +interface InternalEndpoint { + baseUrl: string + token: string +} + +interface InternalApi { + getEndpoint: () => Promise +} + +export { + ChatApi, + Api, + AiApi, + ProviderDefinition, + ProviderKind, + ModelDefinition, + ModelCapability, + ModelStatus, + ModelRecommendedFor, + CacheApi, + NetworkApi, + ProxyConfig, + AgentRuntimeStatus, + SerializedErrorInfo, + DesensitizeRule, + PreprocessConfig, + TokenUsage, + ApiServerApi, + ApiServerConfig, + ApiServerStatus, + DataSource, + ImportSession, + InternalApi, + InternalEndpoint, +} diff --git a/apps/desktop/preload/index.ts b/apps/desktop/preload/index.ts new file mode 100644 index 000000000..9fef1ef4b --- /dev/null +++ b/apps/desktop/preload/index.ts @@ -0,0 +1,64 @@ +/** + * Electron Preload Script + * 将主进程 API 暴露给渲染进程 + */ +import { contextBridge } from 'electron' +import { electronAPI } from '@electron-toolkit/preload' + +// 从拆分的模块导入 API +import { extendedApi } from './apis/core' +import { chatApi } from './apis/chat' +import { aiApi } from './apis/ai' +import { networkApi, cacheApi } from './apis/utils' +import { apiServerApi } from './apis/api-server' +import { internalApi } from './apis/internal-api' + +// 为渲染进程提供统一的类型入口,避免 type-only import 指向无导出的运行时代码。 +export type { PreprocessConfig } from './apis/ai' +export type { + ProviderDefinition, + ProviderKind, + ModelDefinition, + ModelCapability, + ModelStatus, + ModelRecommendedFor, + LLMConnectionConfig, + LLMConnectionConfigCompat, + ModelUsage, + ModelSelectionState, +} from '../main/ai/llm/model-types' + +// Use `contextBridge` APIs to expose Electron APIs to +// renderer only if context isolation is enabled, otherwise +// just add to the DOM global. +if (process.contextIsolated) { + try { + contextBridge.exposeInMainWorld('electron', electronAPI) + contextBridge.exposeInMainWorld('api', extendedApi) + contextBridge.exposeInMainWorld('chatApi', chatApi) + contextBridge.exposeInMainWorld('aiApi', aiApi) + contextBridge.exposeInMainWorld('cacheApi', cacheApi) + contextBridge.exposeInMainWorld('networkApi', networkApi) + contextBridge.exposeInMainWorld('apiServerApi', apiServerApi) + contextBridge.exposeInMainWorld('internalApi', internalApi) + } catch (error) { + console.error(error) + } +} else { + // @ts-ignore (define in dts) + window.electron = electronAPI + // @ts-ignore (define in dts) + window.api = extendedApi + // @ts-ignore (define in dts) + window.chatApi = chatApi + // @ts-ignore (define in dts) + window.aiApi = aiApi + // @ts-ignore (define in dts) + window.cacheApi = cacheApi + // @ts-ignore (define in dts) + window.networkApi = networkApi + // @ts-ignore (define in dts) + window.apiServerApi = apiServerApi + // @ts-ignore (define in dts) + window.internalApi = internalApi +} diff --git a/apps/desktop/shared/types.ts b/apps/desktop/shared/types.ts new file mode 100644 index 000000000..a4e38e70b --- /dev/null +++ b/apps/desktop/shared/types.ts @@ -0,0 +1,43 @@ +/** + * Electron 主进程 / Preload / 渲染进程共享的 Agent 类型定义 + * + * 此文件是 AgentRuntimeStatus、TokenUsage 等跨进程类型的唯一定义源。 + * 所有使用方应从此处导入,避免重复定义导致类型漂移。 + */ + +/** + * 序列化后的结构化错误信息,跨进程传输 & 持久化存储。 + * 所有字段可选——仅在原始错误中存在时才填充。 + */ +export interface SerializedErrorInfo { + name: string | null + message: string | null + stack: string | null + statusCode?: number | null + url?: string | null + responseBody?: string | null + responseHeaders?: Record | null + requestBody?: string | null + cause?: string | null + provider?: string | null + /** formatAIError 生成的用户友好摘要 */ + friendlyMessage?: string | null +} + +export interface TokenUsage { + promptTokens: number + completionTokens: number + totalTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +export interface AgentRuntimeStatus { + phase: 'compressing' | 'preparing' | 'thinking' | 'tool_running' | 'responding' | 'completed' | 'aborted' | 'error' + round: number + toolsUsed: number + currentTool?: string + contextTokens: number + totalUsage: TokenUsage + updatedAt: number +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 000000000..95a6de834 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["../../src/*"], + "@openchatlab/*": ["../../packages/*"], + "@electron/*": ["./*"] + } + }, + "files": [], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json new file mode 100644 index 000000000..00e417498 --- /dev/null +++ b/apps/desktop/tsconfig.node.json @@ -0,0 +1,33 @@ +{ + "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", + "include": [ + "electron.vite.config.*", + "main/**/*", + "preload/**/*", + "shared/**/*", + "../../src/types/**/*", + "../../packages/shared-types/**/*", + "../../packages/core/**/*", + "../../packages/node-runtime/**/*", + "../../packages/config/**/*", + "../../packages/parser/**/*", + "../../packages/mcp-server/**/*", + "../../apps/cli/**/*", + "../../packages/tools/**/*", + "../../packages/sync/**/*", + "../../packages/http-routes/**/*" + ], + "exclude": ["**/node_modules/**", "**/dist/**", "../../**/*.test.ts", "../../**/*.spec.ts", "../../tests/**"], + "compilerOptions": { + "composite": true, + "types": ["electron-vite/node"], + "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@/*": ["../../src/*"], + "@electron/*": ["./*"], + "@openchatlab/*": ["../../packages/*"], + "chatlab-mcp": ["../../packages/mcp-server/src"] + } + } +} diff --git a/build/icon.icns b/build/icon.icns deleted file mode 100644 index e34e3b76b..000000000 Binary files a/build/icon.icns and /dev/null differ diff --git a/build/icon.ico b/build/icon.ico deleted file mode 100644 index 205ea4b98..000000000 Binary files a/build/icon.ico and /dev/null differ diff --git a/build/icon.png b/build/icon.png deleted file mode 100644 index c6dd09035..000000000 Binary files a/build/icon.png and /dev/null differ diff --git a/changelogs/cn.json b/changelogs/cn.json new file mode 100644 index 000000000..19db832f2 --- /dev/null +++ b/changelogs/cn.json @@ -0,0 +1,1770 @@ +[ + { + "version": "0.29.0", + "date": "2026-07-01", + "summary": "新增联系人功能,支持关系星图,优化联系人计算、头像加载和侧边栏性能。", + "changes": [ + { + "type": "feat", + "items": [ + "新增人际关系模块,将联系人作为子页面并支持跨会话联系人聚合、时间范围筛选、分页与虚拟滚动", + "联系人页支持手动将群友标记为好友,并提供来源对话跳转与聊天记录查看入口", + "新增互动关系星图,支持 3D 全景、节点筛选、搜索、详情面板和相关联系人/群组探索", + "关系星图支持高关联、仅好友等视图筛选,并在隐私模式下对姓名进行脱敏", + "【桌面端】支持后台静默下载应用更新" + ] + }, + { + "type": "perf", + "items": [ + "联系人列表改为分页与虚拟滚动,减少大量好友和群友带来的渲染压力", + "头像改为懒加载,并虚拟化侧边栏会话列表,降低启动和页面切换卡顿" + ] + }, + { + "type": "style", + "items": ["统一暗色模式主背景、关系页头部和侧边栏选中态"] + } + ] + }, + { + "version": "0.28.1", + "date": "2026-06-26", + "summary": "聊天记录查看优化,新增导入 API,修复导入、同步和本地模型代理下载问题。", + "changes": [ + { + "type": "feat", + "items": [ + "新增 Push 导入 API,支持按会话追加写入、去重和增量索引生成", + "导入完成后自动生成会话索引,覆盖 CLI、桌面端和 pull sync 导入路径", + "优化聊天记录查看器控件与高亮显示体验", + "侧边栏默认隐藏滚动条,悬停时显示" + ] + }, + { + "type": "fix", + "items": [ + "强化 Push/Pull 导入校验、去重和并发锁,避免异常输入导致误写或重复导入", + "GET /api/v1/sessions/:id 补齐 lastPlatformMessageId 和 importedAt 字段,支持增量导入边界判断", + "后台 pull sync 完成后自动刷新会话列表", + "修复本地语义索引模型下载未正确使用代理的问题,并补齐 Worker 日志初始化", + "【桌面端】API 默认端口与 CLI 对齐为 3110,并在端口占用时自动尝试后续端口", + "【桌面端】系统代理解析支持 HTTP、HTTPS 与 SOCKS 结果,避免本地模型下载静默绕过代理" + ] + }, + { + "type": "refactor", + "items": [ + "【CLI】pull/sync 导入路径改用共享 streaming importer,移除旧解析与写库实现", + "【桌面端】移除增量导入后重复生成会话索引的冗余调用" + ] + }, + { + "type": "docs", + "items": ["修正文档中的 API 端口、示例与未实现能力说明"] + } + ] + }, + { + "version": "0.28.0", + "date": "2026-06-25", + "summary": "新增 Google Chat 导入支持,引入统一应用日志模块,优化语义索引使用体验。", + "changes": [ + { + "type": "feat", + "items": [ + "新增 Google Chat 导入支持,覆盖 CLI、桌面端全链路", + "新增统一应用日志模块,支持按文件大小自动轮转与全局未捕获错误记录", + "语义索引管理改为「移除」操作替代禁用,修复 legacy hash,限制列表高度", + "语义索引模型配置保存时自动预热,并迁移至 AI 目录统一管理", + "简化语义索引设置,移除搜索结果数量配置项,改为内置默认值" + ] + }, + { + "type": "fix", + "items": ["补齐 stream-json/stream-chain 依赖声明,修复多聊天扫描穿透问题"] + }, + { + "type": "docs", + "items": ["文档新增 Google Chat 支持平台说明"] + } + ] + }, + { + "version": "0.27.2", + "date": "2026-06-24", + "summary": "修复 AI auth profile 生命周期管理多处问题,完善分析服务稳定性,补齐本地嵌入运行时依赖。", + "changes": [ + { + "type": "feat", + "items": ["【CLI Web】通过共享 AnalyticsService 统一上报日活用户数据"] + }, + { + "type": "fix", + "items": ["删除 AI 服务配置时同步清理对应 auth profile"] + }, + { + "type": "refactor", + "items": ["移除已过期的分析设置迁移逻辑"] + } + ] + }, + { + "version": "0.27.1", + "date": "2026-06-23", + "summary": "引入 API 批量嵌入提速与本地模型惰性加载,修复语义索引断点续跑、同步游标回退与对话导出等多项问题。", + "changes": [ + { + "type": "feat", + "items": [ + "语义索引支持 API 嵌入模型批量向量化,大幅提升索引构建速度", + "本地嵌入模型改为惰性 Worker 加载,减少启动资源占用" + ] + }, + { + "type": "fix", + "items": [ + "修复 AI 对话导出:支持导出当前可见的 AI 对话内容", + "修复语义索引批量写入中途崩溃后续跑触发唯一约束错误、会话状态卡死的问题", + "修复语义 Worker 多项问题:配置变更未同步到运行中 Worker、可用性探针异常影响普通 AI 对话、活跃构建状态追踪不准确导致 Worker 提前关闭", + "修复同步拉取游标:空响应重试前未保存初始页服务端水位,导致重复拉取尾部窗口", + "修复同步拉取分页游标错误回退,避免重复导入" + ] + }, + { + "type": "refactor", + "items": ["清理多处死代码与过度设计的抽象,简化工具目录结构与深合并逻辑"] + } + ] + }, + { + "version": "0.27.0", + "date": "2026-06-22", + "summary": "引入向量索引与证据检索功能,支持基于向量搜索定位并呈现聊天证据,新增索引管理界面。", + "changes": [ + { + "type": "feat", + "items": [ + "新增语义索引管理界面,支持选择指定会话构建向量索引,提供多语言支持", + "新增 retrieve_chat_evidence 工具:AI 可通过语义搜索检索带时间定位的聊天证据", + "AI 规划器自动识别证据类问题,路由到语义检索工具进行处理", + "证据内容块支持展开查看、折叠收起与点击跳转原始消息", + "语义检索支持时间范围过滤", + "重新设计语义索引模型选择界面", + "AI 处理过程片段支持折叠收起", + "精简证据来源行与 Agent 工具调用结果展示" + ] + }, + { + "type": "fix", + "items": [ + "修复多关键词消息搜索功能", + "修复词云词典加载 UX 回归问题", + "修复排行榜标签与 emoji 清洗", + "修复 v8 数据库迁移缺少国际化文本", + "【CLI】修复工具适配器中会话上下文传递与预处理缺失" + ] + } + ] + }, + { + "version": "0.26.3", + "date": "2026-06-15", + "summary": "优化分析模块性能:新增磁盘缓存与过期请求取消,切换会话更流畅;修复词云、高频词统计及时间轴等多处问题。", + "changes": [ + { + "type": "feat", + "items": ["【CLI Web】新增网站图标(favicon)"] + }, + { + "type": "perf", + "items": [ + "分析结果按数据库文件版本落盘缓存,大幅提升二次加载速度", + "切换会话或过滤条件时自动取消过期分析请求", + "切换词云词数时跳过重新分词,减少不必要计算" + ] + }, + { + "type": "fix", + "items": [ + "修复高频词统计中含有平台回复消息占位符的问题", + "修复词云文本中包含媒体占位符的问题", + "修复 Jieba 自定义词典变更后语言偏好缓存与词频缓存未失效的问题", + "修复时效性分析缓存未按日失效的问题", + "修复时间轴面板默认滚动位置及会话跳转功能失效的问题", + "修复片段消息上下文缺失的问题", + "修复 AI 对话数量始终返回 0 的问题", + "修复首页教程链接未使用本地化路径的问题", + "移除 AI 聊天顶部截图按钮", + "【桌面端】预打包懒路由依赖,避免开发模式 504 错误", + "【CLI】修复版本号不同步导致的误报更新提示", + "【CLI】更新提示支持方向键选择" + ] + }, + { + "type": "refactor", + "items": ["将时间轴面板标签重命名为「摘要」"] + } + ] + }, + { + "version": "0.26.2", + "date": "2026-06-13", + "summary": "优化会话「我是谁」身份识别功能,支持手动选择 owner profile 并自动批量填充同平台会话;修复多处图表渲染与 AI 缓存问题。", + "changes": [ + { + "type": "feat", + "items": [ + "新增会话「我是谁」身份识别功能:手动确认后保存平台 owner profile,并自动批量匹配填充同平台其他未设置所有者的会话", + "导入和 pull sync 完成后自动尝试应用已保存的 owner profile,减少手动配置次数" + ] + }, + { + "type": "fix", + "items": [ + "修复 name-match 平台(WhatsApp / Line / Instagram)批量填充时各会话 ownerId 缓存与数据库写入不一致的问题", + "修复多路由共用独立 PreferencesManager 实例导致 owner profile 被覆写为旧值的问题", + "【CLI】修复长期运行时 sync 模块 preferences 缓存过期,pull 导入新会话后 owner profile 未自动生效", + "修复导入后临时文件未完全删除的问题", + "修复 AI 图表在流式生成期间渲染抖动的问题", + "修复 AI 图表渲染顺序不稳定的问题", + "修复 AI 图表存在仅占位空行的问题", + "新增 AI 图表生成进度提示", + "修复截图前未调整图表尺寸导致图表显示不完整的问题", + "修正 last_message_time 语义:表示数据覆盖截止时间,而非群组最后活跃时间", + "修复增量导入后概览与分析缓存未失效导致显示旧数据的问题" + ] + } + ] + }, + { + "version": "0.26.1", + "date": "2026-06-10", + "summary": "修复工具调用历史重放多处问题,补齐工具调用持久化与重放能力;新增状态栏 Token 缓存统计,消息导出支持多种格式。", + "changes": [ + { + "type": "feat", + "items": ["聊天状态栏新增 Token 缓存命中/未命中用量展示", "消息导出支持 TXT、JSON、Markdown 多种格式"] + }, + { + "type": "fix", + "items": [ + "支持持久化并重放对话中的工具调用记录,保持多轮 AI 上下文完整性", + "修复工具调用无文字输出的 assistant 轮次被历史重放过滤导致工具结果丢失的问题", + "修复连续导出同一会话时文件名冲突导致静默覆盖的问题", + "【桌面端】修复大型会话导出因 60 秒 IPC 超时提前失败的问题", + "修复 DeepSeek 工具调用轮次的 reasoning_content 未随历史重放", + "修复历史压缩时重放工具结果的 token 数未被正确计入", + "修复工具结果传给模型的文本中包含不应暴露的原始消息数据", + "修复工具面板点击外部区域时未自动关闭" + ] + }, + { + "type": "refactor", + "items": ["提取会话页面顶部栏为共享组件"] + } + ] + }, + { + "version": "0.26.0", + "date": "2026-06-10", + "summary": "新增 AI 分析规划器,优化系统提示词,支持流式结构化计划生成与图表规划集成;优化图表模式工具可用性,修复多处 AI 与导入问题。", + "changes": [ + { + "type": "feat", + "items": [ + "新增 AI 分析规划器,支持流式结构化分析计划生成与区块渲染", + "集成图表规划支持,AI 分析计划可自动衔接图表生成", + "规划器引入启动上下文与扩展数据快照,提升分析深度和质量", + "图表渲染优先使用高层工具而非原始 SQL,降低权限要求", + "优化 AI 分析与图表整体行为", + "新增图表自动模式偏好设置", + "新增消息 ID 复制操作", + "新增 shadow 路由 LLM 回退机制,并记录路由决策日志", + "【CLI】暴露完整智能体流式事件" + ] + }, + { + "type": "fix", + "items": [ + "修复显式选择图表技能时 render_chart 工具丢失的问题", + "修复显式图表模式下工具可用性缺口及错误后计划状态异常", + "迁移 ECharts 6 containLabel 配置,隐藏多余饼图图例", + "修复设置页导航顺序、skillSettings 键名及 SubTabs 换行问题", + "修复 AI 助手消息未填满聊天区域宽度", + "优化分析计划生成区块的展示样式", + "修复思考深度选择跨重启不持久的问题", + "修复 JSONL 时间戳规范化导致增量导入异常", + "【桌面端】修复 execute_sql 工具未注册到桌面端工具注册表" + ] + }, + { + "type": "test", + "items": ["新增智能体路由决策评估测试集"] + } + ] + }, + { + "version": "0.25.1", + "date": "2026-06-08", + "summary": "新增数据目录兼容门禁,防止旧版运行时误写已升级的数据;并新增 CLI ai chat 命令。", + "changes": [ + { + "type": "feat", + "items": [ + "新增数据目录兼容门禁:写入 v6 schema 数据后自动记录最低可读写运行时版本,防止旧版本误写已升级的数据目录", + "数据库访问前校验数据目录兼容版本,版本不满足时拒绝操作", + "【桌面端】启动时校验数据目录兼容版本,不满足时弹窗提示并退出", + "【CLI】启动时校验数据目录兼容版本,不满足时立即退出", + "【CLI】新增 ai chat 命令,支持在终端与 AI 进行多轮交互对话" + ] + }, + { + "type": "fix", + "items": [ + "使用打包版本号(而非 Electron 开发模式报告的 0.0.0)写入兼容门禁,并强制要求运行时身份标识", + "数据库迁移和导入完成后正确写入兼容门禁;启动迁移失败时立即报错,不再静默忽略", + "【MCP】启动时主动校验数据目录兼容性,版本不满足时提前退出,避免写入不兼容数据", + "【桌面端】HTTP API 将兼容门禁错误映射为 409 响应,方便前端展示升级提示", + "【CLI】规范化 AI 工具名称,修复 ai chat 命令的边界处理问题" + ] + }, + { + "type": "refactor", + "items": ["对齐 AI 对话与 segment 标识符命名规范"] + }, + { + "type": "test", + "items": ["补充解析器、配置迁移、数据库迁移、HTTP 路由和 AI 工具的测试覆盖"] + }, + { + "type": "docs", + "items": ["新增数据目录兼容门禁公开文档,说明多版本共享数据目录的限制与升级规则"] + } + ] + }, + { + "version": "0.25.0", + "date": "2026-06-07", + "summary": "AI 对话现在支持通过技能生成图表,修复工具轮次、桌面标题栏和开发服务生命周期问题。", + "changes": [ + { + "type": "feat", + "items": [ + "新增斜杠触发的 AI 图表运行时,支持在对话中生成并渲染 ECharts 图表", + "优化 AI 图表结果展示体验,补充图表渲染状态和多类型图表适配" + ] + }, + { + "type": "fix", + "items": [ + "提升 AI Agent 默认工具调用轮次,减少复杂任务中过早停止的问题", + "修复预设问题标签和侧边栏元素层叠顺序异常", + "【CLI Web】修复开发后端生命周期清理不完整的问题", + "【桌面端】修复自动技能未注册图表工具,导致图表技能无法完整执行的问题", + "【桌面端】修复 Windows 标题栏覆盖层切换主题后缓存未刷新和显示不平滑的问题" + ] + }, + { + "type": "refactor", + "items": [ + "集中管理 AI 图表运行时策略,统一 CLI 与桌面端的图表工具启用规则", + "精简技能菜单与 Skill Manager 的重复逻辑" + ] + }, + { + "type": "docs", + "items": ["更新 iMessage 聊天记录导出指南"] + }, + { + "type": "chore", + "items": ["移除公开仓库中的维护者专用技能目录,转为私有维护上下文管理"] + } + ] + }, + { + "version": "0.24.1", + "date": "2026-06-04", + "summary": "新增应用内更新提醒与 AI 预处理默认规则,修复更新提示和脱敏配置保存问题。", + "changes": [ + { + "type": "feat", + "items": [ + "新增应用内更新提醒入口,侧边栏可提示并打开最新版本说明", + "新增 AI 预处理默认配置,自动启用数据清洗、去噪和脱敏等基础规则", + "支持按分组管理 AI 脱敏规则,便于维护内置与自定义规则" + ] + }, + { + "type": "fix", + "items": [ + "修复更新提醒缓存失败结果、旧版本缓存和 CLI Web 开发占位版本导致的错误 New 标记", + "修复 AI 预处理执行前未正确应用内置脱敏规则的问题", + "修复脱敏规则偏好保存时空覆盖无法清除旧内置规则开关的问题", + "修复旧版内置脱敏规则迁移后覆盖设置丢失的问题" + ] + }, + { + "type": "docs", + "items": ["新增公开开发指南,补充本地开发、目录职责和协作规范"] + }, + { + "type": "ci", + "items": ["发布流程新增 Markdown 版本日志链接"] + } + ] + }, + { + "version": "0.24.0", + "date": "2026-06-03", + "summary": "新增 CLI Web 认证与数据目录迁移能力,统一多端 HTTP 路由,并修复数据迁移、AI 配置和导入刷新问题。", + "changes": [ + { + "type": "feat", + "items": [ + "新增多语言版本日志 Markdown 生成能力", + "支持在存储管理中忽略旧数据迁移提示", + "【CLI Web】新增登录页、Token 认证和记住登录状态", + "【CLI Web】支持数据目录迁移流程,可在 Web 设置中切换并迁移数据目录", + "【CLI】新增 --require-auth 参数,用于保护 /_web/* API 访问", + "【桌面端】新增内部 HTTP 服务,支持前端统一通过服务适配层访问共享接口" + ] + }, + { + "type": "fix", + "items": [ + "修复数据目录迁移与数据库迁移流程中的多处边界问题,避免旧库字段缺失或路径切换导致读取失败", + "修复数据目录迁移提示在旧数据不存在或目录变更后仍错误显示的问题", + "修复增量导入后侧边栏消息数量未刷新的问题", + "修复侧边栏折叠状态仅存于 sessionStorage 导致刷新后丢失的问题", + "修复 AI 配置编辑时已保存密钥未正确启用拉取模型和验证按钮的问题", + "修复 AI 共享 SSE 流式响应的稳定性问题", + "修复自定义数据源新增弹窗未强制填写 Token 的问题", + "【桌面端】将图表插件计算移入 Worker,避免主线程计算阻塞" + ] + }, + { + "type": "refactor", + "items": [ + "抽取 @openchatlab/http-routes 共享包,统一 CLI Web 与桌面端的 HTTP 路由实现", + "将 AI 配置、助手、技能、会话、流式响应、缓存和合并相关接口迁移到共享 HTTP 路由", + "精简桌面端 IPC 桥接层,移除遗留的 AI、会话索引、LLM、Assistant、Skill、NLP 等 IPC 兼容代码", + "统一前端服务层,减少 Electron 与 Web 模式的分支实现" + ] + }, + { + "type": "perf", + "items": ["压缩主进程构建产物,并延迟加载 tiktoken rank table,降低启动和打包体积压力"] + }, + { + "type": "ci", + "items": ["修复 Windows 发布流程中的 zstd 缓存问题,并补充 CLI 更新发布说明"] + }, + { + "type": "chore", + "items": ["调整 Node 类型检查项目配置,覆盖桌面端与共享 Node 代码"] + } + ] + }, + { + "version": "0.23.1", + "date": "2026-06-01", + "summary": "新增 clb 短别名与端口预检引导,修复守护进程静默退出及暗色模式标题栏等多处问题。", + "changes": [ + { + "type": "feat", + "items": [ + "优化页面顶部标题栏与工具栏布局", + "【CLI】启动前主动检测端口占用,被占用时给出换端口或 lsof 引导,避免延迟报错", + "【CLI】新增 clb 作为 chatlab 命令的短别名" + ] + }, + { + "type": "fix", + "items": [ + "修复侧边栏 Tooltip 位置异常及 Nuxt UI v4 API 兼容问题", + "修复标题栏暗色模式出现红色背景与层叠顺序错误", + "规范 AI 消息角色参数类型,加强对话测试断言", + "【CLI】修复守护进程启动入口错误导致服务启动后静默退出的问题", + "【CLI】改善端口检测的错误处理与提示信息", + "【CLI】修复 Web 模式下缺少 chatlab.fun 反向代理路由的问题" + ] + } + ] + }, + { + "version": "0.23.0", + "date": "2026-05-31", + "summary": "重构消息编辑交互,新增推理等级按模型独立配置,统一 CLI 启动入口,修复多处推理识别与计算问题。", + "changes": [ + { + "type": "feat", + "items": [ + "新增消息派生(Fork)功能,可从任意 AI 回复处创建独立分支会话", + "状态栏新增推理强度选择器,支持为每个推理模型单独记忆并切换思维等级", + "推理等级控制新增 default/auto 选项,扩展对 Kimi、Doubao、Gemini 等更多模型家族的覆盖", + "消息分析视图拆分为「类型分析」和「时间分析」两个标签页,增加丰富统计洞察卡片", + "批量重建会话索引前新增确认弹窗,防止误操作清空现有摘要", + "演示数据扩展为 4 个文件,覆盖群组及多个私聊场景", + "【CLI】新增统一启动命令 chatlab start,支持 --headless 和 --no-open 参数", + "【CLI】新增后台服务模式,支持 chatlab start --daemon 安装为系统服务,以及 stop/status 命令" + ] + }, + { + "type": "fix", + "items": [ + "修复消息编辑可能导致数据丢失和状态错误的多个并发安全问题", + "修复思维等级与上下文窗口计算未使用当前激活模型 ID 的问题", + "修复自定义模型仅含 chat 能力时推理识别失败,补全启发式回退逻辑", + "修复 Kimi、Doubao 等模型在选择 auto 思维等级时被静默禁用的问题", + "【CLI】修复 start 命令未启动 Web 开发后端的问题", + "【CLI】修复 Linux 上服务路径含空格时守护进程启动失败的问题" + ] + }, + { + "type": "refactor", + "items": [ + "重构消息分支系统为「编辑并重新生成」模型,支持仅更新当前轮或覆盖后续消息两种模式", + "移除模型配置中的「推理模型」和「禁用思维模式」开关,改为按能力自动推断", + "简化模型切换按钮 UI,优化会话索引加载性能" + ] + } + ] + }, + { + "version": "0.22.1", + "date": "2026-05-29", + "summary": "新增会话摘要详细程度配置,修复服务版批量摘要卡死、AI 配置编辑误判及多处 AI 凭据检测问题。", + "changes": [ + { + "type": "feat", + "items": ["新增会话摘要详细程度设置,支持「简洁」与「标准」两种策略,可在 AI 设置中切换"] + }, + { + "type": "fix", + "items": [ + "修复 OpenAI 兼容模式下修改 Base URL 后未重新验证凭据的问题", + "修复 API Key 已设检测逻辑错误,防止旧 key 被意外复用", + "修复批量生成摘要时点击停止需等待当前请求完成才响应的问题", + "【CLI Web】修复批量生成摘要导致页面卡死的问题", + "【CLI Web】修复批量生成摘要时未遵循已选会话范围的问题", + "【CLI Web】修复编辑 AI 配置时第三方服务被误判为本地服务的问题" + ] + }, + { + "type": "refactor", + "items": ["将会话索引国际化 key 从 storage 命名空间迁移至 ai 命名空间"] + }, + { + "type": "style", + "items": ["优化聊天记录列表密度与消息气泡样式"] + }, + { + "type": "docs", + "items": ["重整文档站导航结构,将快速开始迁移至使用指南目录"] + } + ] + }, + { + "version": "0.22.0", + "date": "2026-05-26", + "summary": "本次优化了软件默认状态的样式,并新增 CLI Web 更新与存储管理能力,优化首页导入、文档站和多端稳定性。", + "changes": [ + { + "type": "feat", + "items": [ + "首页导入区重构为分入口体验,新增 API 导入和自动同步入口", + "版本日志改为随应用本地打包,减少运行时远程依赖", + "【CLI Web】新增存储管理能力,可在 Web 设置中查看和管理数据缓存", + "【CLI】新增更新检查与自动更新流程,并接入 CLI Web 检查更新", + "【文档站】新增独立文档站 docs.chatlab.fun" + ] + }, + { + "type": "fix", + "items": [ + "修复首页快捷开始按钮缺失的问题", + "修复国际化 key 路径错误和 ECharts 废弃 API 用法", + "修复自更新与迁移重试流程中的安全保护问题", + "【桌面端】修复统一迁移可能回退数据目录改动的问题", + "【CLI Web】修复发现新版本时展示必然失败的“立即更新”操作", + "【CLI Web】禁用不可用的 Web 自更新执行流程", + "【CLI Web】修复文件管理操作通过 shell 打开带来的兼容性和安全问题", + "【CLI Web】修复数据目录提示后 Web 服务无法继续启动的问题", + "【CLI Web】补齐合并相关 API 兼容层", + "【CLI】优化更新检查的异步缓存、按键交互和开发模式跳过逻辑" + ] + }, + { + "type": "refactor", + "items": ["迁移文档链接到 docs.chatlab.fun", "【设置】将会话索引移入 AI 设置并调整设置页排序"] + }, + { + "type": "style", + "items": ["优化侧边栏密度,并增强 Tab 选择器在深色模式下的对比度"] + }, + { + "type": "docs", + "items": ["重整公开文档站结构与导出说明"] + }, + { + "type": "chore", + "items": ["将工作区包迁移为 ESM", "隔离文档站工作区依赖", "将发布版本日志移出 docs 目录"] + } + ] + }, + { + "version": "0.21.1", + "date": "2026-05-23", + "summary": "优化同步拉取可靠性与数据安全性,新增移除订阅时清理聊天记录选项,并修复 UI 动画和弹窗交互问题。", + "changes": [ + { + "type": "feat", + "items": [ + "Pull 同步后自动生成会话索引", + "新增移除订阅时清理已导入聊天记录的选项", + "【CLI Web】版本日志弹窗支持按版本截图,Markdown 列表修正改为可选", + "【MCP】新增 ci 变更类型的图标与国际化支持" + ] + }, + { + "type": "fix", + "items": [ + "修复 Pull 同步在小页面场景下可能丢失数据的问题,并校验重试导入结果", + "修复拉取同步后会话索引未自动生成的问题", + "修复 Pull 完成或数据删除后侧边栏会话列表未刷新的问题", + "修复远端服务不支持分页时无法获取全部会话的问题", + "优化拉取同步的重试机制与分页策略,提升稳定性", + "修复会话存在性校验缺少 schema 检查导致表缺失报错的问题", + "修复强制生成会话索引弹窗在失败时意外关闭的问题", + "修复空会话状态下会话索引弹窗阻塞页面的问题", + "【桌面端】修复应用版本号显示为 0.0.0 时自动回退读取 package.json 版本", + "【CLI Web】同步图标改为原地旋转动画,避免出现独立加载器" + ] + }, + { + "type": "docs", + "items": ["更新 Pull 协议文档为 since+nextSince 分页模式"] + } + ] + }, + { + "version": "0.21.0", + "date": "2026-05-22", + "summary": "本次新增支持 MCP,统一多端导入与服务层,并修复 Web、AI、同步和发布稳定性问题。", + "changes": [ + { + "type": "feat", + "items": [ + "Electron 与 Web 模式支持统一的文件夹导入流程,适配多文件聊天记录格式", + "【MCP】新增独立命令入口,并在设置页接入 MCP 配置", + "【MCP】Server 扩展到 19 个工具,并支持紧凑文本与 JSON 双格式输出" + ] + }, + { + "type": "fix", + "items": [ + "修复目录导入路径处理不稳导致的导入失败风险", + "修复新增订阅时可能出现的同步竞态问题", + "修复 MiniMax 流式响应中的 内容未正确识别为思考事件的问题", + "【CLI Web】修复增量导入不可用的问题", + "【CLI Web】修复会话不存在、成员历史等行为与桌面端不一致的问题", + "【CLI Web】修复开发服务的 Node 运行时配置问题", + "【MCP】修复启动时原生模块 ABI 绑定不一致的问题" + ] + }, + { + "type": "refactor", + "items": [ + "统一 CLI Web 与 Electron 的共享服务层,减少路由与 IPC 中的重复业务逻辑", + "【MCP】瘦身对外暴露的工具注册表,降低外部 AI Agent 的工具 schema 成本", + "【MCP】将核心能力抽取为独立共享包,简化 CLI 与桌面端集成", + "【MCP】精简设置页集成方式,降低桌面端辅助代码复杂度" + ] + }, + { + "type": "ci", + "items": ["【CLI】发布流程支持同步发布 npm 包"] + }, + { + "type": "docs", + "items": ["补充 changelog 端特有变更的前缀与排序规则"] + }, + { + "type": "chore", + "items": ["【CLI】【MCP】补齐 npm 发布所需配置和发布说明"] + } + ] + }, + { + "version": "0.20.0", + "date": "2026-05-19", + "summary": "本次更新统一了多端核心架构,并优化 AI、导入、同步和桌面构建稳定性。为下个版本的独立 Web、CLI 与 MCP 能力做准备,使用CLI前必须升级至此版本以完成数据预迁移。", + "changes": [ + { + "type": "feat", + "items": [ + "新增独立 CLI、HTTP API 服务和 MCP Server,为命令行、Web 与 AI 代理集成提供基础能力", + "新增独立 Web 构建与一键启动能力,支持通过 CLI 启动 Web UI 并自动打开浏览器", + "Web 模式支持聊天记录导入、Demo 导入、会话查询、成员查询、搜索与分析等核心流程", + "Web 模式接入 AI 对话、模型配置、自定义 Provider/Model、上下文压缩和流式事件展示", + "导入能力升级为共享流式管道,支持多格式解析、增量导入、导入分析和自动生成会话索引", + "新增合并、Markdown 导出和会话缓存相关服务端能力", + "新增同步共享包和 CLI 自动化支持,为后续多端同步能力打基础", + "新增后端偏好设置持久化,并支持 CLI 首次运行时自动检测和迁移桌面端数据" + ] + }, + { + "type": "fix", + "items": [ + "修复 Web 模式下会话索引、跨域代理、Demo 保护和运行时异常等问题", + "修复 Web 模式下应用版本显示为空的问题", + "修复 AI Agent 证据检索、Web 端事件流和错误格式化等问题", + "修复同步展示与导入逻辑不一致的问题", + "修复 CLI 开发模式下 ESM 模块解析问题", + "修复 CLI 安装后可能找不到既有桌面端数据的问题", + "修复 Electron 包拆分后数据迁移路径不一致的问题", + "修复打包后 Worker 线程间接依赖 electron 模块导致崩溃的问题", + "修复 Electron 构建、依赖安装和 better-sqlite3 原生模块重编译相关问题" + ] + }, + { + "type": "refactor", + "items": [ + "将项目迁移为多端工作区结构,拆分 apps/desktop、apps/cli 与多个共享 packages", + "将解析器、配置、数据库适配、查询、迁移、NLP、会话缓存、导入、合并、导出和同步逻辑抽取为共享模块", + "将 AI Agent、工具系统、预处理、上下文压缩、RAG、LLM 配置、助手和技能管理抽取为共享运行时能力", + "统一 Electron、CLI Web 和 MCP 的 AI 工具命名、工具注册和数据访问方式", + "统一数据目录、消息查询、成员查询、会话索引、SQL 执行和导入去重等核心逻辑", + "将 CLI 从 packages/server 迁移到 apps/cli,并补齐 npm 发布构建链路", + "将纯前端图表模块迁移到 src/features,保持 packages 目录仅存放可复用共享包", + "移除多处 Electron 侧纯转发文件和废弃代码,减少重复实现" + ] + }, + { + "type": "build", + "items": [ + "升级 Vite 等构建工具链", + "新增 CLI tsup 构建、Web 资源打包和 npm 发布所需配置", + "调整 Electron 桌面端构建配置,移除 Linux 桌面构建目标" + ] + }, + { + "type": "docs", + "items": ["补充版本策略、提交 scope 约定和相关开发文档"] + } + ] + }, + { + "version": "0.19.0", + "date": "2026-05-06", + "summary": "本次更新支持了 AI 上下文自动压缩能力,新增 Demo 示例,并优化模型配置与调试体验。", + "changes": [ + { + "type": "feat", + "items": [ + "支持模型上下文窗口预设和自定义配置", + "支持 AI 对话上下文自动压缩及相关配置", + "优化上下文压缩流程与状态展示", + "调试模式新增原始数据查看,并支持记录完整 LLM 上下文", + "优化 AI 模型配置文案、表单和设置页展示", + "移除向量模型配置逻辑,简化相关设置", + "新用户可在空状态直接查看 Demo 示例", + "迁移到 pnpm workspace 项目结构" + ] + }, + { + "type": "fix", + "items": [ + "修复快速模型跟随默认助手时可能使用错误模型的问题", + "修复 Demo 按钮在空数据状态下不显示的问题", + "修复主进程直接依赖未声明 axios 导致启动失败的风险" + ] + } + ] + }, + { + "version": "0.18.4", + "date": "2026-04-29", + "summary": "本次更新优化了模型稳定性,同时支持远程获取模型列表,优化 AI 错误详情展示与部分样式等。", + "changes": [ + { + "type": "feat", + "items": [ + "支持远程获取模型列表", + "OpenAI 兼容 API 地址自动补全 /v1 并支持实时预览", + "优化 AI 对话错误详情展示", + "优化部分展示样式" + ] + }, + { + "type": "fix", + "items": ["修复部分逻辑漏洞"] + }, + { + "type": "chore", + "items": ["优化同步日志技能逻辑"] + } + ] + }, + { + "version": "0.18.3", + "date": "2026-04-28", + "summary": "支持配置快捷工具入口位置,优化默认时间筛选,并修复弹窗层级与数据目录安全提示。", + "changes": [ + { + "type": "feat", + "items": ["支持配置快捷工具入口位置", "优化默认时间筛选体验", "禁止将数据目录设置在应用安装目录下"] + }, + { + "type": "fix", + "items": ["修复弹窗样式覆盖问题", "修复设置页内弹窗被遮挡的层级问题"] + } + ] + }, + { + "version": "0.18.2", + "date": "2026-04-26", + "summary": "新增订阅类型选择、远程会话分页发现与每次拉取条数配置。", + "changes": [ + { + "type": "feat", + "items": [ + "订阅会话时支持按类型筛选与选择", + "支持远程会话分页发现与按需加载更多", + "支持为数据源配置每次拉取的消息条数" + ] + } + ] + }, + { + "version": "0.18.1", + "date": "2026-04-24", + "summary": "新增 DeepSeek V4 支持,新增开机自启动选项,优化设置与界面体验,并修复 AI 对话链接打开方式。
从本版本开始,项目将正式迁移至ChatLab组织。", + "changes": [ + { + "type": "feat", + "items": [ + "迁移至 ChatLab 组织", + "优化全局样式表现", + "优化快速提问展示逻辑", + "优化设置弹窗弹出方式", + "支持开机自启动", + "支持 DeepSeek V4 模型" + ] + }, + { + "type": "fix", + "items": ["修复 AI 对话中的链接未在浏览器中打开的问题"] + } + ] + }, + { + "version": "0.18.0", + "date": "2026-04-23", + "summary": "优化 AI 对话交互逻辑,统一多处分析入口,优化数据源同步与 Windows 更新稳定性。", + "changes": [ + { + "type": "feat", + "items": [ + "AI 助手交互逻辑优化", + "调整添加数据源表单提示文案", + "工具 Panel 新增 Mini 模式", + "将聊天记录查看器迁移到工具 Panel", + "统一关系分析相关 Tab", + "将语录模块整体迁移到洞察", + "将关键词分析迁移到实验室" + ] + }, + { + "type": "fix", + "items": [ + "修复存量类型警告", + "Pull 增量同步增加 60 秒重叠窗口,避免消息丢失", + "Pull 拉取时固定 limit=1000,避免远端数据源导出过多数据导致卡顿", + "修复 Windows 更新时 NSIS 弹窗导致静默安装中断的问题" + ] + } + ] + }, + { + "version": "0.17.5", + "date": "2026-04-21", + "summary": "修复了大量BUG。", + "changes": [ + { + "type": "feat", + "items": [ + "优化关系卡片样式", + "以原生机器标识逻辑替换 node-machine-id 依赖,提升 Linux 下 API 密钥修改稳定性", + "合并聊天记录时支持可选保留原记录", + "预设服务与第三方服务 API 地址旁新增验证按钮" + ] + }, + { + "type": "fix", + "items": [ + "优化 dataSource 迁移策略,提升迁移安全性", + "修复话题在消息过少时空状态显示异常", + "修复本地模型验证失效问题", + "修复切换对话后所选 Tab 被重置的问题", + "修复旧版 dataSources 升级后自动化页面白屏问题" + ] + } + ] + }, + { + "version": "0.17.4", + "date": "2026-04-19", + "summary": "实现 Import API v1 完整协议并新增层级数据源管理,现已支持聊天记录自动同步。", + "changes": [ + { + "type": "feat", + "items": ["实现 Import API v1 完整协议与层级数据源管理"] + } + ] + }, + { + "version": "0.17.3", + "date": "2026-04-17", + "summary": "私聊新增语言偏好Tab,侧边栏对话列表支持排序筛选,完善 AI 服务商与模型配置能力,并修复时间筛选重置问题。", + "changes": [ + { + "type": "feat", + "items": [ + "对话列表新增排序与筛选能力", + "新增语言偏好页签,支持偏好查看", + "优化界面样式细节与视觉一致性", + "AI 服务商新增 Anthropic 支持", + "模型第三方服务支持自选接口类型", + "AI 模型配置支持自定义名称" + ] + }, + { + "type": "fix", + "items": ["修复从设置或 AI 对话页返回后时间筛选被重置为“全部”的问题"] + }, + { + "type": "refactor", + "items": ["抽离语言偏好为共享类型,减少重复定义"] + } + ] + }, + { + "version": "0.17.2", + "date": "2026-04-15", + "summary": "新增跨平台数据合并与成员消息合并,强化词库与更新安全校验,优化暗色体验与日志能力,以及修复若干BUG。", + "changes": [ + { + "type": "feat", + "items": [ + "成员管理支持成员消息合并", + "支持跨平台聊天数据合并", + "数据管理部分表头支持排序", + "话题分析入口迁移至洞察模块", + "新增 AI 日志文件原始路径记录", + "优化暗色模式配色,提升视觉体验" + ] + }, + { + "type": "fix", + "items": [ + "修复词库刷新与合并 ID 碰撞问题", + "为 OpenAI 兼容请求补充运行时 User-Agent 请求头", + "修复暗色截图导出背景透明异常", + "词库下载新增 SHA256 完整性校验", + "收紧远程配置拉取策略并强化更新安装确认" + ] + }, + { + "type": "chore", + "items": ["新增 ARM Linux 的 deb 安装包构建支持", "优化日志同步流程"] + }, + { + "type": "docs", + "items": ["新增繁体中文文档"] + } + ] + }, + { + "version": "0.17.1", + "date": "2026-04-13", + "summary": "重构话题模块并新增话题卡片,优化词云关键词过滤与查询缓存逻辑,支持远程下载分词词库及繁体中文词库,完善 WhatsApp 检测逻辑。", + "changes": [ + { + "type": "feat", + "items": [ + "重构话题模块,新增话题卡片展示", + "词云支持关键词过滤", + "支持远程下载分词词库,新增繁体中文词库支持", + "查询缓存逻辑优化", + "部分 Loading 交互统一", + "完善 WhatsApp 检测逻辑" + ] + }, + { + "type": "ci", + "items": ["新增官网文档站并支持自动化同步与构建"] + } + ] + }, + { + "version": "0.17.0", + "date": "2026-04-12", + "summary": "重构并优化总览、视图等卡片的样式,这个版本的审美大幅度提升!并完善 WhatsApp 导入解析逻辑,同时在导入失败后支持指定格式导入,完善了截图与调试能力。", + "changes": [ + { + "type": "feat", + "items": [ + "新增 WhatsApp V2 时间戳弹性解析,自动适配不同地区导出格式", + "完善 WhatsApp 聊天记录检测机制", + "新增指定格式导入能力", + "消息页新增分享卡片", + "DEBUG 模式新增快速调试工具", + "优化总览身份卡并统一时间范围查询逻辑", + "重构总览模块卡片并抽离主题色卡片,预留配色模式", + "统一卡片最大宽度与首页工具布局,支持全局工具侧边栏", + "主题卡片支持截屏,并默认关闭截图移动端适配", + "移除诊断建议并新增提示" + ] + }, + { + "type": "fix", + "items": [ + "修复 WhatsApp 时间解析正则与行匹配正则宽严不一致问题", + "修复 WhatsApp 12 小时制时间与 NNBSP 字符解析兼容性问题" + ] + }, + { + "type": "chore", + "items": ["缓存 electron 与 electron-builder 二进制文件以加速 CI 打包"] + } + ] + }, + { + "version": "0.16.0", + "date": "2026-04-10", + "summary": "新增私聊主动性分析视图,并修复模型编辑弹窗自定义模型丢失问题。", + "changes": [ + { + "type": "feat", + "items": ["私聊场景新增主动性分析视图", "优化页脚区域展示与交互", "优化语录模块下半部分逻辑"] + }, + { + "type": "fix", + "items": ["修复第三方/本地服务编辑弹窗丢失多个自定义模型的问题"] + } + ] + }, + { + "version": "0.15.0", + "date": "2026-04-08", + "summary": "大幅度优化搜索与查询性能,搜索工具支持自动携带上下文消息,优化AI模型配置并新增部分服务商,并支持 Linux 平台。", + "changes": [ + { + "type": "feat", + "items": [ + "增加查询缓存以加速访问", + "搜索工具支持自动携带上下文消息", + "重构模型配置逻辑", + "新用户首次启动时优先弹出语言选择弹窗", + "实验室新增基础调试工具", + "移除旧版提示词" + ] + }, + { + "type": "fix", + "items": [ + "修复 Windows 浅色模式下标题栏按钮区域背景色与应用不一致问题", + "修复 CI 打包工作流中 Node 24 与 pnpm 对齐相关问题", + "补全工具调用显示名称的 i18n 翻译" + ] + }, + { + "type": "refactor", + "items": ["优化 AI 配置弹窗代码组织"] + }, + { + "type": "chore", + "items": ["升级到 Node 24", "支持 Linux 打包"] + }, + { + "type": "docs", + "items": ["更新文档"] + } + ] + }, + { + "version": "0.14.2", + "date": "2026-04-07", + "summary": "本次更新聚焦 AI 对话体验升级,新增AI对话复制、优化 UI,并支持了 FTS5 全文搜索工具,精简部分工具搜索参数,并补强错误提示与测试能力。", + "changes": [ + { + "type": "feat", + "items": [ + "新增 7 天内记住助手选择功能", + "AI 对话支持一键复制消息内容", + "优化 AI 对话样式与整体交互体验", + "支持 FTS5 全文搜索并新增快速搜索工具", + "精简部分工具搜索参数,降低 token 开销", + "新增 Electron 应用 E2E 测试框架,支持端口管理与实例隔离" + ] + }, + { + "type": "fix", + "items": ["完善 AI 对话错误提示,提升问题定位效率"] + }, + { + "type": "refactor", + "items": ["整理 AI 对话模块代码结构", "抽取会话分析页公共逻辑并统一头部文案"] + }, + { + "type": "test", + "items": ["补充可复用的 E2E 启动器冒烟测试能力"] + }, + { + "type": "docs", + "items": ["更新项目说明中的引导图片"] + }, + { + "type": "style", + "items": ["统一部分代码格式,提升可读性"] + } + ] + }, + { + "version": "0.14.1", + "date": "2026-04-02", + "summary": "本次更新聚焦首页架构调整,优化UI,以及提升 SQL 对话体验、统计读取性能与AI工具质量。", + "changes": [ + { + "type": "feat", + "items": [ + "优化总览页样式", + "优化 SQL 对话模块交互逻辑", + "将成员管理迁移到首页并调整相关 Tab 模块布局", + "新增部分 AI 工具并补充获取聊天概览能力", + "新增对话数据缓存管理模块,提升统计数据读取性能", + "完善版本日志弹窗的类型展示" + ] + }, + { + "type": "fix", + "items": ["修复 SQL Lab 与摘要生成中 AI 错误被静默吞没的问题"] + }, + { + "type": "refactor", + "items": ["重构 AI 工具分类体系,提升工具组织的可维护性"] + }, + { + "type": "chore", + "items": ["废弃部分低价值 AI 工具,精简可用工具集"] + } + ] + }, + { + "version": "0.14.0", + "date": "2026-03-28", + "summary": "支持通过 API 导入与查询聊天消息,优化总览和设置体验,并修复消息去重、AI 会话与趋势展示等问题。", + "changes": [ + { + "type": "feat", + "items": [ + "支持 API 导入", + "支持 API 导出", + "设置页支持选择默认进入的会话标签页", + "优化总览页面样式", + "优化整体界面样式与 API 服务设置界面", + "点击预设问题后可直接发起提问", + "优化身份卡片与助手选择交互" + ] + }, + { + "type": "fix", + "items": [ + "修复消息去重误判,并统一空字符串去重语义", + "修复 AI 会话链路与前端 type-check 错误", + "增加默认 assistant 兜底逻辑,修复异常场景", + "修复每日消息趋势不展示的问题" + ] + }, + { + "type": "refactor", + "items": ["清理 parser、worker、RAG 与 merger 的历史类型问题"] + }, + { + "type": "chore", + "items": ["新增 assistant 配置生成技能"] + } + ] + }, + { + "version": "0.13.0", + "date": "2026-03-16", + "summary": "AI对话支持助手模式,对话支持使用技能,输入框支持快捷选择,完善对话与设置界面,支持繁体中文与日语,部分 UI 调整,并修复多项稳定性问题。", + "changes": [ + { + "type": "feat", + "items": [ + "完成助手模式初版并完善助手逻辑与分析工具能力", + "上线助手市场与技能市场,聊天对话支持使用技能", + "支持 @ 选择成员协作", + "支持繁体中文与日语国际化", + "设置页面重构并优化部分 UI 细节", + "优化总览模块样式与对话界面体验", + "调整导出聊天记录的展示位置", + "移除旧版提示词系统与自定义筛选 AI 功能", + "切换页面时调用模型不再中断" + ] + }, + { + "type": "fix", + "items": ["修复 Gemini API 配置问题", "修复 NLP 停用词调用顺序导致的报错"] + }, + { + "type": "refactor", + "items": ["重构 AIChat 组织结构", "重构目录位置与工程结构"] + }, + { + "type": "docs", + "items": ["更新用户协议与项目文档"] + }, + { + "type": "chore", + "items": ["优化版本日志构建流程"] + }, + { + "type": "style", + "items": ["统一代码格式与 lint 规则输出"] + } + ] + }, + { + "version": "0.12.1", + "date": "2026-02-27", + "summary": "新增聊天记录预处理与调试能力,重构 Agent/LLM 架构,并修复国际化与 Windows 主题显示问题。", + "changes": [ + { + "type": "feat", + "items": [ + "新增聊天记录预处理管道", + "新增预处理设置界面与配置管理能力", + "Agent 支持基于会话的上下文时间线与运行状态展示", + "新增 AI 调试模式并增强日志可观测性" + ] + }, + { + "type": "fix", + "items": ["修复英文设置下部分界面未国际化的问题", "修复 Windows 下动态更新 overlay 颜色时主题不一致的问题"] + }, + { + "type": "refactor", + "items": [ + "拆分 Agent 单体实现为模块化架构", + "工具系统重构为 AgentTool + TypeBox 结构并补齐 i18n", + "统一 LLM 访问层,收敛为 pi-ai 方案", + "重构数据流方向与 IPC 协议并完成前端适配", + "引入共享类型并优化 ChatStatusBar 国际化", + "将部分图表重构为插件化结构" + ] + }, + { + "type": "chore", + "items": [ + "移除过度设计的 sessionLog 模块", + "移除 @ai-sdk 相关依赖与旧版 LLM 服务实现", + "临时隐藏向量模型配置入口", + "更新项目描述文案" + ] + }, + { + "type": "style", + "items": ["执行 ESLint 自动格式化,统一代码风格"] + } + ] + }, + { + "version": "0.11.2", + "date": "2026-02-15", + "summary": "优化聊天记录导入机制,优化管理页面,增强多平台聊天记录兼容性。", + "changes": [ + { + "type": "feat", + "items": [ + "增强 LINE 与 WhatsApp 解析器兼容格式", + "优化聊天记录嗅探层,支持轮询检测与回退机制", + "管理页面支持 Shift 多选", + "管理页面新增展示聊天摘要数量与 AI 对话数量", + "优化主页面布局,提升可用空间", + "优化 Windows 下右上角控制栏样式" + ] + }, + { + "type": "docs", + "items": ["更新项目文档"] + } + ] + }, + { + "version": "0.11.0", + "date": "2026-02-13", + "summary": "支持 Telegram 导入,优化增量导入体验,完善国际化配置,修复索引失效与页面闪烁等问题。", + "changes": [ + { + "type": "feat", + "items": [ + "完善 AI 调用、日志与主进程配置的国际化支持", + "支持 Telegram 聊天记录导入", + "优化增量导入交互与相关文案", + "优化打开协议的交互体验" + ] + }, + { + "type": "fix", + "items": [ + "修复增量导入后索引失效的问题(resolve #81)", + "修复 WhatsApp 使用 iPhone 导出后无法识别的问题(resolve #82)", + "修复切换对话页面出现双闪的问题" + ] + }, + { + "type": "chore", + "items": ["优化 TypeScript 配置", "调整 i18n 构建配置", "优化技能相关工程配置"] + } + ] + }, + { + "version": "0.10.0", + "date": "2026-02-11", + "summary": "新增互动频率分析能力,优化会话查询链路,并修复增量索引与数据库扫描相关问题。", + "changes": [ + { + "type": "feat", + "items": ["新增互动频率分析视图,支持更直观地观察成员互动趋势", "优化会话查询相关逻辑与处理链路"] + }, + { + "type": "fix", + "items": [ + "修复增量更新后会话索引生成范围不准确的问题(fix #79)", + "修复迁移与会话扫描时误处理非聊天 SQLite 文件的问题" + ] + }, + { + "type": "refactor", + "items": ["重构会话查询模块,提升查询结构可维护性"] + }, + { + "type": "chore", + "items": ["移除 transformers 相关依赖并更新工程配置"] + } + ] + }, + { + "version": "0.9.4", + "date": "2026-02-08", + "summary": "优化时间筛选与 AI 配置体验,对 API Key 进行本地加密,并修复 LINE 聊天记录解析问题。", + "changes": [ + { + "type": "feat", + "items": [ + "时间筛选支持更多灵活选择", + "API Key 支持本地加密存储", + "新用户首次进入不再显示版本日志", + "优化 AI 对话底部配置状态展示", + "数据目录迁移后支持立即重启软件" + ] + }, + { + "type": "fix", + "items": ["修复 LINE 聊天记录解析问题"] + }, + { + "type": "docs", + "items": ["更新项目文档"] + } + ] + }, + { + "version": "0.9.3", + "date": "2026-02-03", + "summary": "支持自定义数据目录,修复大量已知问题。", + "changes": [ + { + "type": "feat", + "items": [ + "设置内新增数据目录位置配置", + "数据存储目录迁移逻辑优化", + "目录切换新增确认弹窗", + "解析逻辑优化(WeFlow / Echotrace)" + ] + }, + { + "type": "fix", + "items": [ + "修复 Windows 自定义筛选时消息量过大导致崩溃的问题", + "修复第三方中转 API 调用 tool_call 导致对话异常结束的问题", + "修复部分 WhatsApp 聊天记录无法正确识别的问题", + "修复管理页面表头层级显示问题" + ] + }, + { + "type": "refactor", + "items": ["重构 session 查询模块", "完善迁移日志输出"] + } + ] + }, + { + "version": "0.9.2", + "date": "2026-02-02", + "summary": "榜单改为图表展示,优化词云生成逻辑,优化本地 AI 推理模型,改进聊天记录筛选与日期选择器,并在启动后预加载关键路由提升体验。", + "changes": [ + { + "type": "feat", + "items": [ + "榜单重构为图表展示", + "词云效果优化", + "推理模型优化", + "消息会话搜索与筛选联动优化", + "日期选择器交互优化", + "启动后预加载关键路由" + ] + }, + { + "type": "chore", + "items": ["preload 模块化拆分", "优化 analytics 逻辑", "升级 ESLint 并进行代码格式化"] + } + ] + }, + { + "version": "0.9.1", + "date": "2026-01-30", + "summary": "支持了 LINE 聊天记录的导入,新增批量管理,聊天对话支持搜索,修复了一些已知问题。", + "changes": [ + { + "type": "feat", + "items": [ + "新增批量管理,支持批量删除和合并", + "支持聊天对话搜索", + "支持 LINE 聊天记录导入", + "兼容 WeFlow 导出的 JSON 格式", + "成员列表改为后端分页加载", + "优化部分文案" + ] + }, + { + "type": "fix", + "items": ["修复 Windows 在更新时,Worker 占用导致软件无法关闭的问题"] + } + ] + }, + { + "version": "0.9.0", + "date": "2026-01-28", + "summary": "支持了 NLP 分词能力,语录 Tab 下新增词云;新增视图功能,支持展示更多图表;支持了系统代理跟随;优化了部分页面和样式。", + "changes": [ + { + "type": "feat", + "items": [ + "用户选择器性能优化,支持虚拟加载", + "迁移榜单到视图Tab", + "引入分词能力,并新增词云子Tab", + "优化群聊页Tab文案", + "网络代理支持跟随系统代理", + "版本日志显示判断逻辑优化" + ] + }, + { + "type": "style", + "items": ["markdown渲染样式优化"] + } + ] + }, + { + "version": "0.8.0", + "date": "2026-01-26", + "summary": "新增了会话摘要与向量检索能力,每次版本更新后会弹窗展示更新内容,优化了部分界面交互,同时修复了一些已知问题。", + "changes": [ + { + "type": "feat", + "items": [ + "聊天会话支持摘要功能", + "新增批量生成会话摘要逻辑", + "支持向量模型配置和相关检索", + "导入聊天记录报错时记录更详细的日志", + "每次更新新版本后,自动打开版本日志供用户查看", + "首页新增Footer,展示常用链接", + "侧边栏移除帮助与反馈" + ] + }, + { + "type": "fix", + "items": ["修复 shuakami-jsonl 解析错误(fix #50)"] + } + ] + }, + { + "version": "0.7.0", + "date": "2026-01-23", + "summary": "优化了 AI 对话体验,改进了更新逻辑,图表方案使用 Echarts 替代 chart.js。", + "changes": [ + { + "type": "feat", + "items": [ + "优化更新逻辑", + "完善AI对话错误日志", + "聊天对话底部支持快速选择对话模型", + "优化默认提示词,带点幽默", + "Echarts 替换 chart.js", + "取消注册协议逻辑" + ] + } + ] + }, + { + "version": "0.6.0", + "date": "2026-01-21", + "summary": "接入了 AI sdk,提高了 AI 对话 的稳定性;AI对话新增展示思考内容块;对部分样式进行了优化。", + "changes": [ + { + "type": "feat", + "items": [ + "新增定位日志功能", + "接入AI sdk", + "追加思考内容块", + "解决全局弹窗会被首页顶部拖拽区域遮挡的问题", + "优化windows下右上角关闭样式" + ] + } + ] + }, + { + "version": "0.5.2", + "date": "2026-01-20", + "summary": "支持了合并导入,同时修复了一些问题。", + "changes": [ + { + "type": "feat", + "items": ["支持合并导入", "主面板显示聊天记录起止时间", "拖拽区域优化"] + }, + { + "type": "fix", + "items": [ + "优化构建配置以解决macOS x64编译问题", + "消息记录查看器在windows下关闭按钮样式问题", + "macOS 打包时需在对应架构上编译(fixes #36)" + ] + } + ] + }, + { + "version": "0.5.1", + "date": "2026-01-16", + "summary": "修复了一些问题。", + "changes": [ + { + "type": "feat", + "items": ["文案优化"] + }, + { + "type": "fix", + "items": ["修复windows下关闭软件进程不退出的问题(#33)", "修复数字输入框BUG (resolve #34)"] + } + ] + }, + { + "version": "0.5.0", + "date": "2026-01-14", + "summary": "支持了 instagram 聊天记录导入;首页支持了批量导入;聊天页面支持了增量导入。", + "changes": [ + { + "type": "feat", + "items": [ + "支持 instagram 聊天记录导入", + "逻辑优化", + "系统提示词预设功能优化", + "支持增量导入", + "支持批量导入", + "样式优化", + "Windows 端支持原生窗口控制并实现主题同步 (#31)" + ] + }, + { + "type": "chore", + "items": ["移除componenst.d.ts"] + } + ] + }, + { + "version": "0.4.1", + "date": "2026-01-13", + "summary": "进行了一些样式和交互上的优化。", + "changes": [ + { + "type": "feat", + "items": [ + "提示词支持预览", + "优化AI对话状态栏", + "优化迁移表逻辑", + "侧边栏支持显示头像", + "样式优化", + "替换原生窗口控制栏", + "优化全局背景色", + "关闭软件时清理 Worker" + ] + }, + { + "type": "fix", + "items": ["修复主题模式设置跟随系统不生效的问题", "修复更新弹窗提示内容排版问题"] + } + ] + }, + { + "version": "0.4.0", + "date": "2026-01-12", + "summary": "导入支持 shuakami-jsonl 格式;优化了 AI 对话,现在更加节省token了;导入聊天记录时支持生成会话索引,同时消息查看器也支持按索引查看;软件更新支持了加速镜像。", + "changes": [ + { + "type": "feat", + "items": [ + "兼容shuakami-jsonl", + "优化Loading", + "新增自定义筛选", + "重构预设词系统,支持通用预设词", + "精简系统提示词以节省token", + "新增会话相关function calling调用", + "处理消息跳转到上下文逻辑", + "聊天记录查看器支持查看会话索引和快速跳转", + "重构设置弹窗,新增会话索引设置", + "导入聊天记录支持生成会话索引", + "重构设置弹窗", + "优化基础组件交互样式", + "优化首页样式", + "优化更新加速逻辑", + "添加加速镜像" + ] + } + ] + }, + { + "version": "0.3.1", + "date": "2026-01-09", + "summary": "已适配 Discord 导入;各个解析器支持了回复类型的导入;软件存储目录迁移至更规范的目录;导入时支持角色导入;对导入报错支持了更详细的诊断和提示;部分细节优化。", + "changes": [ + { + "type": "feat", + "items": [ + "数据表升级改为在主进程升级", + "自动检查更新时忽略beta版本", + "将数据存储目录迁移到userData下", + "各个解析器重新支持回复消息导入", + "支持平台消息id和回复id,同时进行表迁移", + "支持Tyrrrz/DiscordChatExporter消息格式导入", + "member表支持角色", + "增强chatlab格式检测行为", + "确保点击导入和拖入导入的逻辑一致", + "支持更详细的格式诊断" + ] + }, + { + "type": "fix", + "items": ["修复部分用户platformId 为空的情况"] + } + ] + }, + { + "version": "0.3.0", + "date": "2026-01-08", + "summary": "进行了全量的国际化支持,支持中英文切换;一些功能优化。", + "changes": [ + { + "type": "feat", + "items": [ + "SQL实验室支持导出", + "AI对话支持导出", + "完成最终国际化", + "AI模型错误时显式报错", + "SQL结果支持跳转消息查看器", + "优化系统prompt,支持prompt市场" + ] + } + ] + }, + { + "version": "0.2.0", + "date": "2025-12-29", + "summary": "支持配置代理;导入时支持显示错误日志;优化部分界面交互,以及部分功能更新。", + "changes": [ + { + "type": "feat", + "items": [ + "消息管理器支持显示系统消息", + "优化导入逻辑,错误时会显示导入日志", + "WhatsApp支持英文格式消息导入", + "支持配置代理(resolve #7)", + "优化AI模型界面交互", + "添加用户配置API教程", + "1、新增GLM两个免费调用模型 2、新增豆包服务商和对应的最新模型", + "AI回复不输出think内容" + ] + } + ] + }, + { + "version": "0.1.3", + "date": "2025-12-25", + "summary": "修复了一些问题。", + "changes": [ + { + "type": "fix", + "items": ["修复 Echotrace 解析器错误"] + } + ] + }, + { + "version": "0.1.2", + "date": "2025-12-25", + "summary": "支持了深色模式;AI对话中,系统提示词中支持传递给用户身份。", + "changes": [ + { + "type": "feat", + "items": [ + "AI对话中,系统提示词中支持传递给用户身份", + "聊天记录查看器中,Owner显示在右侧", + "支持数据库升级", + "成员Tab中支持设置Owner视角", + "支持深色模式" + ] + }, + { + "type": "fix", + "items": ["修复私聊误判为群聊的问题"] + } + ] + }, + { + "version": "0.1.1", + "date": "2025-12-24", + "summary": "已适配 WhatsApp 聊天记录的导入;支持旧版 QQ 讨论组格式的分析。", + "changes": [ + { + "type": "feat", + "items": ["聊天会话底部显示token消耗", "支持WhatsApp原生格式消息", "支持旧版QQ txt版本的讨论组格式"] + }, + { + "type": "fix", + "items": ["修复消息管理器层级过低的问题"] + } + ] + }, + { + "version": "0.1.0", + "date": "2025-12-23", + "summary": "项目开源并发布。", + "changes": [ + { + "type": "feat", + "items": ["init"] + } + ] + } +] diff --git a/changelogs/cn.md b/changelogs/cn.md new file mode 100644 index 000000000..631130d80 --- /dev/null +++ b/changelogs/cn.md @@ -0,0 +1,1427 @@ +# 更新日志 + +## v0.29.0 (2026-07-01) + +> 新增联系人功能,支持关系星图,优化联系人计算、头像加载和侧边栏性能。 + +### ✨ 新功能 + +- 新增人际关系模块,将联系人作为子页面并支持跨会话联系人聚合、时间范围筛选、分页与虚拟滚动 +- 联系人页支持手动将群友标记为好友,并提供来源对话跳转与聊天记录查看入口 +- 新增互动关系星图,支持 3D 全景、节点筛选、搜索、详情面板和相关联系人/群组探索 +- 关系星图支持高关联、仅好友等视图筛选,并在隐私模式下对姓名进行脱敏 +- 【桌面端】支持后台静默下载应用更新 + +### ⚡ 性能 + +- 联系人列表改为分页与虚拟滚动,减少大量好友和群友带来的渲染压力 +- 头像改为懒加载,并虚拟化侧边栏会话列表,降低启动和页面切换卡顿 + +### 💄 样式 + +- 统一暗色模式主背景、关系页头部和侧边栏选中态 + +## v0.28.1 (2026-06-26) + +> 聊天记录查看优化,新增导入 API,修复导入、同步和本地模型代理下载问题。 + +### ✨ 新功能 + +- 新增 Push 导入 API,支持按会话追加写入、去重和增量索引生成 +- 导入完成后自动生成会话索引,覆盖 CLI、桌面端和 pull sync 导入路径 +- 优化聊天记录查看器控件与高亮显示体验 +- 侧边栏默认隐藏滚动条,悬停时显示 + +### 🐛 修复 + +- 强化 Push/Pull 导入校验、去重和并发锁,避免异常输入导致误写或重复导入 +- GET /api/v1/sessions/:id 补齐 lastPlatformMessageId 和 importedAt 字段,支持增量导入边界判断 +- 后台 pull sync 完成后自动刷新会话列表 +- 修复本地语义索引模型下载未正确使用代理的问题,并补齐 Worker 日志初始化 +- 【桌面端】API 默认端口与 CLI 对齐为 3110,并在端口占用时自动尝试后续端口 +- 【桌面端】系统代理解析支持 HTTP、HTTPS 与 SOCKS 结果,避免本地模型下载静默绕过代理 + +### ♻️ 重构 + +- 【CLI】pull/sync 导入路径改用共享 streaming importer,移除旧解析与写库实现 +- 【桌面端】移除增量导入后重复生成会话索引的冗余调用 + +### 📝 文档 + +- 修正文档中的 API 端口、示例与未实现能力说明 + +## v0.28.0 (2026-06-25) + +> 新增 Google Chat 导入支持,引入统一应用日志模块,优化语义索引使用体验。 + +### ✨ 新功能 + +- 新增 Google Chat 导入支持,覆盖 CLI、桌面端全链路 +- 新增统一应用日志模块,支持按文件大小自动轮转与全局未捕获错误记录 +- 语义索引管理改为「移除」操作替代禁用,修复 legacy hash,限制列表高度 +- 语义索引模型配置保存时自动预热,并迁移至 AI 目录统一管理 +- 简化语义索引设置,移除搜索结果数量配置项,改为内置默认值 + +### 🐛 修复 + +- 补齐 stream-json/stream-chain 依赖声明,修复多聊天扫描穿透问题 + +### 📝 文档 + +- 文档新增 Google Chat 支持平台说明 + +## v0.27.2 (2026-06-24) + +> 修复 AI auth profile 生命周期管理多处问题,完善分析服务稳定性,补齐本地嵌入运行时依赖。 + +### ✨ 新功能 + +- 【CLI Web】通过共享 AnalyticsService 统一上报日活用户数据 + +### 🐛 修复 + +- 删除 AI 服务配置时同步清理对应 auth profile + +### ♻️ 重构 + +- 移除已过期的分析设置迁移逻辑 + +## v0.27.1 (2026-06-23) + +> 引入 API 批量嵌入提速与本地模型惰性加载,修复语义索引断点续跑、同步游标回退与对话导出等多项问题。 + +### ✨ 新功能 + +- 语义索引支持 API 嵌入模型批量向量化,大幅提升索引构建速度 +- 本地嵌入模型改为惰性 Worker 加载,减少启动资源占用 + +### 🐛 修复 + +- 修复 AI 对话导出:支持导出当前可见的 AI 对话内容 +- 修复语义索引批量写入中途崩溃后续跑触发唯一约束错误、会话状态卡死的问题 +- 修复语义 Worker 多项问题:配置变更未同步到运行中 Worker、可用性探针异常影响普通 AI 对话、活跃构建状态追踪不准确导致 Worker 提前关闭 +- 修复同步拉取游标:空响应重试前未保存初始页服务端水位,导致重复拉取尾部窗口 +- 修复同步拉取分页游标错误回退,避免重复导入 + +### ♻️ 重构 + +- 清理多处死代码与过度设计的抽象,简化工具目录结构与深合并逻辑 + +## v0.27.0 (2026-06-22) + +> 引入向量索引与证据检索功能,支持基于向量搜索定位并呈现聊天证据,新增索引管理界面。 + +### ✨ 新功能 + +- 新增语义索引管理界面,支持选择指定会话构建向量索引,提供多语言支持 +- 新增 retrieve_chat_evidence 工具:AI 可通过语义搜索检索带时间定位的聊天证据 +- AI 规划器自动识别证据类问题,路由到语义检索工具进行处理 +- 证据内容块支持展开查看、折叠收起与点击跳转原始消息 +- 语义检索支持时间范围过滤 +- 重新设计语义索引模型选择界面 +- AI 处理过程片段支持折叠收起 +- 精简证据来源行与 Agent 工具调用结果展示 + +### 🐛 修复 + +- 修复多关键词消息搜索功能 +- 修复词云词典加载 UX 回归问题 +- 修复排行榜标签与 emoji 清洗 +- 修复 v8 数据库迁移缺少国际化文本 +- 【CLI】修复工具适配器中会话上下文传递与预处理缺失 + +## v0.26.3 (2026-06-15) + +> 优化分析模块性能:新增磁盘缓存与过期请求取消,切换会话更流畅;修复词云、高频词统计及时间轴等多处问题。 + +### ✨ 新功能 + +- 【CLI Web】新增网站图标(favicon) + +### ⚡ 性能 + +- 分析结果按数据库文件版本落盘缓存,大幅提升二次加载速度 +- 切换会话或过滤条件时自动取消过期分析请求 +- 切换词云词数时跳过重新分词,减少不必要计算 + +### 🐛 修复 + +- 修复高频词统计中含有平台回复消息占位符的问题 +- 修复词云文本中包含媒体占位符的问题 +- 修复 Jieba 自定义词典变更后语言偏好缓存与词频缓存未失效的问题 +- 修复时效性分析缓存未按日失效的问题 +- 修复时间轴面板默认滚动位置及会话跳转功能失效的问题 +- 修复片段消息上下文缺失的问题 +- 修复 AI 对话数量始终返回 0 的问题 +- 修复首页教程链接未使用本地化路径的问题 +- 移除 AI 聊天顶部截图按钮 +- 【桌面端】预打包懒路由依赖,避免开发模式 504 错误 +- 【CLI】修复版本号不同步导致的误报更新提示 +- 【CLI】更新提示支持方向键选择 + +### ♻️ 重构 + +- 将时间轴面板标签重命名为「摘要」 + +## v0.26.2 (2026-06-13) + +> 优化会话「我是谁」身份识别功能,支持手动选择 owner profile 并自动批量填充同平台会话;修复多处图表渲染与 AI 缓存问题。 + +### ✨ 新功能 + +- 新增会话「我是谁」身份识别功能:手动确认后保存平台 owner profile,并自动批量匹配填充同平台其他未设置所有者的会话 +- 导入和 pull sync 完成后自动尝试应用已保存的 owner profile,减少手动配置次数 + +### 🐛 修复 + +- 修复 name-match 平台(WhatsApp / Line / Instagram)批量填充时各会话 ownerId 缓存与数据库写入不一致的问题 +- 修复多路由共用独立 PreferencesManager 实例导致 owner profile 被覆写为旧值的问题 +- 【CLI】修复长期运行时 sync 模块 preferences 缓存过期,pull 导入新会话后 owner profile 未自动生效 +- 修复导入后临时文件未完全删除的问题 +- 修复 AI 图表在流式生成期间渲染抖动的问题 +- 修复 AI 图表渲染顺序不稳定的问题 +- 修复 AI 图表存在仅占位空行的问题 +- 新增 AI 图表生成进度提示 +- 修复截图前未调整图表尺寸导致图表显示不完整的问题 +- 修正 last_message_time 语义:表示数据覆盖截止时间,而非群组最后活跃时间 +- 修复增量导入后概览与分析缓存未失效导致显示旧数据的问题 + +## v0.26.1 (2026-06-10) + +> 修复工具调用历史重放多处问题,补齐工具调用持久化与重放能力;新增状态栏 Token 缓存统计,消息导出支持多种格式。 + +### ✨ 新功能 + +- 聊天状态栏新增 Token 缓存命中/未命中用量展示 +- 消息导出支持 TXT、JSON、Markdown 多种格式 + +### 🐛 修复 + +- 支持持久化并重放对话中的工具调用记录,保持多轮 AI 上下文完整性 +- 修复工具调用无文字输出的 assistant 轮次被历史重放过滤导致工具结果丢失的问题 +- 修复连续导出同一会话时文件名冲突导致静默覆盖的问题 +- 【桌面端】修复大型会话导出因 60 秒 IPC 超时提前失败的问题 +- 修复 DeepSeek 工具调用轮次的 reasoning_content 未随历史重放 +- 修复历史压缩时重放工具结果的 token 数未被正确计入 +- 修复工具结果传给模型的文本中包含不应暴露的原始消息数据 +- 修复工具面板点击外部区域时未自动关闭 + +### ♻️ 重构 + +- 提取会话页面顶部栏为共享组件 + +## v0.26.0 (2026-06-10) + +> 新增 AI 分析规划器,优化系统提示词,支持流式结构化计划生成与图表规划集成;优化图表模式工具可用性,修复多处 AI 与导入问题。 + +### ✨ 新功能 + +- 新增 AI 分析规划器,支持流式结构化分析计划生成与区块渲染 +- 集成图表规划支持,AI 分析计划可自动衔接图表生成 +- 规划器引入启动上下文与扩展数据快照,提升分析深度和质量 +- 图表渲染优先使用高层工具而非原始 SQL,降低权限要求 +- 优化 AI 分析与图表整体行为 +- 新增图表自动模式偏好设置 +- 新增消息 ID 复制操作 +- 新增 shadow 路由 LLM 回退机制,并记录路由决策日志 +- 【CLI】暴露完整智能体流式事件 + +### 🐛 修复 + +- 修复显式选择图表技能时 render_chart 工具丢失的问题 +- 修复显式图表模式下工具可用性缺口及错误后计划状态异常 +- 迁移 ECharts 6 containLabel 配置,隐藏多余饼图图例 +- 修复设置页导航顺序、skillSettings 键名及 SubTabs 换行问题 +- 修复 AI 助手消息未填满聊天区域宽度 +- 优化分析计划生成区块的展示样式 +- 修复思考深度选择跨重启不持久的问题 +- 修复 JSONL 时间戳规范化导致增量导入异常 +- 【桌面端】修复 execute_sql 工具未注册到桌面端工具注册表 + +### test + +- 新增智能体路由决策评估测试集 + +## v0.25.1 (2026-06-08) + +> 新增数据目录兼容门禁,防止旧版运行时误写已升级的数据;并新增 CLI ai chat 命令。 + +### ✨ 新功能 + +- 新增数据目录兼容门禁:写入 v6 schema 数据后自动记录最低可读写运行时版本,防止旧版本误写已升级的数据目录 +- 数据库访问前校验数据目录兼容版本,版本不满足时拒绝操作 +- 【桌面端】启动时校验数据目录兼容版本,不满足时弹窗提示并退出 +- 【CLI】启动时校验数据目录兼容版本,不满足时立即退出 +- 【CLI】新增 ai chat 命令,支持在终端与 AI 进行多轮交互对话 + +### 🐛 修复 + +- 使用打包版本号(而非 Electron 开发模式报告的 0.0.0)写入兼容门禁,并强制要求运行时身份标识 +- 数据库迁移和导入完成后正确写入兼容门禁;启动迁移失败时立即报错,不再静默忽略 +- 【MCP】启动时主动校验数据目录兼容性,版本不满足时提前退出,避免写入不兼容数据 +- 【桌面端】HTTP API 将兼容门禁错误映射为 409 响应,方便前端展示升级提示 +- 【CLI】规范化 AI 工具名称,修复 ai chat 命令的边界处理问题 + +### ♻️ 重构 + +- 对齐 AI 对话与 segment 标识符命名规范 + +### test + +- 补充解析器、配置迁移、数据库迁移、HTTP 路由和 AI 工具的测试覆盖 + +### 📝 文档 + +- 新增数据目录兼容门禁公开文档,说明多版本共享数据目录的限制与升级规则 + +## v0.25.0 (2026-06-07) + +> AI 对话现在支持通过技能生成图表,修复工具轮次、桌面标题栏和开发服务生命周期问题。 + +### ✨ 新功能 + +- 新增斜杠触发的 AI 图表运行时,支持在对话中生成并渲染 ECharts 图表 +- 优化 AI 图表结果展示体验,补充图表渲染状态和多类型图表适配 + +### 🐛 修复 + +- 提升 AI Agent 默认工具调用轮次,减少复杂任务中过早停止的问题 +- 修复预设问题标签和侧边栏元素层叠顺序异常 +- 【CLI Web】修复开发后端生命周期清理不完整的问题 +- 【桌面端】修复自动技能未注册图表工具,导致图表技能无法完整执行的问题 +- 【桌面端】修复 Windows 标题栏覆盖层切换主题后缓存未刷新和显示不平滑的问题 + +### ♻️ 重构 + +- 集中管理 AI 图表运行时策略,统一 CLI 与桌面端的图表工具启用规则 +- 精简技能菜单与 Skill Manager 的重复逻辑 + +### 📝 文档 + +- 更新 iMessage 聊天记录导出指南 + +### 🔧 杂项 + +- 移除公开仓库中的维护者专用技能目录,转为私有维护上下文管理 + +## v0.24.1 (2026-06-04) + +> 新增应用内更新提醒与 AI 预处理默认规则,修复更新提示和脱敏配置保存问题。 + +### ✨ 新功能 + +- 新增应用内更新提醒入口,侧边栏可提示并打开最新版本说明 +- 新增 AI 预处理默认配置,自动启用数据清洗、去噪和脱敏等基础规则 +- 支持按分组管理 AI 脱敏规则,便于维护内置与自定义规则 + +### 🐛 修复 + +- 修复更新提醒缓存失败结果、旧版本缓存和 CLI Web 开发占位版本导致的错误 New 标记 +- 修复 AI 预处理执行前未正确应用内置脱敏规则的问题 +- 修复脱敏规则偏好保存时空覆盖无法清除旧内置规则开关的问题 +- 修复旧版内置脱敏规则迁移后覆盖设置丢失的问题 + +### 📝 文档 + +- 新增公开开发指南,补充本地开发、目录职责和协作规范 + +### 👷 CI + +- 发布流程新增 Markdown 版本日志链接 + +## v0.24.0 (2026-06-03) + +> 新增 CLI Web 认证与数据目录迁移能力,统一多端 HTTP 路由,并修复数据迁移、AI 配置和导入刷新问题。 + +### ✨ 新功能 + +- 新增多语言版本日志 Markdown 生成能力 +- 支持在存储管理中忽略旧数据迁移提示 +- 【CLI Web】新增登录页、Token 认证和记住登录状态 +- 【CLI Web】支持数据目录迁移流程,可在 Web 设置中切换并迁移数据目录 +- 【CLI】新增 --require-auth 参数,用于保护 /\_web/\* API 访问 +- 【桌面端】新增内部 HTTP 服务,支持前端统一通过服务适配层访问共享接口 + +### 🐛 修复 + +- 修复数据目录迁移与数据库迁移流程中的多处边界问题,避免旧库字段缺失或路径切换导致读取失败 +- 修复数据目录迁移提示在旧数据不存在或目录变更后仍错误显示的问题 +- 修复增量导入后侧边栏消息数量未刷新的问题 +- 修复侧边栏折叠状态仅存于 sessionStorage 导致刷新后丢失的问题 +- 修复 AI 配置编辑时已保存密钥未正确启用拉取模型和验证按钮的问题 +- 修复 AI 共享 SSE 流式响应的稳定性问题 +- 修复自定义数据源新增弹窗未强制填写 Token 的问题 +- 【桌面端】将图表插件计算移入 Worker,避免主线程计算阻塞 + +### ♻️ 重构 + +- 抽取 @openchatlab/http-routes 共享包,统一 CLI Web 与桌面端的 HTTP 路由实现 +- 将 AI 配置、助手、技能、会话、流式响应、缓存和合并相关接口迁移到共享 HTTP 路由 +- 精简桌面端 IPC 桥接层,移除遗留的 AI、会话索引、LLM、Assistant、Skill、NLP 等 IPC 兼容代码 +- 统一前端服务层,减少 Electron 与 Web 模式的分支实现 + +### ⚡ 性能 + +- 压缩主进程构建产物,并延迟加载 tiktoken rank table,降低启动和打包体积压力 + +### 👷 CI + +- 修复 Windows 发布流程中的 zstd 缓存问题,并补充 CLI 更新发布说明 + +### 🔧 杂项 + +- 调整 Node 类型检查项目配置,覆盖桌面端与共享 Node 代码 + +## v0.23.1 (2026-06-01) + +> 新增 clb 短别名与端口预检引导,修复守护进程静默退出及暗色模式标题栏等多处问题。 + +### ✨ 新功能 + +- 优化页面顶部标题栏与工具栏布局 +- 【CLI】启动前主动检测端口占用,被占用时给出换端口或 lsof 引导,避免延迟报错 +- 【CLI】新增 clb 作为 chatlab 命令的短别名 + +### 🐛 修复 + +- 修复侧边栏 Tooltip 位置异常及 Nuxt UI v4 API 兼容问题 +- 修复标题栏暗色模式出现红色背景与层叠顺序错误 +- 规范 AI 消息角色参数类型,加强对话测试断言 +- 【CLI】修复守护进程启动入口错误导致服务启动后静默退出的问题 +- 【CLI】改善端口检测的错误处理与提示信息 +- 【CLI】修复 Web 模式下缺少 chatlab.fun 反向代理路由的问题 + +## v0.23.0 (2026-05-31) + +> 重构消息编辑交互,新增推理等级按模型独立配置,统一 CLI 启动入口,修复多处推理识别与计算问题。 + +### ✨ 新功能 + +- 新增消息派生(Fork)功能,可从任意 AI 回复处创建独立分支会话 +- 状态栏新增推理强度选择器,支持为每个推理模型单独记忆并切换思维等级 +- 推理等级控制新增 default/auto 选项,扩展对 Kimi、Doubao、Gemini 等更多模型家族的覆盖 +- 消息分析视图拆分为「类型分析」和「时间分析」两个标签页,增加丰富统计洞察卡片 +- 批量重建会话索引前新增确认弹窗,防止误操作清空现有摘要 +- 演示数据扩展为 4 个文件,覆盖群组及多个私聊场景 +- 【CLI】新增统一启动命令 chatlab start,支持 --headless 和 --no-open 参数 +- 【CLI】新增后台服务模式,支持 chatlab start --daemon 安装为系统服务,以及 stop/status 命令 + +### 🐛 修复 + +- 修复消息编辑可能导致数据丢失和状态错误的多个并发安全问题 +- 修复思维等级与上下文窗口计算未使用当前激活模型 ID 的问题 +- 修复自定义模型仅含 chat 能力时推理识别失败,补全启发式回退逻辑 +- 修复 Kimi、Doubao 等模型在选择 auto 思维等级时被静默禁用的问题 +- 【CLI】修复 start 命令未启动 Web 开发后端的问题 +- 【CLI】修复 Linux 上服务路径含空格时守护进程启动失败的问题 + +### ♻️ 重构 + +- 重构消息分支系统为「编辑并重新生成」模型,支持仅更新当前轮或覆盖后续消息两种模式 +- 移除模型配置中的「推理模型」和「禁用思维模式」开关,改为按能力自动推断 +- 简化模型切换按钮 UI,优化会话索引加载性能 + +## v0.22.1 (2026-05-29) + +> 新增会话摘要详细程度配置,修复服务版批量摘要卡死、AI 配置编辑误判及多处 AI 凭据检测问题。 + +### ✨ 新功能 + +- 新增会话摘要详细程度设置,支持「简洁」与「标准」两种策略,可在 AI 设置中切换 + +### 🐛 修复 + +- 修复 OpenAI 兼容模式下修改 Base URL 后未重新验证凭据的问题 +- 修复 API Key 已设检测逻辑错误,防止旧 key 被意外复用 +- 修复批量生成摘要时点击停止需等待当前请求完成才响应的问题 +- 【CLI Web】修复批量生成摘要导致页面卡死的问题 +- 【CLI Web】修复批量生成摘要时未遵循已选会话范围的问题 +- 【CLI Web】修复编辑 AI 配置时第三方服务被误判为本地服务的问题 + +### ♻️ 重构 + +- 将会话索引国际化 key 从 storage 命名空间迁移至 ai 命名空间 + +### 💄 样式 + +- 优化聊天记录列表密度与消息气泡样式 + +### 📝 文档 + +- 重整文档站导航结构,将快速开始迁移至使用指南目录 + +## v0.22.0 (2026-05-26) + +> 本次优化了软件默认状态的样式,并新增 CLI Web 更新与存储管理能力,优化首页导入、文档站和多端稳定性。 + +### ✨ 新功能 + +- 首页导入区重构为分入口体验,新增 API 导入和自动同步入口 +- 版本日志改为随应用本地打包,减少运行时远程依赖 +- 【CLI Web】新增存储管理能力,可在 Web 设置中查看和管理数据缓存 +- 【CLI】新增更新检查与自动更新流程,并接入 CLI Web 检查更新 +- 【文档站】新增独立文档站 docs.chatlab.fun + +### 🐛 修复 + +- 修复首页快捷开始按钮缺失的问题 +- 修复国际化 key 路径错误和 ECharts 废弃 API 用法 +- 修复自更新与迁移重试流程中的安全保护问题 +- 【桌面端】修复统一迁移可能回退数据目录改动的问题 +- 【CLI Web】修复发现新版本时展示必然失败的“立即更新”操作 +- 【CLI Web】禁用不可用的 Web 自更新执行流程 +- 【CLI Web】修复文件管理操作通过 shell 打开带来的兼容性和安全问题 +- 【CLI Web】修复数据目录提示后 Web 服务无法继续启动的问题 +- 【CLI Web】补齐合并相关 API 兼容层 +- 【CLI】优化更新检查的异步缓存、按键交互和开发模式跳过逻辑 + +### ♻️ 重构 + +- 迁移文档链接到 docs.chatlab.fun +- 【设置】将会话索引移入 AI 设置并调整设置页排序 + +### 💄 样式 + +- 优化侧边栏密度,并增强 Tab 选择器在深色模式下的对比度 + +### 📝 文档 + +- 重整公开文档站结构与导出说明 + +### 🔧 杂项 + +- 将工作区包迁移为 ESM +- 隔离文档站工作区依赖 +- 将发布版本日志移出 docs 目录 + +## v0.21.1 (2026-05-23) + +> 优化同步拉取可靠性与数据安全性,新增移除订阅时清理聊天记录选项,并修复 UI 动画和弹窗交互问题。 + +### ✨ 新功能 + +- Pull 同步后自动生成会话索引 +- 新增移除订阅时清理已导入聊天记录的选项 +- 【CLI Web】版本日志弹窗支持按版本截图,Markdown 列表修正改为可选 +- 【MCP】新增 ci 变更类型的图标与国际化支持 + +### 🐛 修复 + +- 修复 Pull 同步在小页面场景下可能丢失数据的问题,并校验重试导入结果 +- 修复拉取同步后会话索引未自动生成的问题 +- 修复 Pull 完成或数据删除后侧边栏会话列表未刷新的问题 +- 修复远端服务不支持分页时无法获取全部会话的问题 +- 优化拉取同步的重试机制与分页策略,提升稳定性 +- 修复会话存在性校验缺少 schema 检查导致表缺失报错的问题 +- 修复强制生成会话索引弹窗在失败时意外关闭的问题 +- 修复空会话状态下会话索引弹窗阻塞页面的问题 +- 【桌面端】修复应用版本号显示为 0.0.0 时自动回退读取 package.json 版本 +- 【CLI Web】同步图标改为原地旋转动画,避免出现独立加载器 + +### 📝 文档 + +- 更新 Pull 协议文档为 since+nextSince 分页模式 + +## v0.21.0 (2026-05-22) + +> 本次新增支持 MCP,统一多端导入与服务层,并修复 Web、AI、同步和发布稳定性问题。 + +### ✨ 新功能 + +- Electron 与 Web 模式支持统一的文件夹导入流程,适配多文件聊天记录格式 +- 【MCP】新增独立命令入口,并在设置页接入 MCP 配置 +- 【MCP】Server 扩展到 19 个工具,并支持紧凑文本与 JSON 双格式输出 + +### 🐛 修复 + +- 修复目录导入路径处理不稳导致的导入失败风险 +- 修复新增订阅时可能出现的同步竞态问题 +- 修复 MiniMax 流式响应中的 内容未正确识别为思考事件的问题 +- 【CLI Web】修复增量导入不可用的问题 +- 【CLI Web】修复会话不存在、成员历史等行为与桌面端不一致的问题 +- 【CLI Web】修复开发服务的 Node 运行时配置问题 +- 【MCP】修复启动时原生模块 ABI 绑定不一致的问题 + +### ♻️ 重构 + +- 统一 CLI Web 与 Electron 的共享服务层,减少路由与 IPC 中的重复业务逻辑 +- 【MCP】瘦身对外暴露的工具注册表,降低外部 AI Agent 的工具 schema 成本 +- 【MCP】将核心能力抽取为独立共享包,简化 CLI 与桌面端集成 +- 【MCP】精简设置页集成方式,降低桌面端辅助代码复杂度 + +### 👷 CI + +- 【CLI】发布流程支持同步发布 npm 包 + +### 📝 文档 + +- 补充 changelog 端特有变更的前缀与排序规则 + +### 🔧 杂项 + +- 【CLI】【MCP】补齐 npm 发布所需配置和发布说明 + +## v0.20.0 (2026-05-19) + +> 本次更新统一了多端核心架构,并优化 AI、导入、同步和桌面构建稳定性。为下个版本的独立 Web、CLI 与 MCP 能力做准备,使用CLI前必须升级至此版本以完成数据预迁移。 + +### ✨ 新功能 + +- 新增独立 CLI、HTTP API 服务和 MCP Server,为命令行、Web 与 AI 代理集成提供基础能力 +- 新增独立 Web 构建与一键启动能力,支持通过 CLI 启动 Web UI 并自动打开浏览器 +- Web 模式支持聊天记录导入、Demo 导入、会话查询、成员查询、搜索与分析等核心流程 +- Web 模式接入 AI 对话、模型配置、自定义 Provider/Model、上下文压缩和流式事件展示 +- 导入能力升级为共享流式管道,支持多格式解析、增量导入、导入分析和自动生成会话索引 +- 新增合并、Markdown 导出和会话缓存相关服务端能力 +- 新增同步共享包和 CLI 自动化支持,为后续多端同步能力打基础 +- 新增后端偏好设置持久化,并支持 CLI 首次运行时自动检测和迁移桌面端数据 + +### 🐛 修复 + +- 修复 Web 模式下会话索引、跨域代理、Demo 保护和运行时异常等问题 +- 修复 Web 模式下应用版本显示为空的问题 +- 修复 AI Agent 证据检索、Web 端事件流和错误格式化等问题 +- 修复同步展示与导入逻辑不一致的问题 +- 修复 CLI 开发模式下 ESM 模块解析问题 +- 修复 CLI 安装后可能找不到既有桌面端数据的问题 +- 修复 Electron 包拆分后数据迁移路径不一致的问题 +- 修复打包后 Worker 线程间接依赖 electron 模块导致崩溃的问题 +- 修复 Electron 构建、依赖安装和 better-sqlite3 原生模块重编译相关问题 + +### ♻️ 重构 + +- 将项目迁移为多端工作区结构,拆分 apps/desktop、apps/cli 与多个共享 packages +- 将解析器、配置、数据库适配、查询、迁移、NLP、会话缓存、导入、合并、导出和同步逻辑抽取为共享模块 +- 将 AI Agent、工具系统、预处理、上下文压缩、RAG、LLM 配置、助手和技能管理抽取为共享运行时能力 +- 统一 Electron、CLI Web 和 MCP 的 AI 工具命名、工具注册和数据访问方式 +- 统一数据目录、消息查询、成员查询、会话索引、SQL 执行和导入去重等核心逻辑 +- 将 CLI 从 packages/server 迁移到 apps/cli,并补齐 npm 发布构建链路 +- 将纯前端图表模块迁移到 src/features,保持 packages 目录仅存放可复用共享包 +- 移除多处 Electron 侧纯转发文件和废弃代码,减少重复实现 + +### build + +- 升级 Vite 等构建工具链 +- 新增 CLI tsup 构建、Web 资源打包和 npm 发布所需配置 +- 调整 Electron 桌面端构建配置,移除 Linux 桌面构建目标 + +### 📝 文档 + +- 补充版本策略、提交 scope 约定和相关开发文档 + +## v0.19.0 (2026-05-06) + +> 本次更新支持了 AI 上下文自动压缩能力,新增 Demo 示例,并优化模型配置与调试体验。 + +### ✨ 新功能 + +- 支持模型上下文窗口预设和自定义配置 +- 支持 AI 对话上下文自动压缩及相关配置 +- 优化上下文压缩流程与状态展示 +- 调试模式新增原始数据查看,并支持记录完整 LLM 上下文 +- 优化 AI 模型配置文案、表单和设置页展示 +- 移除向量模型配置逻辑,简化相关设置 +- 新用户可在空状态直接查看 Demo 示例 +- 迁移到 pnpm workspace 项目结构 + +### 🐛 修复 + +- 修复快速模型跟随默认助手时可能使用错误模型的问题 +- 修复 Demo 按钮在空数据状态下不显示的问题 +- 修复主进程直接依赖未声明 axios 导致启动失败的风险 + +## v0.18.4 (2026-04-29) + +> 本次更新优化了模型稳定性,同时支持远程获取模型列表,优化 AI 错误详情展示与部分样式等。 + +### ✨ 新功能 + +- 支持远程获取模型列表 +- OpenAI 兼容 API 地址自动补全 /v1 并支持实时预览 +- 优化 AI 对话错误详情展示 +- 优化部分展示样式 + +### 🐛 修复 + +- 修复部分逻辑漏洞 + +### 🔧 杂项 + +- 优化同步日志技能逻辑 + +## v0.18.3 (2026-04-28) + +> 支持配置快捷工具入口位置,优化默认时间筛选,并修复弹窗层级与数据目录安全提示。 + +### ✨ 新功能 + +- 支持配置快捷工具入口位置 +- 优化默认时间筛选体验 +- 禁止将数据目录设置在应用安装目录下 + +### 🐛 修复 + +- 修复弹窗样式覆盖问题 +- 修复设置页内弹窗被遮挡的层级问题 + +## v0.18.2 (2026-04-26) + +> 新增订阅类型选择、远程会话分页发现与每次拉取条数配置。 + +### ✨ 新功能 + +- 订阅会话时支持按类型筛选与选择 +- 支持远程会话分页发现与按需加载更多 +- 支持为数据源配置每次拉取的消息条数 + +## v0.18.1 (2026-04-24) + +> 新增 DeepSeek V4 支持,新增开机自启动选项,优化设置与界面体验,并修复 AI 对话链接打开方式。
从本版本开始,项目将正式迁移至ChatLab组织。 + +### ✨ 新功能 + +- 迁移至 ChatLab 组织 +- 优化全局样式表现 +- 优化快速提问展示逻辑 +- 优化设置弹窗弹出方式 +- 支持开机自启动 +- 支持 DeepSeek V4 模型 + +### 🐛 修复 + +- 修复 AI 对话中的链接未在浏览器中打开的问题 + +## v0.18.0 (2026-04-23) + +> 优化 AI 对话交互逻辑,统一多处分析入口,优化数据源同步与 Windows 更新稳定性。 + +### ✨ 新功能 + +- AI 助手交互逻辑优化 +- 调整添加数据源表单提示文案 +- 工具 Panel 新增 Mini 模式 +- 将聊天记录查看器迁移到工具 Panel +- 统一关系分析相关 Tab +- 将语录模块整体迁移到洞察 +- 将关键词分析迁移到实验室 + +### 🐛 修复 + +- 修复存量类型警告 +- Pull 增量同步增加 60 秒重叠窗口,避免消息丢失 +- Pull 拉取时固定 limit=1000,避免远端数据源导出过多数据导致卡顿 +- 修复 Windows 更新时 NSIS 弹窗导致静默安装中断的问题 + +## v0.17.5 (2026-04-21) + +> 修复了大量BUG。 + +### ✨ 新功能 + +- 优化关系卡片样式 +- 以原生机器标识逻辑替换 node-machine-id 依赖,提升 Linux 下 API 密钥修改稳定性 +- 合并聊天记录时支持可选保留原记录 +- 预设服务与第三方服务 API 地址旁新增验证按钮 + +### 🐛 修复 + +- 优化 dataSource 迁移策略,提升迁移安全性 +- 修复话题在消息过少时空状态显示异常 +- 修复本地模型验证失效问题 +- 修复切换对话后所选 Tab 被重置的问题 +- 修复旧版 dataSources 升级后自动化页面白屏问题 + +## v0.17.4 (2026-04-19) + +> 实现 Import API v1 完整协议并新增层级数据源管理,现已支持聊天记录自动同步。 + +### ✨ 新功能 + +- 实现 Import API v1 完整协议与层级数据源管理 + +## v0.17.3 (2026-04-17) + +> 私聊新增语言偏好Tab,侧边栏对话列表支持排序筛选,完善 AI 服务商与模型配置能力,并修复时间筛选重置问题。 + +### ✨ 新功能 + +- 对话列表新增排序与筛选能力 +- 新增语言偏好页签,支持偏好查看 +- 优化界面样式细节与视觉一致性 +- AI 服务商新增 Anthropic 支持 +- 模型第三方服务支持自选接口类型 +- AI 模型配置支持自定义名称 + +### 🐛 修复 + +- 修复从设置或 AI 对话页返回后时间筛选被重置为“全部”的问题 + +### ♻️ 重构 + +- 抽离语言偏好为共享类型,减少重复定义 + +## v0.17.2 (2026-04-15) + +> 新增跨平台数据合并与成员消息合并,强化词库与更新安全校验,优化暗色体验与日志能力,以及修复若干BUG。 + +### ✨ 新功能 + +- 成员管理支持成员消息合并 +- 支持跨平台聊天数据合并 +- 数据管理部分表头支持排序 +- 话题分析入口迁移至洞察模块 +- 新增 AI 日志文件原始路径记录 +- 优化暗色模式配色,提升视觉体验 + +### 🐛 修复 + +- 修复词库刷新与合并 ID 碰撞问题 +- 为 OpenAI 兼容请求补充运行时 User-Agent 请求头 +- 修复暗色截图导出背景透明异常 +- 词库下载新增 SHA256 完整性校验 +- 收紧远程配置拉取策略并强化更新安装确认 + +### 🔧 杂项 + +- 新增 ARM Linux 的 deb 安装包构建支持 +- 优化日志同步流程 + +### 📝 文档 + +- 新增繁体中文文档 + +## v0.17.1 (2026-04-13) + +> 重构话题模块并新增话题卡片,优化词云关键词过滤与查询缓存逻辑,支持远程下载分词词库及繁体中文词库,完善 WhatsApp 检测逻辑。 + +### ✨ 新功能 + +- 重构话题模块,新增话题卡片展示 +- 词云支持关键词过滤 +- 支持远程下载分词词库,新增繁体中文词库支持 +- 查询缓存逻辑优化 +- 部分 Loading 交互统一 +- 完善 WhatsApp 检测逻辑 + +### 👷 CI + +- 新增官网文档站并支持自动化同步与构建 + +## v0.17.0 (2026-04-12) + +> 重构并优化总览、视图等卡片的样式,这个版本的审美大幅度提升!并完善 WhatsApp 导入解析逻辑,同时在导入失败后支持指定格式导入,完善了截图与调试能力。 + +### ✨ 新功能 + +- 新增 WhatsApp V2 时间戳弹性解析,自动适配不同地区导出格式 +- 完善 WhatsApp 聊天记录检测机制 +- 新增指定格式导入能力 +- 消息页新增分享卡片 +- DEBUG 模式新增快速调试工具 +- 优化总览身份卡并统一时间范围查询逻辑 +- 重构总览模块卡片并抽离主题色卡片,预留配色模式 +- 统一卡片最大宽度与首页工具布局,支持全局工具侧边栏 +- 主题卡片支持截屏,并默认关闭截图移动端适配 +- 移除诊断建议并新增提示 + +### 🐛 修复 + +- 修复 WhatsApp 时间解析正则与行匹配正则宽严不一致问题 +- 修复 WhatsApp 12 小时制时间与 NNBSP 字符解析兼容性问题 + +### 🔧 杂项 + +- 缓存 electron 与 electron-builder 二进制文件以加速 CI 打包 + +## v0.16.0 (2026-04-10) + +> 新增私聊主动性分析视图,并修复模型编辑弹窗自定义模型丢失问题。 + +### ✨ 新功能 + +- 私聊场景新增主动性分析视图 +- 优化页脚区域展示与交互 +- 优化语录模块下半部分逻辑 + +### 🐛 修复 + +- 修复第三方/本地服务编辑弹窗丢失多个自定义模型的问题 + +## v0.15.0 (2026-04-08) + +> 大幅度优化搜索与查询性能,搜索工具支持自动携带上下文消息,优化AI模型配置并新增部分服务商,并支持 Linux 平台。 + +### ✨ 新功能 + +- 增加查询缓存以加速访问 +- 搜索工具支持自动携带上下文消息 +- 重构模型配置逻辑 +- 新用户首次启动时优先弹出语言选择弹窗 +- 实验室新增基础调试工具 +- 移除旧版提示词 + +### 🐛 修复 + +- 修复 Windows 浅色模式下标题栏按钮区域背景色与应用不一致问题 +- 修复 CI 打包工作流中 Node 24 与 pnpm 对齐相关问题 +- 补全工具调用显示名称的 i18n 翻译 + +### ♻️ 重构 + +- 优化 AI 配置弹窗代码组织 + +### 🔧 杂项 + +- 升级到 Node 24 +- 支持 Linux 打包 + +### 📝 文档 + +- 更新文档 + +## v0.14.2 (2026-04-07) + +> 本次更新聚焦 AI 对话体验升级,新增AI对话复制、优化 UI,并支持了 FTS5 全文搜索工具,精简部分工具搜索参数,并补强错误提示与测试能力。 + +### ✨ 新功能 + +- 新增 7 天内记住助手选择功能 +- AI 对话支持一键复制消息内容 +- 优化 AI 对话样式与整体交互体验 +- 支持 FTS5 全文搜索并新增快速搜索工具 +- 精简部分工具搜索参数,降低 token 开销 +- 新增 Electron 应用 E2E 测试框架,支持端口管理与实例隔离 + +### 🐛 修复 + +- 完善 AI 对话错误提示,提升问题定位效率 + +### ♻️ 重构 + +- 整理 AI 对话模块代码结构 +- 抽取会话分析页公共逻辑并统一头部文案 + +### test + +- 补充可复用的 E2E 启动器冒烟测试能力 + +### 📝 文档 + +- 更新项目说明中的引导图片 + +### 💄 样式 + +- 统一部分代码格式,提升可读性 + +## v0.14.1 (2026-04-02) + +> 本次更新聚焦首页架构调整,优化UI,以及提升 SQL 对话体验、统计读取性能与AI工具质量。 + +### ✨ 新功能 + +- 优化总览页样式 +- 优化 SQL 对话模块交互逻辑 +- 将成员管理迁移到首页并调整相关 Tab 模块布局 +- 新增部分 AI 工具并补充获取聊天概览能力 +- 新增对话数据缓存管理模块,提升统计数据读取性能 +- 完善版本日志弹窗的类型展示 + +### 🐛 修复 + +- 修复 SQL Lab 与摘要生成中 AI 错误被静默吞没的问题 + +### ♻️ 重构 + +- 重构 AI 工具分类体系,提升工具组织的可维护性 + +### 🔧 杂项 + +- 废弃部分低价值 AI 工具,精简可用工具集 + +## v0.14.0 (2026-03-28) + +> 支持通过 API 导入与查询聊天消息,优化总览和设置体验,并修复消息去重、AI 会话与趋势展示等问题。 + +### ✨ 新功能 + +- 支持 API 导入 +- 支持 API 导出 +- 设置页支持选择默认进入的会话标签页 +- 优化总览页面样式 +- 优化整体界面样式与 API 服务设置界面 +- 点击预设问题后可直接发起提问 +- 优化身份卡片与助手选择交互 + +### 🐛 修复 + +- 修复消息去重误判,并统一空字符串去重语义 +- 修复 AI 会话链路与前端 type-check 错误 +- 增加默认 assistant 兜底逻辑,修复异常场景 +- 修复每日消息趋势不展示的问题 + +### ♻️ 重构 + +- 清理 parser、worker、RAG 与 merger 的历史类型问题 + +### 🔧 杂项 + +- 新增 assistant 配置生成技能 + +## v0.13.0 (2026-03-16) + +> AI对话支持助手模式,对话支持使用技能,输入框支持快捷选择,完善对话与设置界面,支持繁体中文与日语,部分 UI 调整,并修复多项稳定性问题。 + +### ✨ 新功能 + +- 完成助手模式初版并完善助手逻辑与分析工具能力 +- 上线助手市场与技能市场,聊天对话支持使用技能 +- 支持 @ 选择成员协作 +- 支持繁体中文与日语国际化 +- 设置页面重构并优化部分 UI 细节 +- 优化总览模块样式与对话界面体验 +- 调整导出聊天记录的展示位置 +- 移除旧版提示词系统与自定义筛选 AI 功能 +- 切换页面时调用模型不再中断 + +### 🐛 修复 + +- 修复 Gemini API 配置问题 +- 修复 NLP 停用词调用顺序导致的报错 + +### ♻️ 重构 + +- 重构 AIChat 组织结构 +- 重构目录位置与工程结构 + +### 📝 文档 + +- 更新用户协议与项目文档 + +### 🔧 杂项 + +- 优化版本日志构建流程 + +### 💄 样式 + +- 统一代码格式与 lint 规则输出 + +## v0.12.1 (2026-02-27) + +> 新增聊天记录预处理与调试能力,重构 Agent/LLM 架构,并修复国际化与 Windows 主题显示问题。 + +### ✨ 新功能 + +- 新增聊天记录预处理管道 +- 新增预处理设置界面与配置管理能力 +- Agent 支持基于会话的上下文时间线与运行状态展示 +- 新增 AI 调试模式并增强日志可观测性 + +### 🐛 修复 + +- 修复英文设置下部分界面未国际化的问题 +- 修复 Windows 下动态更新 overlay 颜色时主题不一致的问题 + +### ♻️ 重构 + +- 拆分 Agent 单体实现为模块化架构 +- 工具系统重构为 AgentTool + TypeBox 结构并补齐 i18n +- 统一 LLM 访问层,收敛为 pi-ai 方案 +- 重构数据流方向与 IPC 协议并完成前端适配 +- 引入共享类型并优化 ChatStatusBar 国际化 +- 将部分图表重构为插件化结构 + +### 🔧 杂项 + +- 移除过度设计的 sessionLog 模块 +- 移除 @ai-sdk 相关依赖与旧版 LLM 服务实现 +- 临时隐藏向量模型配置入口 +- 更新项目描述文案 + +### 💄 样式 + +- 执行 ESLint 自动格式化,统一代码风格 + +## v0.11.2 (2026-02-15) + +> 优化聊天记录导入机制,优化管理页面,增强多平台聊天记录兼容性。 + +### ✨ 新功能 + +- 增强 LINE 与 WhatsApp 解析器兼容格式 +- 优化聊天记录嗅探层,支持轮询检测与回退机制 +- 管理页面支持 Shift 多选 +- 管理页面新增展示聊天摘要数量与 AI 对话数量 +- 优化主页面布局,提升可用空间 +- 优化 Windows 下右上角控制栏样式 + +### 📝 文档 + +- 更新项目文档 + +## v0.11.0 (2026-02-13) + +> 支持 Telegram 导入,优化增量导入体验,完善国际化配置,修复索引失效与页面闪烁等问题。 + +### ✨ 新功能 + +- 完善 AI 调用、日志与主进程配置的国际化支持 +- 支持 Telegram 聊天记录导入 +- 优化增量导入交互与相关文案 +- 优化打开协议的交互体验 + +### 🐛 修复 + +- 修复增量导入后索引失效的问题(resolve #81) +- 修复 WhatsApp 使用 iPhone 导出后无法识别的问题(resolve #82) +- 修复切换对话页面出现双闪的问题 + +### 🔧 杂项 + +- 优化 TypeScript 配置 +- 调整 i18n 构建配置 +- 优化技能相关工程配置 + +## v0.10.0 (2026-02-11) + +> 新增互动频率分析能力,优化会话查询链路,并修复增量索引与数据库扫描相关问题。 + +### ✨ 新功能 + +- 新增互动频率分析视图,支持更直观地观察成员互动趋势 +- 优化会话查询相关逻辑与处理链路 + +### 🐛 修复 + +- 修复增量更新后会话索引生成范围不准确的问题(fix #79) +- 修复迁移与会话扫描时误处理非聊天 SQLite 文件的问题 + +### ♻️ 重构 + +- 重构会话查询模块,提升查询结构可维护性 + +### 🔧 杂项 + +- 移除 transformers 相关依赖并更新工程配置 + +## v0.9.4 (2026-02-08) + +> 优化时间筛选与 AI 配置体验,对 API Key 进行本地加密,并修复 LINE 聊天记录解析问题。 + +### ✨ 新功能 + +- 时间筛选支持更多灵活选择 +- API Key 支持本地加密存储 +- 新用户首次进入不再显示版本日志 +- 优化 AI 对话底部配置状态展示 +- 数据目录迁移后支持立即重启软件 + +### 🐛 修复 + +- 修复 LINE 聊天记录解析问题 + +### 📝 文档 + +- 更新项目文档 + +## v0.9.3 (2026-02-03) + +> 支持自定义数据目录,修复大量已知问题。 + +### ✨ 新功能 + +- 设置内新增数据目录位置配置 +- 数据存储目录迁移逻辑优化 +- 目录切换新增确认弹窗 +- 解析逻辑优化(WeFlow / Echotrace) + +### 🐛 修复 + +- 修复 Windows 自定义筛选时消息量过大导致崩溃的问题 +- 修复第三方中转 API 调用 tool_call 导致对话异常结束的问题 +- 修复部分 WhatsApp 聊天记录无法正确识别的问题 +- 修复管理页面表头层级显示问题 + +### ♻️ 重构 + +- 重构 session 查询模块 +- 完善迁移日志输出 + +## v0.9.2 (2026-02-02) + +> 榜单改为图表展示,优化词云生成逻辑,优化本地 AI 推理模型,改进聊天记录筛选与日期选择器,并在启动后预加载关键路由提升体验。 + +### ✨ 新功能 + +- 榜单重构为图表展示 +- 词云效果优化 +- 推理模型优化 +- 消息会话搜索与筛选联动优化 +- 日期选择器交互优化 +- 启动后预加载关键路由 + +### 🔧 杂项 + +- preload 模块化拆分 +- 优化 analytics 逻辑 +- 升级 ESLint 并进行代码格式化 + +## v0.9.1 (2026-01-30) + +> 支持了 LINE 聊天记录的导入,新增批量管理,聊天对话支持搜索,修复了一些已知问题。 + +### ✨ 新功能 + +- 新增批量管理,支持批量删除和合并 +- 支持聊天对话搜索 +- 支持 LINE 聊天记录导入 +- 兼容 WeFlow 导出的 JSON 格式 +- 成员列表改为后端分页加载 +- 优化部分文案 + +### 🐛 修复 + +- 修复 Windows 在更新时,Worker 占用导致软件无法关闭的问题 + +## v0.9.0 (2026-01-28) + +> 支持了 NLP 分词能力,语录 Tab 下新增词云;新增视图功能,支持展示更多图表;支持了系统代理跟随;优化了部分页面和样式。 + +### ✨ 新功能 + +- 用户选择器性能优化,支持虚拟加载 +- 迁移榜单到视图Tab +- 引入分词能力,并新增词云子Tab +- 优化群聊页Tab文案 +- 网络代理支持跟随系统代理 +- 版本日志显示判断逻辑优化 + +### 💄 样式 + +- markdown渲染样式优化 + +## v0.8.0 (2026-01-26) + +> 新增了会话摘要与向量检索能力,每次版本更新后会弹窗展示更新内容,优化了部分界面交互,同时修复了一些已知问题。 + +### ✨ 新功能 + +- 聊天会话支持摘要功能 +- 新增批量生成会话摘要逻辑 +- 支持向量模型配置和相关检索 +- 导入聊天记录报错时记录更详细的日志 +- 每次更新新版本后,自动打开版本日志供用户查看 +- 首页新增Footer,展示常用链接 +- 侧边栏移除帮助与反馈 + +### 🐛 修复 + +- 修复 shuakami-jsonl 解析错误(fix #50) + +## v0.7.0 (2026-01-23) + +> 优化了 AI 对话体验,改进了更新逻辑,图表方案使用 Echarts 替代 chart.js。 + +### ✨ 新功能 + +- 优化更新逻辑 +- 完善AI对话错误日志 +- 聊天对话底部支持快速选择对话模型 +- 优化默认提示词,带点幽默 +- Echarts 替换 chart.js +- 取消注册协议逻辑 + +## v0.6.0 (2026-01-21) + +> 接入了 AI sdk,提高了 AI 对话 的稳定性;AI对话新增展示思考内容块;对部分样式进行了优化。 + +### ✨ 新功能 + +- 新增定位日志功能 +- 接入AI sdk +- 追加思考内容块 +- 解决全局弹窗会被首页顶部拖拽区域遮挡的问题 +- 优化windows下右上角关闭样式 + +## v0.5.2 (2026-01-20) + +> 支持了合并导入,同时修复了一些问题。 + +### ✨ 新功能 + +- 支持合并导入 +- 主面板显示聊天记录起止时间 +- 拖拽区域优化 + +### 🐛 修复 + +- 优化构建配置以解决macOS x64编译问题 +- 消息记录查看器在windows下关闭按钮样式问题 +- macOS 打包时需在对应架构上编译(fixes #36) + +## v0.5.1 (2026-01-16) + +> 修复了一些问题。 + +### ✨ 新功能 + +- 文案优化 + +### 🐛 修复 + +- 修复windows下关闭软件进程不退出的问题(#33) +- 修复数字输入框BUG (resolve #34) + +## v0.5.0 (2026-01-14) + +> 支持了 instagram 聊天记录导入;首页支持了批量导入;聊天页面支持了增量导入。 + +### ✨ 新功能 + +- 支持 instagram 聊天记录导入 +- 逻辑优化 +- 系统提示词预设功能优化 +- 支持增量导入 +- 支持批量导入 +- 样式优化 +- Windows 端支持原生窗口控制并实现主题同步 (#31) + +### 🔧 杂项 + +- 移除componenst.d.ts + +## v0.4.1 (2026-01-13) + +> 进行了一些样式和交互上的优化。 + +### ✨ 新功能 + +- 提示词支持预览 +- 优化AI对话状态栏 +- 优化迁移表逻辑 +- 侧边栏支持显示头像 +- 样式优化 +- 替换原生窗口控制栏 +- 优化全局背景色 +- 关闭软件时清理 Worker + +### 🐛 修复 + +- 修复主题模式设置跟随系统不生效的问题 +- 修复更新弹窗提示内容排版问题 + +## v0.4.0 (2026-01-12) + +> 导入支持 shuakami-jsonl 格式;优化了 AI 对话,现在更加节省token了;导入聊天记录时支持生成会话索引,同时消息查看器也支持按索引查看;软件更新支持了加速镜像。 + +### ✨ 新功能 + +- 兼容shuakami-jsonl +- 优化Loading +- 新增自定义筛选 +- 重构预设词系统,支持通用预设词 +- 精简系统提示词以节省token +- 新增会话相关function calling调用 +- 处理消息跳转到上下文逻辑 +- 聊天记录查看器支持查看会话索引和快速跳转 +- 重构设置弹窗,新增会话索引设置 +- 导入聊天记录支持生成会话索引 +- 重构设置弹窗 +- 优化基础组件交互样式 +- 优化首页样式 +- 优化更新加速逻辑 +- 添加加速镜像 + +## v0.3.1 (2026-01-09) + +> 已适配 Discord 导入;各个解析器支持了回复类型的导入;软件存储目录迁移至更规范的目录;导入时支持角色导入;对导入报错支持了更详细的诊断和提示;部分细节优化。 + +### ✨ 新功能 + +- 数据表升级改为在主进程升级 +- 自动检查更新时忽略beta版本 +- 将数据存储目录迁移到userData下 +- 各个解析器重新支持回复消息导入 +- 支持平台消息id和回复id,同时进行表迁移 +- 支持Tyrrrz/DiscordChatExporter消息格式导入 +- member表支持角色 +- 增强chatlab格式检测行为 +- 确保点击导入和拖入导入的逻辑一致 +- 支持更详细的格式诊断 + +### 🐛 修复 + +- 修复部分用户platformId 为空的情况 + +## v0.3.0 (2026-01-08) + +> 进行了全量的国际化支持,支持中英文切换;一些功能优化。 + +### ✨ 新功能 + +- SQL实验室支持导出 +- AI对话支持导出 +- 完成最终国际化 +- AI模型错误时显式报错 +- SQL结果支持跳转消息查看器 +- 优化系统prompt,支持prompt市场 + +## v0.2.0 (2025-12-29) + +> 支持配置代理;导入时支持显示错误日志;优化部分界面交互,以及部分功能更新。 + +### ✨ 新功能 + +- 消息管理器支持显示系统消息 +- 优化导入逻辑,错误时会显示导入日志 +- WhatsApp支持英文格式消息导入 +- 支持配置代理(resolve #7) +- 优化AI模型界面交互 +- 添加用户配置API教程 +- 1、新增GLM两个免费调用模型 2、新增豆包服务商和对应的最新模型 +- AI回复不输出think内容 + +## v0.1.3 (2025-12-25) + +> 修复了一些问题。 + +### 🐛 修复 + +- 修复 Echotrace 解析器错误 + +## v0.1.2 (2025-12-25) + +> 支持了深色模式;AI对话中,系统提示词中支持传递给用户身份。 + +### ✨ 新功能 + +- AI对话中,系统提示词中支持传递给用户身份 +- 聊天记录查看器中,Owner显示在右侧 +- 支持数据库升级 +- 成员Tab中支持设置Owner视角 +- 支持深色模式 + +### 🐛 修复 + +- 修复私聊误判为群聊的问题 + +## v0.1.1 (2025-12-24) + +> 已适配 WhatsApp 聊天记录的导入;支持旧版 QQ 讨论组格式的分析。 + +### ✨ 新功能 + +- 聊天会话底部显示token消耗 +- 支持WhatsApp原生格式消息 +- 支持旧版QQ txt版本的讨论组格式 + +### 🐛 修复 + +- 修复消息管理器层级过低的问题 + +## v0.1.0 (2025-12-23) + +> 项目开源并发布。 + +### ✨ 新功能 + +- init diff --git a/changelogs/en.json b/changelogs/en.json new file mode 100644 index 000000000..2f069c5ea --- /dev/null +++ b/changelogs/en.json @@ -0,0 +1,1823 @@ +[ + { + "version": "0.29.0", + "date": "2026-07-01", + "summary": "Add Contacts, introduce the relationship galaxy, and improve contact computation, avatar loading, and sidebar performance.", + "changes": [ + { + "type": "feat", + "items": [ + "Add the People module with Contacts as a subpage, plus cross-session contact aggregation, time range filters, pagination, and virtual scrolling", + "Let the Contacts page manually mark group members as friends, with entry points for source conversations and chat record viewing", + "Add an interaction relationship galaxy with a 3D panorama, node filters, search, a detail panel, and related contacts/group exploration", + "Add High Association and Friends Only filters to the relationship galaxy, and mask names in privacy mode", + "[Desktop] Download app updates silently in the background" + ] + }, + { + "type": "perf", + "items": [ + "Use pagination and virtual scrolling for contact lists to reduce render pressure with many friends and group members", + "Lazy-load avatars and virtualize the sidebar session list to reduce startup and page switching stutter" + ] + }, + { + "type": "style", + "items": ["Align the dark mode main background, relationship page header, and sidebar selected state"] + } + ] + }, + { + "version": "0.28.1", + "date": "2026-06-26", + "summary": "Improve chat record viewing, add the import API, and fix import, sync, and local model proxy download issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Add the Push Import API with per-session append, deduplication, and incremental index generation", + "Automatically generate session indexes after imports across CLI, Desktop, and pull sync paths", + "Improve chat record viewer controls and highlight behavior", + "Hide the sidebar scrollbar by default and show it on hover" + ] + }, + { + "type": "fix", + "items": [ + "Harden Push/Pull import validation, deduplication, and concurrency locks to avoid invalid writes or duplicate imports", + "Include lastPlatformMessageId and importedAt in GET /api/v1/sessions/:id for incremental import boundaries", + "Refresh the session list automatically after background pull sync completes", + "Fix local semantic index model downloads so they use the configured proxy correctly, and initialize worker logging", + "[Desktop] Align the default API port with CLI at 3110 and try following ports automatically when it is occupied", + "[Desktop] Resolve HTTP, HTTPS, and SOCKS system proxy results to avoid silently bypassing the proxy for local model downloads" + ] + }, + { + "type": "refactor", + "items": [ + "[CLI] Move pull/sync import paths to the shared streaming importer and remove the legacy parser/write path", + "[Desktop] Remove redundant session index generation after incremental imports" + ] + }, + { + "type": "docs", + "items": ["Fix API port references, examples, and documentation for unavailable capabilities"] + } + ] + }, + { + "version": "0.28.0", + "date": "2026-06-25", + "summary": "Add Google Chat import support, introduce a unified app logger with file rotation, and improve the semantic index experience.", + "changes": [ + { + "type": "feat", + "items": [ + "Add Google Chat import support across CLI and Desktop", + "Add a unified app logger with automatic file rotation and global uncaught error capture", + "Replace disable with remove in semantic index management; fix legacy hash and cap list height", + "Preload semantic index model on config save and move it to the AI directory", + "Simplify semantic index settings by removing the search result count option in favor of a built-in default" + ] + }, + { + "type": "fix", + "items": ["Declare stream-json/stream-chain dependencies and fix multi-chat scan fallthrough"] + }, + { + "type": "docs", + "items": ["Add Google Chat to the supported platforms documentation"] + } + ] + }, + { + "version": "0.27.2", + "date": "2026-06-24", + "summary": "Fix multiple auth profile lifecycle issues, improve analytics service reliability, and bundle missing local embedding runtime dependencies.", + "changes": [ + { + "type": "feat", + "items": ["[CLI Web] Unify daily active user reporting through a shared AnalyticsService"] + }, + { + "type": "fix", + "items": ["Clean up the corresponding auth profile when removing an AI service config"] + }, + { + "type": "refactor", + "items": ["Remove outdated analytics settings migration"] + } + ] + }, + { + "version": "0.27.1", + "date": "2026-06-23", + "summary": "Add batched API embedding for faster index builds and lazy-loaded local models; fix semantic index resume-after-crash, sync cursor regression, and AI conversation export.", + "changes": [ + { + "type": "feat", + "items": [ + "Support batched embedding via API providers, significantly speeding up index builds", + "Switch local embedding models to lazy worker initialization, reducing startup overhead" + ] + }, + { + "type": "fix", + "items": [ + "Fix AI conversation export to correctly export currently visible AI conversations", + "Fix semantic index batched-write resume: prevent unique-constraint errors and stuck sessions after a mid-batch crash", + "Fix multiple semantic worker issues: config changes not forwarded to a running worker, availability probes causing failures in normal AI chats, and inaccurate active-build tracking leading to premature worker shutdown", + "Fix sync pull cursor: initial empty-page server watermark was discarded before retries, causing repeated refetches of the tail window", + "Fix sync pagination cursor regression that caused unnecessary duplicate imports" + ] + }, + { + "type": "refactor", + "items": [ + "Remove dead code and over-engineered abstractions; simplify the tool catalog structure and deep-merge logic" + ] + } + ] + }, + { + "version": "0.27.0", + "date": "2026-06-22", + "summary": "Introduce vector index and evidence retrieval: locate and present chat evidence via semantic search, with a new index management UI.", + "changes": [ + { + "type": "feat", + "items": [ + "Add a semantic index management UI with conversation picker and i18n support", + "Add retrieve_chat_evidence tool: AI can retrieve time-anchored chat evidence via semantic search", + "Planner automatically routes evidence-type questions to the semantic retrieval tool", + "Evidence blocks support expand/collapse and click-through to the source message", + "Semantic retrieval supports time-range filtering", + "Redesign the semantic index model selection UI", + "AI process segments are now collapsible", + "Streamline evidence source rows and agent tool result display" + ] + }, + { + "type": "fix", + "items": [ + "Fix multi-keyword message search", + "Fix UX regression in word cloud dictionary loading", + "Fix ranking label formatting and emoji cleanup", + "Add missing i18n text for the v8 database migration", + "[CLI] Fix missing session context forwarding and preprocessing in the tool adapter" + ] + } + ] + }, + { + "version": "0.26.3", + "date": "2026-06-15", + "summary": "Improve analytics performance with on-disk caching and stale request cancellation for smoother session switching; fix several issues in word cloud, top-word stats, and the timeline panel.", + "changes": [ + { + "type": "feat", + "items": ["[CLI Web] Add favicon"] + }, + { + "type": "perf", + "items": [ + "Cache analytics results to disk keyed by database file version, significantly speeding up subsequent loads", + "Automatically cancel stale analytics requests when switching sessions or changing filters", + "Skip re-segmentation when changing the word cloud word count to reduce unnecessary computation" + ] + }, + { + "type": "fix", + "items": [ + "Fix catchphrase stats including platform reply message placeholders", + "Fix word cloud text including media placeholders", + "Fix language-preference and word-frequency caches not being invalidated when the Jieba custom dictionary changes", + "Fix time-sensitive analytics cache entries not expiring daily", + "Fix timeline panel default scroll position and session jump not working", + "Fix missing context for segmented messages", + "Fix AI conversation count always returning 0", + "Fix the home page tutorial link not using the localized path", + "Remove the screenshot button from the top of the AI chat panel", + "[Desktop] Pre-bundle lazy-route dependencies to avoid 504 errors in dev mode", + "[CLI] Fix false update notifications caused by mismatched package version", + "[CLI] Support arrow key selection in the update prompt" + ] + }, + { + "type": "refactor", + "items": ["Rename the timeline panel label to 'Summary'"] + } + ] + }, + { + "version": "0.26.2", + "date": "2026-06-13", + "summary": "Improve the 'Who am I' session identity feature with manual owner profile selection and automatic batch-fill across same-platform sessions; fix several chart rendering and AI cache issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Add 'Who am I' session identity feature: manually confirm your identity to save a platform owner profile and automatically match it across other unowned sessions on the same platform", + "Automatically apply the saved owner profile after import or pull sync, reducing manual setup" + ] + }, + { + "type": "fix", + "items": [ + "Fix ownerId cache inconsistency when batch-filling sessions on name-match platforms (WhatsApp / Line / Instagram)", + "Fix owner profile being overwritten by stale values when multiple routes share independent PreferencesManager instances", + "[CLI] Fix stale preferences cache in the sync module causing newly imported sessions to miss the owner profile during long-running server sessions", + "Fix temporary files not being fully deleted after import", + "Fix chart flickering during AI streaming generation", + "Fix unstable render order in AI-generated charts", + "Fix empty placeholder rows appearing in AI-generated charts", + "Add progress indicator during AI chart generation", + "Fix charts appearing incomplete in screenshots due to missing resize step before capture", + "Clarify that last_message_time represents data coverage end time, not the group's last activity time", + "Fix stale overview and analysis caches not being invalidated after incremental import" + ] + } + ] + }, + { + "version": "0.26.1", + "date": "2026-06-10", + "summary": "Fix several tool-call history replay issues and complete tool-call persistence and replay support; add Token cache usage stats to the status bar and support multiple export formats.", + "changes": [ + { + "type": "feat", + "items": [ + "Add Token cache hit/miss usage display to the chat status bar", + "Support TXT, JSON, and Markdown export formats" + ] + }, + { + "type": "fix", + "items": [ + "Persist and replay tool calls across conversation turns to maintain multi-turn AI context", + "Fix tool-only assistant turns being filtered out during history replay, causing tool results to be lost", + "Fix repeated exports of the same session silently overwriting the previous file due to a deterministic filename", + "Desktop: Fix large session exports failing prematurely due to the 60-second IPC timeout", + "Fix DeepSeek tool-call turns not replaying `reasoning_content` in history", + "Fix replayed tool results not being counted correctly in history compression token accounting", + "Fix raw message data leaking into the tool result text sent to the model", + "Fix the tool panel not closing when clicking outside" + ] + }, + { + "type": "refactor", + "items": ["Extract the session page header into a shared component"] + } + ] + }, + { + "version": "0.26.0", + "date": "2026-06-10", + "summary": "Add an AI analysis planner with streaming structured plan generation and chart planning integration; improve chart mode tool availability and fix several AI and import issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Add an AI analysis planner with streaming structured plan generation and block rendering", + "Integrate chart planning support so AI analysis plans can automatically transition into chart generation", + "Provide the planner with startup context and extended data snapshots for deeper, higher-quality analysis", + "Prefer high-level tools over raw SQL when rendering charts to reduce permission requirements", + "Improve overall AI analysis and chart behavior", + "Add a chart auto-mode preference setting", + "Add copy-message-ID actions", + "Add LLM fallback for shadow routing and log routing decisions", + "CLI: Expose full agent stream events" + ] + }, + { + "type": "fix", + "items": [ + "Fix the `render_chart` tool being dropped when a chart skill is explicitly selected", + "Fix tool availability gaps in explicit chart mode and plan status after errors", + "Migrate ECharts 6 `containLabel` configuration and hide redundant pie chart legends", + "Fix settings page navigation order, `skillSettings` key naming, and SubTabs line-wrapping", + "Fix assistant messages not filling the full chat area width", + "Improve the visual layout of the analysis plan generation block", + "Fix thinking-level selection not persisting across restarts", + "Fix JSONL timestamp normalization causing incremental import failures", + "Desktop: Fix `execute_sql` tool not being registered in the desktop tool registry" + ] + }, + { + "type": "test", + "items": ["Add an evaluation set for agent routing decisions"] + } + ] + }, + { + "version": "0.25.1", + "date": "2026-06-08", + "summary": "Introduce a data directory compatibility gate to prevent older runtimes from writing to upgraded data directories, and add a CLI `ai chat` command.", + "changes": [ + { + "type": "feat", + "items": [ + "Add a data directory compatibility gate: after writing v6 schema data, record the minimum required runtime version to prevent older runtimes from corrupting upgraded directories", + "Verify data directory compatibility before each database operation and reject access when the version requirement is not met", + "Desktop: Check data directory compatibility at startup and show an error dialog before quitting if the requirement is not met", + "CLI: Check data directory compatibility at startup and exit immediately if the requirement is not met", + "CLI: Add the `ai chat` command for multi-turn AI conversations directly in the terminal" + ] + }, + { + "type": "fix", + "items": [ + "Use the packaged app version (not Electron's dev-mode 0.0.0) when writing the compatibility gate, and require a valid runtime identity", + "Write the compatibility gate after database migrations and imports complete; abort startup migrations immediately on failure instead of failing silently", + "MCP: Check data directory compatibility at startup and exit early to prevent writing incompatible data", + "Desktop: Map compatibility gate errors to 409 responses in the HTTP API so the frontend can show an upgrade prompt", + "CLI: Normalize AI tool names and fix edge cases in the `ai chat` command" + ] + }, + { + "type": "refactor", + "items": ["Align AI chat and segment identifier naming"] + }, + { + "type": "test", + "items": ["Add test coverage for the parser, config migration, database migration, HTTP routes, and AI tools"] + }, + { + "type": "docs", + "items": [ + "Add public documentation for the data directory compatibility gate, explaining version restrictions and upgrade rules when sharing a data directory" + ] + } + ] + }, + { + "version": "0.25.0", + "date": "2026-06-07", + "summary": "AI chat can now generate charts through skills, with fixes for tool rounds, the desktop title bar, and the development service lifecycle.", + "changes": [ + { + "type": "feat", + "items": [ + "Add a slash-activated AI chart runtime that can generate and render ECharts charts in conversations", + "Improve the AI chart result experience with clearer rendering states and support for more chart types" + ] + }, + { + "type": "fix", + "items": [ + "Increase the default AI Agent tool round limit to reduce early stops in complex tasks", + "Fix preset question chips and sidebar elements stacking incorrectly", + "CLI Web: Fix incomplete cleanup of the development backend lifecycle", + "Desktop: Register the chart tool for automatic skills so chart skills can run correctly", + "Desktop: Refresh the Windows title bar overlay cache after theme changes and smooth its display" + ] + }, + { + "type": "refactor", + "items": [ + "Centralize the AI chart runtime policy and unify chart tool enablement across CLI and desktop", + "Deduplicate skill menu and Skill Manager logic" + ] + }, + { + "type": "docs", + "items": ["Update the iMessage chat export guide"] + }, + { + "type": "chore", + "items": ["Move maintainer-only skills out of the public repository into private maintenance context"] + } + ] + }, + { + "version": "0.24.1", + "date": "2026-06-04", + "summary": "Adds in-app update notices and default AI preprocessing rules, with fixes for update badges and desensitization settings.", + "changes": [ + { + "type": "feat", + "items": [ + "Add an in-app update notice entry so the sidebar can surface the latest release details", + "Add default AI preprocessing settings for data cleaning, denoising, and desensitization", + "Support grouped AI desensitization rules to make built-in and custom rules easier to manage" + ] + }, + { + "type": "fix", + "items": [ + "Fix incorrect New badges caused by failed update checks, stale update caches, and CLI Web development placeholder versions", + "Fix built-in desensitization rules not being applied before AI preprocessing runs", + "Fix empty desensitization preference overrides not clearing previously saved built-in rule switches", + "Fix legacy built-in desensitization rule overrides being lost during migration" + ] + }, + { + "type": "docs", + "items": [ + "Add a public development guide covering local setup, directory responsibilities, and collaboration conventions" + ] + }, + { + "type": "ci", + "items": ["Add Markdown changelog links to the release workflow"] + } + ] + }, + { + "version": "0.24.0", + "date": "2026-06-03", + "summary": "Adds CLI Web authentication and data directory migration, unifies cross-platform HTTP routes, and fixes data migration, AI settings, and import refresh issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Add Markdown generation for multilingual release notes", + "Allow legacy data migration prompts to be ignored from storage management", + "[CLI Web] Add a login page, token authentication, and persistent login state", + "[CLI Web] Support data directory migration from Web settings", + "[CLI] Add the --require-auth flag to protect /_web/* API access", + "[Desktop] Add an internal HTTP service so the frontend can use shared service adapters" + ] + }, + { + "type": "fix", + "items": [ + "Fix multiple edge cases in data directory and database migrations to prevent missing legacy columns or failed reads after path changes", + "Fix legacy data migration prompts appearing incorrectly when no legacy data exists or after directory changes", + "Fix sidebar message counts not refreshing after incremental imports", + "Fix sidebar collapsed state being lost after refresh because it was stored only in sessionStorage", + "Fix fetch-models and validate buttons not being enabled when editing AI settings with a saved key", + "Fix stability issues in shared AI SSE streaming", + "Fix custom data source creation not requiring a token", + "[Desktop] Move chart plugin computation to a worker to avoid blocking the main thread" + ] + }, + { + "type": "refactor", + "items": [ + "Extract the @openchatlab/http-routes shared package to unify HTTP route implementations across CLI Web and Desktop", + "Move AI settings, assistants, skills, conversations, streaming responses, cache, and merge APIs to shared HTTP routes", + "Trim the desktop IPC bridge by removing legacy AI, session index, LLM, Assistant, Skill, and NLP compatibility handlers", + "Unify the frontend service layer to reduce Electron/Web mode branching" + ] + }, + { + "type": "perf", + "items": [ + "Minify the main process bundle and lazy-load the tiktoken rank table to reduce startup and package size pressure" + ] + }, + { + "type": "ci", + "items": ["Fix the Windows release workflow zstd cache issue and add CLI update notes to release notes"] + }, + { + "type": "chore", + "items": ["Align Node type-check projects to cover desktop and shared Node code"] + } + ] + }, + { + "version": "0.23.1", + "date": "2026-06-01", + "summary": "Adds a clb command alias and port conflict detection with actionable guidance. Fixes daemon silent exit and several UI issues in dark mode.", + "changes": [ + { + "type": "feat", + "items": [ + "Improve the page header and toolbar layout", + "[CLI] Detect port conflicts before startup and display clear guidance (switch port or run lsof) instead of a delayed EADDRINUSE error", + "[CLI] Add clb as a short alias for the chatlab command" + ] + }, + { + "type": "fix", + "items": [ + "Fix sidebar tooltip misplacement and Nuxt UI v4 API compatibility", + "Fix red background and incorrect z-index layering in the title bar under dark mode", + "Tighten AI message role parameter types and strengthen conversation test assertions", + "[CLI] Fix daemon entry point error that caused the service to exit silently after startup", + "[CLI] Improve error handling and messaging for port conflict detection", + "[CLI] Fix missing chatlab.fun reverse proxy route in web mode" + ] + } + ] + }, + { + "version": "0.23.0", + "date": "2026-05-31", + "summary": "Overhauls message editing with a fork/regenerate model, adds per-model thinking level controls, unifies the CLI entry point, and fixes multiple reasoning detection and computation issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Add a Fork button to AI replies to branch the conversation from any point into an independent session", + "Add a per-model thinking level selector in the status bar, with the choice remembered per model slot", + "Extend thinking level controls with default and auto options, covering Kimi, Doubao, Gemini, and more model families", + "Split the message analysis view into Type Analysis and Time Analysis tabs, each with enriched statistical insight cards", + "Add a confirmation dialog before regenerating all session indexes to prevent accidental data loss", + "Expand demo data to 4 files covering group and multiple private chat scenarios", + "【CLI】Add unified chatlab start command with --headless and --no-open flags", + "【CLI】Add daemon mode via chatlab start --daemon to install as a system service, with stop and status commands" + ] + }, + { + "type": "fix", + "items": [ + "Fix multiple concurrency issues in message editing that could cause data loss or stale state", + "Fix thinking level and context window computation not using the active model ID", + "Fix reasoning detection failure for custom models with only chat capabilities by adding a heuristic fallback", + "Fix thinking being silently disabled for Kimi, Doubao, and similar models when the auto level is selected", + "【CLI】Fix the start command failing to launch the web development backend", + "【CLI】Fix daemon startup failure on Linux when the service path contains spaces" + ] + }, + { + "type": "refactor", + "items": [ + "Refactor the message branching system to an edit-and-regenerate model, supporting both current-round-only and overwrite-all modes", + "Remove the manual Reasoning Model and Disable Thinking toggles in favor of automatic inference from model capabilities", + "Simplify the model switcher button UI and improve session index loading performance" + ] + } + ] + }, + { + "version": "0.22.1", + "date": "2026-05-29", + "summary": "Adds session summary detail level settings, and fixes CLI Web batch summary freeze, AI config edit misidentification, and several API credential detection issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Add session summary detail level setting with \"Brief\" and \"Standard\" strategies, configurable in AI settings" + ] + }, + { + "type": "fix", + "items": [ + "Fix missing credential revalidation when changing Base URL in OpenAI-compatible mode", + "Fix incorrect API key detection logic to prevent stale key reuse", + "Fix stop button not responding immediately during batch summary generation", + "[CLI Web] Fix page freeze caused by batch summary generation", + "[CLI Web] Fix batch summary generation not honoring the selected session scope", + "[CLI Web] Fix third-party AI service misidentified as local when editing config" + ] + }, + { + "type": "refactor", + "items": ["Move session index i18n keys from the storage namespace to the ai namespace"] + }, + { + "type": "style", + "items": ["Improve chat record list density and message bubble styling"] + }, + { + "type": "docs", + "items": ["Restructure documentation site navigation and move Quick Start under the Usage section"] + } + ] + }, + { + "version": "0.22.0", + "date": "2026-05-26", + "summary": "This update improves the default UI styling, adds CLI Web update and storage management features, and strengthens home import, documentation, and multi-platform stability.", + "changes": [ + { + "type": "feat", + "items": [ + "Restructure the home import area into separate entry points, with new API import and auto-sync options", + "Bundle changelogs with the app to reduce runtime dependency on remote resources", + "【CLI Web】Add storage management in Web settings for viewing and managing data cache", + "【CLI】Add update checking and auto-update flow, with update checks exposed in CLI Web", + "【Docs】Add the standalone docs.chatlab.fun documentation site" + ] + }, + { + "type": "fix", + "items": [ + "Restore missing quick start buttons on the home page", + "Fix incorrect i18n key paths and deprecated ECharts api.style() usage", + "Harden self-update and migration retry safety checks", + "【Desktop】Prevent unified migration from reverting data directory changes", + "【CLI Web】Remove the broken Update Now action when a new version is detected", + "【CLI Web】Disable the unavailable Web self-update execution path", + "【CLI Web】Fix file manager actions that relied on shell execution, improving compatibility and safety", + "【CLI Web】Keep the Web service running after data directory warnings", + "【CLI Web】Add compatibility shims for merge-related APIs", + "【CLI】Improve update checks with async caching, better keypress handling, and a development-mode bypass" + ] + }, + { + "type": "refactor", + "items": [ + "Move documentation links to docs.chatlab.fun", + "【Settings】Move session index into AI settings and reorder the settings tabs" + ] + }, + { + "type": "style", + "items": ["Improve sidebar density and increase tab selector contrast in dark mode"] + }, + { + "type": "docs", + "items": ["Restructure the public documentation site and export guide"] + }, + { + "type": "chore", + "items": [ + "Migrate workspace packages to ESM", + "Isolate documentation site workspace dependencies", + "Move release changelogs out of the docs directory" + ] + } + ] + }, + { + "version": "0.21.1", + "date": "2026-05-23", + "summary": "Improve pull sync reliability and data safety, add an option to clean up imported chats when removing subscriptions, and fix UI animation and modal interaction issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Auto-generate session index after pull sync", + "Add option to delete imported chats when removing a subscription", + "【CLI Web】Support per-version screenshots in the changelog modal and make Markdown list fixes opt-in", + "【MCP】Add icon and i18n support for the ci change type" + ] + }, + { + "type": "fix", + "items": [ + "Fix potential data loss on small pages during pull sync and validate retry import results", + "Fix session index not auto-generating after pull sync completes", + "Fix sidebar session list not refreshing after pull completes or data is deleted", + "Fix inability to fetch all sessions when the remote server lacks pagination support", + "Improve pull sync retry logic and pagination strategy for better stability", + "Fix missing schema check in session existence validation that caused 'no such table' errors", + "Fix forced session index modal unexpectedly closing on generation failure", + "Fix session index modal blocking the page when sessions are empty", + "【Desktop】Fall back to the package.json version when app.getVersion returns 0.0.0", + "【CLI Web】Sync icon now spins in-place instead of showing a separate loader" + ] + }, + { + "type": "docs", + "items": ["Update Pull protocol docs to the since+nextSince pagination model"] + } + ] + }, + { + "version": "0.21.0", + "date": "2026-05-22", + "summary": "This update adds MCP support, unifies multi-platform import and service layers, and improves Web, AI, sync, and release stability.", + "changes": [ + { + "type": "feat", + "items": [ + "Support a unified folder import flow across Electron and Web mode for multi-file chat history formats", + "【MCP】Add a standalone command entry and integrate MCP settings into the settings page", + "【MCP】Expand the server to 19 tools with compact text and JSON output formats" + ] + }, + { + "type": "fix", + "items": [ + "Harden directory import path handling to reduce import failure risks", + "Fix a race condition that could occur when adding new subscriptions", + "Parse MiniMax streaming content correctly as thinking events", + "【CLI Web】Fix incremental import support", + "【CLI Web】Keep session not-found and member history behavior aligned with the desktop app", + "【CLI Web】Fix the Node runtime configuration for the development server", + "【MCP】Fix native module ABI binding mismatches during startup" + ] + }, + { + "type": "refactor", + "items": [ + "Unify the shared service layer for CLI Web and Electron to reduce duplicated route and IPC logic", + "【MCP】Trim the externally exposed tool registry to reduce tool schema cost for external AI agents", + "【MCP】Extract core MCP capabilities into a standalone shared package and simplify CLI and desktop integration", + "【MCP】Simplify settings page integration and reduce desktop-side helper complexity" + ] + }, + { + "type": "ci", + "items": ["【CLI】Publish the npm package as part of the release workflow"] + }, + { + "type": "docs", + "items": ["Document prefix and ordering rules for platform-specific changelog entries"] + }, + { + "type": "chore", + "items": ["【CLI】【MCP】Complete the configuration and release notes needed for npm publishing"] + } + ] + }, + { + "version": "0.20.0", + "date": "2026-05-19", + "summary": "This update unifies the core multi-platform architecture and improves AI, import, sync, and desktop build stability. It also prepares for the standalone Web, CLI, and MCP capabilities in the next release; please update before using the CLI so data can be pre-migrated.", + "changes": [ + { + "type": "feat", + "items": [ + "Add standalone CLI, HTTP API service, and MCP Server foundations for command-line use, Web, and AI agent integrations", + "Add a standalone Web build and one-command startup flow, including launching the Web UI from the CLI and opening the browser automatically", + "Support core Web-mode workflows, including chat import, demo import, session queries, member queries, search, and analysis", + "Connect Web mode to AI chat, model configuration, custom providers and models, context compression, and streamed event display", + "Upgrade imports to a shared streaming pipeline with multi-format parsing, incremental import, import analysis, and automatic session index generation", + "Add server-side capabilities for merge workflows, Markdown export, and session caching", + "Add the shared sync package and CLI automation support as groundwork for future multi-platform sync", + "Add backend persistence for preferences and automatically detect and migrate desktop data on first CLI run" + ] + }, + { + "type": "fix", + "items": [ + "Fix Web-mode issues around session indexes, CORS proxying, demo safeguards, and runtime errors", + "Fix the app version showing as empty in Web mode", + "Fix AI Agent evidence retrieval, Web event streaming, and error formatting issues", + "Fix inconsistencies between sync display and import logic", + "Fix ESM module resolution in CLI development mode", + "Fix cases where the CLI could not find existing desktop data after installation", + "Fix data migration path mismatches after extracting the Electron package", + "Fix packaged Worker crashes caused by an indirect dependency on the electron module", + "Fix Electron build, dependency installation, and better-sqlite3 native rebuild issues" + ] + }, + { + "type": "refactor", + "items": [ + "Move the project to a multi-platform workspace structure with apps/desktop, apps/cli, and shared packages", + "Extract parser, configuration, database adapters, queries, migrations, NLP, session cache, import, merge, export, and sync logic into shared modules", + "Extract AI Agent, tools, preprocessing, context compression, RAG, LLM configuration, assistant, and skill management into shared runtime capabilities", + "Unify AI tool naming, tool registration, and data access across Electron, CLI Web, and MCP", + "Unify core logic for data directories, message queries, member queries, session indexes, SQL execution, and import deduplication", + "Move the CLI from packages/server to apps/cli and complete the npm publishing build pipeline", + "Move frontend-only chart modules into src/features so packages only contain reusable shared libraries", + "Remove several Electron-side passthrough files and obsolete code to reduce duplicate implementations" + ] + }, + { + "type": "build", + "items": [ + "Upgrade Vite and related build tooling", + "Add CLI tsup builds, Web asset bundling, and npm publishing configuration", + "Adjust Electron desktop build settings and remove Linux desktop build targets" + ] + }, + { + "type": "docs", + "items": ["Document versioning, commit scope conventions, and related development notes"] + } + ] + }, + { + "version": "0.19.0", + "date": "2026-05-06", + "summary": "This update adds AI context auto-compression, introduces demo data for new users, and improves model configuration and debugging.", + "changes": [ + { + "type": "feat", + "items": [ + "Add model context window presets and custom configuration.", + "Add AI chat context auto-compression and related settings.", + "Improve the context compression flow and status display.", + "Add raw data viewing in debug mode and record full LLM context.", + "Improve AI model configuration copy, forms, and settings displays.", + "Remove vector model configuration to simplify related settings.", + "Let new users try demo data directly from the empty state.", + "Migrate the project structure to pnpm workspace." + ] + }, + { + "type": "fix", + "items": [ + "Fix the fast model follow-assistant mode using the wrong model in some cases.", + "Fix the demo button not appearing in the empty data state.", + "Fix a startup risk caused by undeclared axios imports in the main process." + ] + } + ] + }, + { + "version": "0.18.4", + "date": "2026-04-29", + "summary": "This update optimizes model stability, supports fetching remote model lists, improves AI error detail presentation, and refines some styles.", + "changes": [ + { + "type": "feat", + "items": [ + "Support fetching remote model lists.", + "Auto-complete `/v1` for OpenAI-compatible API addresses with real-time preview support.", + "Improve the presentation of AI chat error details.", + "Refine parts of the display styling." + ] + }, + { + "type": "fix", + "items": ["Fix some logic vulnerabilities."] + }, + { + "type": "chore", + "items": ["Optimize the logic of the sync-changelog skill."] + } + ] + }, + { + "version": "0.18.3", + "date": "2026-04-28", + "summary": "This release adds configurable quick tool entry placement, improves the default time filter, and fixes modal layering and data directory safety messaging.", + "changes": [ + { + "type": "feat", + "items": [ + "Add a setting for the placement of the quick tool entry.", + "Improve the default time filter experience.", + "Prevent data directories from being set inside the app installation directory." + ] + }, + { + "type": "fix", + "items": ["Fix modal style override issues.", "Fix modal layering issues in the settings page."] + } + ] + }, + { + "version": "0.18.2", + "date": "2026-04-26", + "summary": "This release adds session type filters for subscriptions, paginated remote session discovery, and configurable per-request message limits.", + "changes": [ + { + "type": "feat", + "items": [ + "Add session type filtering and selection when subscribing to sessions.", + "Add paginated remote session discovery with on-demand loading.", + "Allow each data source to configure how many messages are fetched per request." + ] + } + ] + }, + { + "version": "0.18.1", + "date": "2026-04-24", + "summary": "This release adds DeepSeek V4 support and a launch-at-startup option, improves the settings and overall UI experience, and fixes how links open in AI chat.
Starting with this release, the project is officially moving to the ChatLab organization.", + "changes": [ + { + "type": "feat", + "items": [ + "Move the project to the ChatLab organization.", + "Refine the global visual style.", + "Improve how quick prompts are presented.", + "Improve how the settings modal opens.", + "Add launch-at-startup support.", + "Add support for the DeepSeek V4 model." + ] + }, + { + "type": "fix", + "items": ["Fix links in AI chat so they open in the browser."] + } + ] + }, + { + "version": "0.18.0", + "date": "2026-04-23", + "summary": "This release improves AI chat interactions, consolidates several analysis entry points, and makes data source sync and Windows updates more reliable.", + "changes": [ + { + "type": "feat", + "items": [ + "Improve AI assistant interactions.", + "Update the helper text in the Add Data Source form.", + "Add a Mini mode to the Tool Panel.", + "Move the chat history viewer into the Tool Panel.", + "Unify the relationship analysis tabs.", + "Move the Quotes module into Insights.", + "Move keyword analysis into Labs." + ] + }, + { + "type": "fix", + "items": [ + "Fix existing type warnings.", + "Add a 60-second overlap window to Pull incremental sync to prevent missed messages.", + "Set Pull requests to `limit=1000` to avoid slowdowns when remote sources export too much data at once.", + "Fix a Windows update issue where an NSIS popup could interrupt silent installation." + ] + } + ] + }, + { + "version": "0.17.5", + "date": "2026-04-21", + "summary": "This release focuses on a broad set of bug fixes to improve overall stability.", + "changes": [ + { + "type": "feat", + "items": [ + "Refined the visual style of relationship cards.", + "Replaced the `node-machine-id` dependency with native machine identity logic to improve API key update reliability on Linux.", + "Added an option to keep original records when merging chat histories.", + "Added a verification button next to API endpoints for preset and third-party services." + ] + }, + { + "type": "fix", + "items": [ + "Hardened the data source migration strategy for safer migrations.", + "Fixed incorrect empty-state rendering for topics with very few messages.", + "Fixed local model validation failures.", + "Fixed an issue where the selected tab reset after switching conversations.", + "Fixed a white-screen issue in Automation pages after upgrading legacy `dataSources` structures." + ] + } + ] + }, + { + "version": "0.17.4", + "date": "2026-04-19", + "summary": "Implemented the full Import API v1 protocol and added hierarchical data source management, now supporting automatic chat history sync.", + "changes": [ + { + "type": "feat", + "items": ["Implement the full Import API v1 protocol with hierarchical data source management."] + } + ] + }, + { + "version": "0.17.3", + "date": "2026-04-17", + "summary": "This release adds a language preference tab for private chats, introduces sorting and filtering in the sidebar conversation list, improves AI provider and model configuration, and fixes a time filter reset issue.", + "changes": [ + { + "type": "feat", + "items": [ + "Add sorting and filtering to the conversation list.", + "Add a Language Preference tab for viewing language preferences.", + "Refine UI details for better visual consistency.", + "Add Anthropic as an AI provider option.", + "Allow selecting API interface types for third-party model services.", + "Allow custom display names for AI model configurations." + ] + }, + { + "type": "fix", + "items": [ + "Fix an issue where the time filter reset to \"All\" after returning from Settings or the AI Chat page." + ] + }, + { + "type": "refactor", + "items": ["Extract language preference definitions into shared types to reduce duplication."] + } + ] + }, + { + "version": "0.17.2", + "date": "2026-04-15", + "summary": "This update adds cross-platform data merge and member message merge, strengthens dictionary and update security checks, improves the dark theme experience and logging, and fixes several bugs.", + "changes": [ + { + "type": "feat", + "items": [ + "Support merging member messages in member management.", + "Support merging chat data across platforms.", + "Add sorting support to selected table columns in data management.", + "Move the topic analysis entry point to Insights.", + "Add original file path recording for AI log files.", + "Improve dark theme colors for better visual comfort." + ] + }, + { + "type": "fix", + "items": [ + "Fix dictionary refresh and merge ID collision issues.", + "Add runtime User-Agent headers for OpenAI-compatible requests.", + "Fix transparent background issues when exporting dark-theme screenshots.", + "Add SHA256 integrity verification for dictionary downloads.", + "Tighten remote config fetching and strengthen update installation confirmation." + ] + }, + { + "type": "chore", + "items": ["Add deb package build support for ARM Linux.", "Optimize the changelog sync flow."] + }, + { + "type": "docs", + "items": ["Add Traditional Chinese documentation."] + } + ] + }, + { + "version": "0.17.1", + "date": "2026-04-13", + "summary": "Refactored the Topics module with a new topic card view, improved word cloud keyword filtering and query caching, added remote tokenizer dictionary downloads with Traditional Chinese support, and improved WhatsApp detection.", + "changes": [ + { + "type": "feat", + "items": [ + "Refactor the Topics module and add a topic card view.", + "Add keyword filtering to the word cloud.", + "Support remote tokenizer dictionary downloads with Traditional Chinese dictionary included.", + "Improve query cache logic for faster lookups.", + "Standardize loading indicators across components.", + "Improve WhatsApp chat detection reliability." + ] + }, + { + "type": "ci", + "items": ["Launch the official documentation site with automated sync and deployment."] + } + ] + }, + { + "version": "0.17.0", + "date": "2026-04-12", + "summary": "This release strengthens WhatsApp import parsing and specified-format imports, while refreshing Overview cards and adding sharing, screenshots, and debugging utilities.", + "changes": [ + { + "type": "feat", + "items": [ + "Add a flexible WhatsApp V2 timestamp parser that adapts to export variants across regions.", + "Improve WhatsApp chat log detection.", + "Add specified-format import support.", + "Add a sharing card in the Messages tab.", + "Add quick debugging tools in DEBUG mode.", + "Improve the Overview identity card and unify time-range query logic.", + "Refactor Overview module cards and extract a theme color card with reserved palette modes.", + "Unify maximum card width and elevate Home tools into a global tools sidebar.", + "Add screenshot support for theme cards and disable mobile screenshot adaptation by default.", + "Remove diagnostic suggestions and add new prompts." + ] + }, + { + "type": "fix", + "items": [ + "Fix inconsistency between WhatsApp time-parsing regex rules and line-matching regex rules.", + "Fix compatibility issues when parsing WhatsApp 12-hour time formats and NNBSP characters." + ] + }, + { + "type": "chore", + "items": ["Cache electron and electron-builder binaries to speed up CI packaging."] + } + ] + }, + { + "version": "0.16.0", + "date": "2026-04-10", + "summary": "This update adds a new initiative analysis view for private chats and fixes missing custom models in the model-edit dialog.", + "changes": [ + { + "type": "feat", + "items": [ + "Add a new initiative analysis view for private chats.", + "Improve the footer presentation and interactions.", + "Refine the logic in the lower section of the quotes module." + ] + }, + { + "type": "fix", + "items": [ + "Fix an issue where multiple custom models could disappear in the third-party/local service edit dialog." + ] + } + ] + }, + { + "version": "0.15.0", + "date": "2026-04-08", + "summary": "This release significantly improves search and query performance with context-aware search, streamlines AI model configuration with added providers, and adds Linux platform support.", + "changes": [ + { + "type": "feat", + "items": [ + "Add query caching to speed up access.", + "Enable automatic context carry-over in search tools.", + "Refactor the model configuration flow.", + "Show a language selection dialog on first launch for new users.", + "Add basic debugging tools in the Lab.", + "Remove legacy prompts." + ] + }, + { + "type": "fix", + "items": [ + "Fix inconsistent title bar button background color in Windows light mode.", + "Fix CI packaging workflow alignment issues between Node 24 and pnpm.", + "Fill in missing i18n translations for tool invocation display names." + ] + }, + { + "type": "refactor", + "items": ["Improve the code organization of the AI configuration modal."] + }, + { + "type": "chore", + "items": ["Upgrade to Node 24.", "Add Linux packaging support."] + }, + { + "type": "docs", + "items": ["Update documentation."] + } + ] + }, + { + "version": "0.14.2", + "date": "2026-04-07", + "summary": "This update improves the AI chat experience with copy support, cleaner UI, new FTS5 full-text search tools, leaner search parameters, and clearer error feedback with stronger test coverage.", + "changes": [ + { + "type": "feat", + "items": [ + "Add a 7-day memory for assistant selection.", + "Add one-click message copying in AI chat.", + "Improve AI chat styling and overall interaction flow.", + "Add FTS5 full-text search support with a quick search tool.", + "Trim search parameters in selected tools to reduce token usage.", + "Add an E2E testing framework for Electron apps with port management and instance isolation." + ] + }, + { + "type": "fix", + "items": ["Improve AI chat error messages to make issues easier to diagnose."] + }, + { + "type": "refactor", + "items": [ + "Reorganize the AI chat module code structure.", + "Extract shared logic from the session analysis page and unify header copy." + ] + }, + { + "type": "test", + "items": ["Add reusable smoke-test coverage for the E2E app launcher."] + }, + { + "type": "docs", + "items": ["Update intro images in project documentation."] + }, + { + "type": "style", + "items": ["Standardize parts of the code formatting to improve readability."] + } + ] + }, + { + "version": "0.14.1", + "date": "2026-04-02", + "summary": "This update refines the Home information architecture and UI, while improving SQL conversation UX, stats read performance, and AI tool quality.", + "changes": [ + { + "type": "feat", + "items": [ + "Improve the Overview page styling.", + "Improve interaction flows in the SQL conversation module.", + "Move member management to Home and adjust related tab layouts.", + "Add new AI tools, including a tool for chat overview retrieval.", + "Add a conversation data cache manager to speed up stats loading.", + "Improve changelog modal type presentation." + ] + }, + { + "type": "fix", + "items": ["Fix silently swallowed AI errors in SQL Lab and summary generation."] + }, + { + "type": "refactor", + "items": ["Refactor AI tool categorization to improve maintainability."] + }, + { + "type": "chore", + "items": ["Deprecate low-value AI tools to keep the toolset focused."] + } + ] + }, + { + "version": "0.14.0", + "date": "2026-03-28", + "summary": "Add API import/export and preset prompts, improve Overview and settings flows, and fix message deduplication, AI conversation flow, and daily trend display.", + "changes": [ + { + "type": "feat", + "items": [ + "Add API import", + "Add API export", + "Let preset questions send immediately when selected", + "Add a Settings option for the default tab when opening a chat session", + "Improve the Overview page styling", + "Refine the overall UI and the API service settings screen", + "Improve identity cards and assistant selection interactions" + ] + }, + { + "type": "fix", + "items": [ + "Fix false positives in message deduplication and unify empty-string deduplication behavior", + "Fix AI conversation flow issues and frontend type-check errors", + "Add a fallback default assistant for edge cases", + "Fix daily message trends not rendering" + ] + }, + { + "type": "refactor", + "items": ["Clean up legacy typing issues across the parser, worker, RAG, and merger modules"] + }, + { + "type": "chore", + "items": ["Add a skill for generating assistant configurations"] + } + ] + }, + { + "version": "0.13.0", + "date": "2026-03-16", + "summary": "Assistant Mode is here with skills in chat, quick input actions, improved chat and settings UI, Traditional Chinese and Japanese support, UI refinements, and multiple stability fixes.", + "changes": [ + { + "type": "feat", + "items": [ + "Shipped the first Assistant Mode release with improved assistant logic and analysis tools", + "Launched the Assistant and Skill marketplaces; chats can now use skills", + "Added @-mention member selection for collaboration", + "Added Traditional Chinese and Japanese localization", + "Refactored Settings and refined UI details", + "Improved Overview styling and chat experience", + "Moved the export chat history entry point", + "Removed the legacy prompt system and custom AI filtering", + "Model calls no longer stop when switching pages" + ] + }, + { + "type": "fix", + "items": ["Fixed Gemini API configuration issues", "Fixed an error caused by stopword processing order in NLP"] + }, + { + "type": "refactor", + "items": ["Refactored AIChat organization", "Restructured directory and project layout"] + }, + { + "type": "docs", + "items": ["Updated the user agreement and project docs"] + }, + { + "type": "chore", + "items": ["Improved the changelog build pipeline"] + }, + { + "type": "style", + "items": ["Standardized code formatting and lint output"] + } + ] + }, + { + "version": "0.12.1", + "date": "2026-02-27", + "summary": "Add chat-history preprocessing and AI debugging, refactor the Agent/LLM architecture, and fix i18n and Windows theme consistency issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Add a chat-history preprocessing pipeline.", + "Add preprocessing settings UI and configuration management.", + "Add session-based context timelines and runtime status for the Agent.", + "Add an AI debug mode with improved log observability." + ] + }, + { + "type": "fix", + "items": [ + "Fix partial UI text not being localized under English settings.", + "Fix overlay color updates not matching the active theme on Windows." + ] + }, + { + "type": "refactor", + "items": [ + "Split the monolithic Agent implementation into a modular architecture.", + "Refactor the tool system to AgentTool + TypeBox and complete i18n support.", + "Unify the LLM access layer under the pi-ai implementation.", + "Refactor data-flow direction and IPC contracts, with corresponding frontend adaptation.", + "Introduce shared types and improve ChatStatusBar i18n.", + "Refactor parts of the chart stack into a plugin-based architecture." + ] + }, + { + "type": "chore", + "items": [ + "Remove the over-engineered sessionLog module.", + "Remove @ai-sdk dependencies and legacy LLM service implementations.", + "Temporarily hide the vector model configuration entry.", + "Update project description copy." + ] + }, + { + "type": "style", + "items": ["Run ESLint auto-fix to unify code style."] + } + ] + }, + { + "version": "0.11.2", + "date": "2026-02-15", + "summary": "Improve chat import workflows and management tools, while enhancing cross-platform parser compatibility.", + "changes": [ + { + "type": "feat", + "items": [ + "Improve parser compatibility for LINE and WhatsApp formats.", + "Improve the chat sniffing layer with polling detection and a fallback strategy.", + "Support Shift multi-select in the Manage page.", + "Show chat summary count and AI conversation count in the Manage page.", + "Optimize the main-page layout to provide more usable space.", + "Improve top-right window controls styling on Windows." + ] + }, + { + "type": "docs", + "items": ["Update project documentation."] + } + ] + }, + { + "version": "0.11.0", + "date": "2026-02-13", + "summary": "Add Telegram import, improve incremental import UX, strengthen i18n coverage, and fix indexing and page flicker issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Expand i18n support across AI calls, logs, and main-process configuration.", + "Add support for importing Telegram chat history.", + "Improve the incremental import flow and related copy.", + "Improve the interaction flow when opening protocol links." + ] + }, + { + "type": "fix", + "items": [ + "Fix index invalidation after incremental imports (resolve #81).", + "Fix WhatsApp iPhone-exported chats not being recognized (resolve #82).", + "Fix a double-flicker issue when switching to the chat page." + ] + }, + { + "type": "chore", + "items": [ + "Optimize TypeScript configuration.", + "Adjust i18n build configuration.", + "Improve skill-related project configuration." + ] + } + ] + }, + { + "version": "0.10.0", + "date": "2026-02-11", + "summary": "Add interaction frequency analysis, improve the session query pipeline, and fix issues in incremental indexing and database scanning.", + "changes": [ + { + "type": "feat", + "items": [ + "Add an interaction frequency analysis view to make member interaction trends easier to understand.", + "Improve session query logic and processing flow." + ] + }, + { + "type": "fix", + "items": [ + "Fix inaccurate session index generation scope after incremental updates (fix #79).", + "Fix non-chat SQLite files being incorrectly processed during migration and session scanning." + ] + }, + { + "type": "refactor", + "items": ["Refactor the session query module to improve maintainability."] + }, + { + "type": "chore", + "items": ["Remove transformers-related dependencies and update project configuration."] + } + ] + }, + { + "version": "0.9.4", + "date": "2026-02-08", + "summary": "Improved time filtering and AI configuration UX, added local API key encryption, and fixed LINE chat log parsing.", + "changes": [ + { + "type": "feat", + "items": [ + "Add more flexible time-filtering options.", + "Store API keys with local encryption.", + "Hide release notes for first-time users.", + "Improve the configuration status display in the AI chat footer.", + "Allow the app to restart immediately after data directory migration." + ] + }, + { + "type": "fix", + "items": ["Fix parsing issues for LINE chat logs."] + }, + { + "type": "docs", + "items": ["Update project documentation."] + } + ] + }, + { + "version": "0.9.3", + "date": "2026-02-03", + "summary": "Support custom data directories and fix many known issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Add a data directory location setting", + "Optimize data directory migration logic", + "Add a confirmation dialog for directory switching", + "Improve parser logic (WeFlow / Echotrace)" + ] + }, + { + "type": "fix", + "items": [ + "Fix crashes on Windows when custom filtering processes large message volumes", + "Fix conversations ending early when third-party relay APIs call tool_call", + "Fix some WhatsApp chat logs not being detected correctly", + "Fix manage page header stacking above settings" + ] + }, + { + "type": "refactor", + "items": ["Refactor session query module", "Improve migration logging"] + } + ] + }, + { + "version": "0.9.2", + "date": "2026-02-02", + "summary": "Rankings are now displayed as charts; word cloud generation and the local AI inference model are optimized; chat record filtering and the date picker are improved; and key routes are preloaded after launch.", + "changes": [ + { + "type": "feat", + "items": [ + "Refactor rankings to chart-based views", + "Optimize word cloud output", + "Optimize inference models", + "Improve linked search + filter in chat records", + "Enhance date picker interactions", + "Preload key routes after app launch" + ] + }, + { + "type": "chore", + "items": ["Modularize preload APIs", "Optimize analytics logic", "Upgrade ESLint and format code"] + } + ] + }, + { + "version": "0.9.1", + "date": "2026-01-30", + "summary": "Add LINE chat import, batch management, and chat search, plus fixes for known issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Add batch management with batch delete and merge", + "Support chat conversation search", + "Support LINE chat import", + "Compatible with WeFlow exported JSON format", + "Member list uses backend pagination", + "Improve some copy" + ] + }, + { + "type": "fix", + "items": ["Fix Windows app not closing during updates due to Worker occupation"] + } + ] + }, + { + "version": "0.9.0", + "date": "2026-01-28", + "summary": "Add NLP capabilities with a word cloud page under the Quotes tab; add a Views tab for more charts; support following system proxy settings; and refine some pages and styles.", + "changes": [ + { + "type": "feat", + "items": [ + "Optimize user selector performance with virtualized loading", + "Move rankings to the Views tab", + "Introduce tokenization and add a word cloud sub-tab", + "Improve group chat tab copy", + "Network proxy follows system proxy settings", + "Optimize release notes display logic" + ] + }, + { + "type": "style", + "items": ["Improve Markdown rendering styles"] + } + ] + }, + { + "version": "0.8.0", + "date": "2026-01-26", + "summary": "This update adds session summaries and vector retrieval; shows release notes after each update; improves parts of the UI; and fixes some known issues.", + "changes": [ + { + "type": "feat", + "items": [ + "Remove Help & Feedback from the sidebar", + "Add a footer on the home page with common links", + "Automatically open release notes after updating to a new version", + "Optimize batch session summary generation", + "Add session summaries in chat", + "Support vector model configuration and retrieval", + "Log more detailed errors when chat import fails" + ] + }, + { + "type": "fix", + "items": ["Fix shuakami-jsonl parsing error (fix #50)"] + } + ] + }, + { + "version": "0.7.0", + "date": "2026-01-23", + "summary": "Improve the AI chat experience, and refine update logic and charting.", + "changes": [ + { + "type": "feat", + "items": [ + "Improve update logic", + "Improve AI chat error logs", + "Quick model selection at the bottom of chat", + "Improve default prompts with a touch of humor", + "Replace chart.js with ECharts", + "Remove registration agreement logic" + ] + } + ] + }, + { + "version": "0.6.0", + "date": "2026-01-21", + "summary": "Integrate AI SDK to improve AI chat stability; add a thinking content block; and refine some styles", + "changes": [ + { + "type": "feat", + "items": [ + "Add a log locator feature", + "Integrate AI SDK", + "Add a thinking content block", + "Fix global modals being covered by the home page drag area", + "Improve top-right close button style on Windows" + ] + } + ] + }, + { + "version": "0.5.2", + "date": "2026-01-20", + "summary": "Support merged imports; fix several issues", + "changes": [ + { + "type": "feat", + "items": [ + "Support merged imports", + "Show chat log start/end time on the main panel", + "Improve the drag-and-drop area" + ] + }, + { + "type": "fix", + "items": [ + "Improve build config to fix macOS x64 compilation", + "Fix close button style in the message viewer on Windows", + "Require building on the target architecture for macOS packaging (fixes #36)" + ] + } + ] + }, + { + "version": "0.5.1", + "date": "2026-01-16", + "summary": "Fix several issues", + "changes": [ + { + "type": "feat", + "items": ["Improve copy"] + }, + { + "type": "fix", + "items": ["Fix app process not exiting on Windows when closing (#33)", "Fix number input bug (resolve #34)"] + } + ] + }, + { + "version": "0.5.0", + "date": "2026-01-14", + "summary": "Support Instagram chat import; add batch and incremental import", + "changes": [ + { + "type": "feat", + "items": [ + "Support Instagram chat import", + "Logic improvements", + "Improve system prompt presets", + "Support incremental import", + "Support batch import", + "Style improvements", + "Support native window controls and theme sync on Windows (#31)" + ] + }, + { + "type": "chore", + "items": ["Remove componenst.d.ts"] + } + ] + }, + { + "version": "0.4.1", + "date": "2026-01-13", + "summary": "This release focuses on style and interaction improvements, with no major new features", + "changes": [ + { + "type": "feat", + "items": [ + "Prompt preview support", + "Improve AI chat status bar", + "Improve table migration logic", + "Show avatars in the sidebar", + "Style improvements", + "Replace native window controls bar", + "Improve global background color", + "Clean up Worker on app exit" + ] + }, + { + "type": "fix", + "items": ["Fix theme-follow-system setting not working", "Fix update modal layout issues"] + } + ] + }, + { + "version": "0.4.0", + "date": "2026-01-12", + "summary": "Import now supports shuakami-jsonl; AI chat is optimized to save tokens; imports can generate session indexes and the viewer can jump by index; updates now support acceleration mirrors", + "changes": [ + { + "type": "feat", + "items": [ + "Compatibility with shuakami-jsonl", + "Improve loading state", + "Add custom filters", + "Refactor preset system with shared presets", + "Trim system prompts to save tokens", + "Add session-related function calling", + "Handle message jumps with context", + "Message viewer supports session index and quick jump", + "Refactor settings modal and add session index settings", + "Generate session index when importing chats", + "Refactor settings modal", + "Improve base component interactions", + "Improve home page styling", + "Improve update acceleration logic", + "Add acceleration mirrors" + ] + } + ] + }, + { + "version": "0.3.1", + "date": "2026-01-09", + "summary": "Add Discord import support; parsers now import reply messages; storage moves to a more standard location; role import is supported; import errors provide more detailed diagnostics; and various improvements", + "changes": [ + { + "type": "feat", + "items": [ + "Move table upgrades to the main process", + "Ignore beta versions during auto-update checks", + "Move data storage to userData", + "Parsers re-enable reply message import", + "Support platform message IDs and reply IDs with table migration", + "Support Tyrrrz/DiscordChatExporter import format", + "Support roles in the member table", + "Enhance ChatLab format detection", + "Align click import and drag import behaviors", + "Provide more detailed format diagnostics" + ] + }, + { + "type": "fix", + "items": ["Fix some users having empty platformId"] + } + ] + }, + { + "version": "0.3.0", + "date": "2026-01-08", + "summary": "Add English support and various improvements", + "changes": [ + { + "type": "feat", + "items": [ + "SQL Lab supports export", + "AI chat supports export", + "Finalize localization", + "Show explicit errors for AI model failures", + "SQL results can jump to the message viewer", + "Improve system prompts and support a prompt marketplace" + ] + } + ] + }, + { + "version": "0.2.0", + "date": "2025-12-29", + "summary": "Support proxy configuration; show error logs on import; improve some UI interactions; and add feature updates", + "changes": [ + { + "type": "feat", + "items": [ + "Message manager shows system messages", + "Improve import flow and show logs on errors", + "WhatsApp supports English-format message import", + "Support proxy configuration (resolve #7)", + "Improve AI model UI interactions", + "Add API tutorial for user configuration", + "Add two free GLM models; add Doubao provider and latest models", + "AI replies no longer output think content" + ] + } + ] + }, + { + "version": "0.1.3", + "date": "2025-12-25", + "summary": "Fix several issues", + "changes": [ + { + "type": "fix", + "items": ["Fix Echotrace parser errors"] + } + ] + }, + { + "version": "0.1.2", + "date": "2025-12-25", + "summary": "Add dark mode and allow passing user identity in system prompts during AI chats", + "changes": [ + { + "type": "feat", + "items": [ + "Allow passing user identity in system prompts during AI chats", + "Show Owner on the right in the message viewer", + "Support database upgrades", + "Allow Owner view in the Members tab", + "Support dark mode" + ] + }, + { + "type": "fix", + "items": ["Fix private chats misidentified as group chats"] + } + ] + }, + { + "version": "0.1.1", + "date": "2025-12-24", + "summary": "Support WhatsApp and legacy QQ chat analysis", + "changes": [ + { + "type": "feat", + "items": [ + "Show token usage at the bottom of chat sessions", + "Support native WhatsApp message format", + "Support legacy QQ txt group format" + ] + }, + { + "type": "fix", + "items": ["Fix message manager z-index being too low"] + } + ] + }, + { + "version": "0.1.0", + "date": "2025-12-23", + "summary": "Project launch", + "changes": [ + { + "type": "feat", + "items": ["Initial release"] + } + ] + } +] diff --git a/changelogs/en.md b/changelogs/en.md new file mode 100644 index 000000000..68cc67b59 --- /dev/null +++ b/changelogs/en.md @@ -0,0 +1,1427 @@ +# Changelog + +## v0.29.0 (2026-07-01) + +> Add Contacts, introduce the relationship galaxy, and improve contact computation, avatar loading, and sidebar performance. + +### ✨ Features + +- Add the People module with Contacts as a subpage, plus cross-session contact aggregation, time range filters, pagination, and virtual scrolling +- Let the Contacts page manually mark group members as friends, with entry points for source conversations and chat record viewing +- Add an interaction relationship galaxy with a 3D panorama, node filters, search, a detail panel, and related contacts/group exploration +- Add High Association and Friends Only filters to the relationship galaxy, and mask names in privacy mode +- [Desktop] Download app updates silently in the background + +### ⚡ Performance + +- Use pagination and virtual scrolling for contact lists to reduce render pressure with many friends and group members +- Lazy-load avatars and virtualize the sidebar session list to reduce startup and page switching stutter + +### 💄 Styles + +- Align the dark mode main background, relationship page header, and sidebar selected state + +## v0.28.1 (2026-06-26) + +> Improve chat record viewing, add the import API, and fix import, sync, and local model proxy download issues. + +### ✨ Features + +- Add the Push Import API with per-session append, deduplication, and incremental index generation +- Automatically generate session indexes after imports across CLI, Desktop, and pull sync paths +- Improve chat record viewer controls and highlight behavior +- Hide the sidebar scrollbar by default and show it on hover + +### 🐛 Bug Fixes + +- Harden Push/Pull import validation, deduplication, and concurrency locks to avoid invalid writes or duplicate imports +- Include lastPlatformMessageId and importedAt in GET /api/v1/sessions/:id for incremental import boundaries +- Refresh the session list automatically after background pull sync completes +- Fix local semantic index model downloads so they use the configured proxy correctly, and initialize worker logging +- [Desktop] Align the default API port with CLI at 3110 and try following ports automatically when it is occupied +- [Desktop] Resolve HTTP, HTTPS, and SOCKS system proxy results to avoid silently bypassing the proxy for local model downloads + +### ♻️ Refactoring + +- [CLI] Move pull/sync import paths to the shared streaming importer and remove the legacy parser/write path +- [Desktop] Remove redundant session index generation after incremental imports + +### 📝 Documentation + +- Fix API port references, examples, and documentation for unavailable capabilities + +## v0.28.0 (2026-06-25) + +> Add Google Chat import support, introduce a unified app logger with file rotation, and improve the semantic index experience. + +### ✨ Features + +- Add Google Chat import support across CLI and Desktop +- Add a unified app logger with automatic file rotation and global uncaught error capture +- Replace disable with remove in semantic index management; fix legacy hash and cap list height +- Preload semantic index model on config save and move it to the AI directory +- Simplify semantic index settings by removing the search result count option in favor of a built-in default + +### 🐛 Bug Fixes + +- Declare stream-json/stream-chain dependencies and fix multi-chat scan fallthrough + +### 📝 Documentation + +- Add Google Chat to the supported platforms documentation + +## v0.27.2 (2026-06-24) + +> Fix multiple auth profile lifecycle issues, improve analytics service reliability, and bundle missing local embedding runtime dependencies. + +### ✨ Features + +- [CLI Web] Unify daily active user reporting through a shared AnalyticsService + +### 🐛 Bug Fixes + +- Clean up the corresponding auth profile when removing an AI service config + +### ♻️ Refactoring + +- Remove outdated analytics settings migration + +## v0.27.1 (2026-06-23) + +> Add batched API embedding for faster index builds and lazy-loaded local models; fix semantic index resume-after-crash, sync cursor regression, and AI conversation export. + +### ✨ Features + +- Support batched embedding via API providers, significantly speeding up index builds +- Switch local embedding models to lazy worker initialization, reducing startup overhead + +### 🐛 Bug Fixes + +- Fix AI conversation export to correctly export currently visible AI conversations +- Fix semantic index batched-write resume: prevent unique-constraint errors and stuck sessions after a mid-batch crash +- Fix multiple semantic worker issues: config changes not forwarded to a running worker, availability probes causing failures in normal AI chats, and inaccurate active-build tracking leading to premature worker shutdown +- Fix sync pull cursor: initial empty-page server watermark was discarded before retries, causing repeated refetches of the tail window +- Fix sync pagination cursor regression that caused unnecessary duplicate imports + +### ♻️ Refactoring + +- Remove dead code and over-engineered abstractions; simplify the tool catalog structure and deep-merge logic + +## v0.27.0 (2026-06-22) + +> Introduce vector index and evidence retrieval: locate and present chat evidence via semantic search, with a new index management UI. + +### ✨ Features + +- Add a semantic index management UI with conversation picker and i18n support +- Add retrieve_chat_evidence tool: AI can retrieve time-anchored chat evidence via semantic search +- Planner automatically routes evidence-type questions to the semantic retrieval tool +- Evidence blocks support expand/collapse and click-through to the source message +- Semantic retrieval supports time-range filtering +- Redesign the semantic index model selection UI +- AI process segments are now collapsible +- Streamline evidence source rows and agent tool result display + +### 🐛 Bug Fixes + +- Fix multi-keyword message search +- Fix UX regression in word cloud dictionary loading +- Fix ranking label formatting and emoji cleanup +- Add missing i18n text for the v8 database migration +- [CLI] Fix missing session context forwarding and preprocessing in the tool adapter + +## v0.26.3 (2026-06-15) + +> Improve analytics performance with on-disk caching and stale request cancellation for smoother session switching; fix several issues in word cloud, top-word stats, and the timeline panel. + +### ✨ Features + +- [CLI Web] Add favicon + +### ⚡ Performance + +- Cache analytics results to disk keyed by database file version, significantly speeding up subsequent loads +- Automatically cancel stale analytics requests when switching sessions or changing filters +- Skip re-segmentation when changing the word cloud word count to reduce unnecessary computation + +### 🐛 Bug Fixes + +- Fix catchphrase stats including platform reply message placeholders +- Fix word cloud text including media placeholders +- Fix language-preference and word-frequency caches not being invalidated when the Jieba custom dictionary changes +- Fix time-sensitive analytics cache entries not expiring daily +- Fix timeline panel default scroll position and session jump not working +- Fix missing context for segmented messages +- Fix AI conversation count always returning 0 +- Fix the home page tutorial link not using the localized path +- Remove the screenshot button from the top of the AI chat panel +- [Desktop] Pre-bundle lazy-route dependencies to avoid 504 errors in dev mode +- [CLI] Fix false update notifications caused by mismatched package version +- [CLI] Support arrow key selection in the update prompt + +### ♻️ Refactoring + +- Rename the timeline panel label to 'Summary' + +## v0.26.2 (2026-06-13) + +> Improve the 'Who am I' session identity feature with manual owner profile selection and automatic batch-fill across same-platform sessions; fix several chart rendering and AI cache issues. + +### ✨ Features + +- Add 'Who am I' session identity feature: manually confirm your identity to save a platform owner profile and automatically match it across other unowned sessions on the same platform +- Automatically apply the saved owner profile after import or pull sync, reducing manual setup + +### 🐛 Bug Fixes + +- Fix ownerId cache inconsistency when batch-filling sessions on name-match platforms (WhatsApp / Line / Instagram) +- Fix owner profile being overwritten by stale values when multiple routes share independent PreferencesManager instances +- [CLI] Fix stale preferences cache in the sync module causing newly imported sessions to miss the owner profile during long-running server sessions +- Fix temporary files not being fully deleted after import +- Fix chart flickering during AI streaming generation +- Fix unstable render order in AI-generated charts +- Fix empty placeholder rows appearing in AI-generated charts +- Add progress indicator during AI chart generation +- Fix charts appearing incomplete in screenshots due to missing resize step before capture +- Clarify that last_message_time represents data coverage end time, not the group's last activity time +- Fix stale overview and analysis caches not being invalidated after incremental import + +## v0.26.1 (2026-06-10) + +> Fix several tool-call history replay issues and complete tool-call persistence and replay support; add Token cache usage stats to the status bar and support multiple export formats. + +### ✨ Features + +- Add Token cache hit/miss usage display to the chat status bar +- Support TXT, JSON, and Markdown export formats + +### 🐛 Bug Fixes + +- Persist and replay tool calls across conversation turns to maintain multi-turn AI context +- Fix tool-only assistant turns being filtered out during history replay, causing tool results to be lost +- Fix repeated exports of the same session silently overwriting the previous file due to a deterministic filename +- Desktop: Fix large session exports failing prematurely due to the 60-second IPC timeout +- Fix DeepSeek tool-call turns not replaying `reasoning_content` in history +- Fix replayed tool results not being counted correctly in history compression token accounting +- Fix raw message data leaking into the tool result text sent to the model +- Fix the tool panel not closing when clicking outside + +### ♻️ Refactoring + +- Extract the session page header into a shared component + +## v0.26.0 (2026-06-10) + +> Add an AI analysis planner with streaming structured plan generation and chart planning integration; improve chart mode tool availability and fix several AI and import issues. + +### ✨ Features + +- Add an AI analysis planner with streaming structured plan generation and block rendering +- Integrate chart planning support so AI analysis plans can automatically transition into chart generation +- Provide the planner with startup context and extended data snapshots for deeper, higher-quality analysis +- Prefer high-level tools over raw SQL when rendering charts to reduce permission requirements +- Improve overall AI analysis and chart behavior +- Add a chart auto-mode preference setting +- Add copy-message-ID actions +- Add LLM fallback for shadow routing and log routing decisions +- CLI: Expose full agent stream events + +### 🐛 Bug Fixes + +- Fix the `render_chart` tool being dropped when a chart skill is explicitly selected +- Fix tool availability gaps in explicit chart mode and plan status after errors +- Migrate ECharts 6 `containLabel` configuration and hide redundant pie chart legends +- Fix settings page navigation order, `skillSettings` key naming, and SubTabs line-wrapping +- Fix assistant messages not filling the full chat area width +- Improve the visual layout of the analysis plan generation block +- Fix thinking-level selection not persisting across restarts +- Fix JSONL timestamp normalization causing incremental import failures +- Desktop: Fix `execute_sql` tool not being registered in the desktop tool registry + +### test + +- Add an evaluation set for agent routing decisions + +## v0.25.1 (2026-06-08) + +> Introduce a data directory compatibility gate to prevent older runtimes from writing to upgraded data directories, and add a CLI `ai chat` command. + +### ✨ Features + +- Add a data directory compatibility gate: after writing v6 schema data, record the minimum required runtime version to prevent older runtimes from corrupting upgraded directories +- Verify data directory compatibility before each database operation and reject access when the version requirement is not met +- Desktop: Check data directory compatibility at startup and show an error dialog before quitting if the requirement is not met +- CLI: Check data directory compatibility at startup and exit immediately if the requirement is not met +- CLI: Add the `ai chat` command for multi-turn AI conversations directly in the terminal + +### 🐛 Bug Fixes + +- Use the packaged app version (not Electron's dev-mode 0.0.0) when writing the compatibility gate, and require a valid runtime identity +- Write the compatibility gate after database migrations and imports complete; abort startup migrations immediately on failure instead of failing silently +- MCP: Check data directory compatibility at startup and exit early to prevent writing incompatible data +- Desktop: Map compatibility gate errors to 409 responses in the HTTP API so the frontend can show an upgrade prompt +- CLI: Normalize AI tool names and fix edge cases in the `ai chat` command + +### ♻️ Refactoring + +- Align AI chat and segment identifier naming + +### test + +- Add test coverage for the parser, config migration, database migration, HTTP routes, and AI tools + +### 📝 Documentation + +- Add public documentation for the data directory compatibility gate, explaining version restrictions and upgrade rules when sharing a data directory + +## v0.25.0 (2026-06-07) + +> AI chat can now generate charts through skills, with fixes for tool rounds, the desktop title bar, and the development service lifecycle. + +### ✨ Features + +- Add a slash-activated AI chart runtime that can generate and render ECharts charts in conversations +- Improve the AI chart result experience with clearer rendering states and support for more chart types + +### 🐛 Bug Fixes + +- Increase the default AI Agent tool round limit to reduce early stops in complex tasks +- Fix preset question chips and sidebar elements stacking incorrectly +- CLI Web: Fix incomplete cleanup of the development backend lifecycle +- Desktop: Register the chart tool for automatic skills so chart skills can run correctly +- Desktop: Refresh the Windows title bar overlay cache after theme changes and smooth its display + +### ♻️ Refactoring + +- Centralize the AI chart runtime policy and unify chart tool enablement across CLI and desktop +- Deduplicate skill menu and Skill Manager logic + +### 📝 Documentation + +- Update the iMessage chat export guide + +### 🔧 Chores + +- Move maintainer-only skills out of the public repository into private maintenance context + +## v0.24.1 (2026-06-04) + +> Adds in-app update notices and default AI preprocessing rules, with fixes for update badges and desensitization settings. + +### ✨ Features + +- Add an in-app update notice entry so the sidebar can surface the latest release details +- Add default AI preprocessing settings for data cleaning, denoising, and desensitization +- Support grouped AI desensitization rules to make built-in and custom rules easier to manage + +### 🐛 Bug Fixes + +- Fix incorrect New badges caused by failed update checks, stale update caches, and CLI Web development placeholder versions +- Fix built-in desensitization rules not being applied before AI preprocessing runs +- Fix empty desensitization preference overrides not clearing previously saved built-in rule switches +- Fix legacy built-in desensitization rule overrides being lost during migration + +### 📝 Documentation + +- Add a public development guide covering local setup, directory responsibilities, and collaboration conventions + +### 👷 CI + +- Add Markdown changelog links to the release workflow + +## v0.24.0 (2026-06-03) + +> Adds CLI Web authentication and data directory migration, unifies cross-platform HTTP routes, and fixes data migration, AI settings, and import refresh issues. + +### ✨ Features + +- Add Markdown generation for multilingual release notes +- Allow legacy data migration prompts to be ignored from storage management +- [CLI Web] Add a login page, token authentication, and persistent login state +- [CLI Web] Support data directory migration from Web settings +- [CLI] Add the --require-auth flag to protect /\_web/\* API access +- [Desktop] Add an internal HTTP service so the frontend can use shared service adapters + +### 🐛 Bug Fixes + +- Fix multiple edge cases in data directory and database migrations to prevent missing legacy columns or failed reads after path changes +- Fix legacy data migration prompts appearing incorrectly when no legacy data exists or after directory changes +- Fix sidebar message counts not refreshing after incremental imports +- Fix sidebar collapsed state being lost after refresh because it was stored only in sessionStorage +- Fix fetch-models and validate buttons not being enabled when editing AI settings with a saved key +- Fix stability issues in shared AI SSE streaming +- Fix custom data source creation not requiring a token +- [Desktop] Move chart plugin computation to a worker to avoid blocking the main thread + +### ♻️ Refactoring + +- Extract the @openchatlab/http-routes shared package to unify HTTP route implementations across CLI Web and Desktop +- Move AI settings, assistants, skills, conversations, streaming responses, cache, and merge APIs to shared HTTP routes +- Trim the desktop IPC bridge by removing legacy AI, session index, LLM, Assistant, Skill, and NLP compatibility handlers +- Unify the frontend service layer to reduce Electron/Web mode branching + +### ⚡ Performance + +- Minify the main process bundle and lazy-load the tiktoken rank table to reduce startup and package size pressure + +### 👷 CI + +- Fix the Windows release workflow zstd cache issue and add CLI update notes to release notes + +### 🔧 Chores + +- Align Node type-check projects to cover desktop and shared Node code + +## v0.23.1 (2026-06-01) + +> Adds a clb command alias and port conflict detection with actionable guidance. Fixes daemon silent exit and several UI issues in dark mode. + +### ✨ Features + +- Improve the page header and toolbar layout +- [CLI] Detect port conflicts before startup and display clear guidance (switch port or run lsof) instead of a delayed EADDRINUSE error +- [CLI] Add clb as a short alias for the chatlab command + +### 🐛 Bug Fixes + +- Fix sidebar tooltip misplacement and Nuxt UI v4 API compatibility +- Fix red background and incorrect z-index layering in the title bar under dark mode +- Tighten AI message role parameter types and strengthen conversation test assertions +- [CLI] Fix daemon entry point error that caused the service to exit silently after startup +- [CLI] Improve error handling and messaging for port conflict detection +- [CLI] Fix missing chatlab.fun reverse proxy route in web mode + +## v0.23.0 (2026-05-31) + +> Overhauls message editing with a fork/regenerate model, adds per-model thinking level controls, unifies the CLI entry point, and fixes multiple reasoning detection and computation issues. + +### ✨ Features + +- Add a Fork button to AI replies to branch the conversation from any point into an independent session +- Add a per-model thinking level selector in the status bar, with the choice remembered per model slot +- Extend thinking level controls with default and auto options, covering Kimi, Doubao, Gemini, and more model families +- Split the message analysis view into Type Analysis and Time Analysis tabs, each with enriched statistical insight cards +- Add a confirmation dialog before regenerating all session indexes to prevent accidental data loss +- Expand demo data to 4 files covering group and multiple private chat scenarios +- 【CLI】Add unified chatlab start command with --headless and --no-open flags +- 【CLI】Add daemon mode via chatlab start --daemon to install as a system service, with stop and status commands + +### 🐛 Bug Fixes + +- Fix multiple concurrency issues in message editing that could cause data loss or stale state +- Fix thinking level and context window computation not using the active model ID +- Fix reasoning detection failure for custom models with only chat capabilities by adding a heuristic fallback +- Fix thinking being silently disabled for Kimi, Doubao, and similar models when the auto level is selected +- 【CLI】Fix the start command failing to launch the web development backend +- 【CLI】Fix daemon startup failure on Linux when the service path contains spaces + +### ♻️ Refactoring + +- Refactor the message branching system to an edit-and-regenerate model, supporting both current-round-only and overwrite-all modes +- Remove the manual Reasoning Model and Disable Thinking toggles in favor of automatic inference from model capabilities +- Simplify the model switcher button UI and improve session index loading performance + +## v0.22.1 (2026-05-29) + +> Adds session summary detail level settings, and fixes CLI Web batch summary freeze, AI config edit misidentification, and several API credential detection issues. + +### ✨ Features + +- Add session summary detail level setting with "Brief" and "Standard" strategies, configurable in AI settings + +### 🐛 Bug Fixes + +- Fix missing credential revalidation when changing Base URL in OpenAI-compatible mode +- Fix incorrect API key detection logic to prevent stale key reuse +- Fix stop button not responding immediately during batch summary generation +- [CLI Web] Fix page freeze caused by batch summary generation +- [CLI Web] Fix batch summary generation not honoring the selected session scope +- [CLI Web] Fix third-party AI service misidentified as local when editing config + +### ♻️ Refactoring + +- Move session index i18n keys from the storage namespace to the ai namespace + +### 💄 Styles + +- Improve chat record list density and message bubble styling + +### 📝 Documentation + +- Restructure documentation site navigation and move Quick Start under the Usage section + +## v0.22.0 (2026-05-26) + +> This update improves the default UI styling, adds CLI Web update and storage management features, and strengthens home import, documentation, and multi-platform stability. + +### ✨ Features + +- Restructure the home import area into separate entry points, with new API import and auto-sync options +- Bundle changelogs with the app to reduce runtime dependency on remote resources +- 【CLI Web】Add storage management in Web settings for viewing and managing data cache +- 【CLI】Add update checking and auto-update flow, with update checks exposed in CLI Web +- 【Docs】Add the standalone docs.chatlab.fun documentation site + +### 🐛 Bug Fixes + +- Restore missing quick start buttons on the home page +- Fix incorrect i18n key paths and deprecated ECharts api.style() usage +- Harden self-update and migration retry safety checks +- 【Desktop】Prevent unified migration from reverting data directory changes +- 【CLI Web】Remove the broken Update Now action when a new version is detected +- 【CLI Web】Disable the unavailable Web self-update execution path +- 【CLI Web】Fix file manager actions that relied on shell execution, improving compatibility and safety +- 【CLI Web】Keep the Web service running after data directory warnings +- 【CLI Web】Add compatibility shims for merge-related APIs +- 【CLI】Improve update checks with async caching, better keypress handling, and a development-mode bypass + +### ♻️ Refactoring + +- Move documentation links to docs.chatlab.fun +- 【Settings】Move session index into AI settings and reorder the settings tabs + +### 💄 Styles + +- Improve sidebar density and increase tab selector contrast in dark mode + +### 📝 Documentation + +- Restructure the public documentation site and export guide + +### 🔧 Chores + +- Migrate workspace packages to ESM +- Isolate documentation site workspace dependencies +- Move release changelogs out of the docs directory + +## v0.21.1 (2026-05-23) + +> Improve pull sync reliability and data safety, add an option to clean up imported chats when removing subscriptions, and fix UI animation and modal interaction issues. + +### ✨ Features + +- Auto-generate session index after pull sync +- Add option to delete imported chats when removing a subscription +- 【CLI Web】Support per-version screenshots in the changelog modal and make Markdown list fixes opt-in +- 【MCP】Add icon and i18n support for the ci change type + +### 🐛 Bug Fixes + +- Fix potential data loss on small pages during pull sync and validate retry import results +- Fix session index not auto-generating after pull sync completes +- Fix sidebar session list not refreshing after pull completes or data is deleted +- Fix inability to fetch all sessions when the remote server lacks pagination support +- Improve pull sync retry logic and pagination strategy for better stability +- Fix missing schema check in session existence validation that caused 'no such table' errors +- Fix forced session index modal unexpectedly closing on generation failure +- Fix session index modal blocking the page when sessions are empty +- 【Desktop】Fall back to the package.json version when app.getVersion returns 0.0.0 +- 【CLI Web】Sync icon now spins in-place instead of showing a separate loader + +### 📝 Documentation + +- Update Pull protocol docs to the since+nextSince pagination model + +## v0.21.0 (2026-05-22) + +> This update adds MCP support, unifies multi-platform import and service layers, and improves Web, AI, sync, and release stability. + +### ✨ Features + +- Support a unified folder import flow across Electron and Web mode for multi-file chat history formats +- 【MCP】Add a standalone command entry and integrate MCP settings into the settings page +- 【MCP】Expand the server to 19 tools with compact text and JSON output formats + +### 🐛 Bug Fixes + +- Harden directory import path handling to reduce import failure risks +- Fix a race condition that could occur when adding new subscriptions +- Parse MiniMax streaming content correctly as thinking events +- 【CLI Web】Fix incremental import support +- 【CLI Web】Keep session not-found and member history behavior aligned with the desktop app +- 【CLI Web】Fix the Node runtime configuration for the development server +- 【MCP】Fix native module ABI binding mismatches during startup + +### ♻️ Refactoring + +- Unify the shared service layer for CLI Web and Electron to reduce duplicated route and IPC logic +- 【MCP】Trim the externally exposed tool registry to reduce tool schema cost for external AI agents +- 【MCP】Extract core MCP capabilities into a standalone shared package and simplify CLI and desktop integration +- 【MCP】Simplify settings page integration and reduce desktop-side helper complexity + +### 👷 CI + +- 【CLI】Publish the npm package as part of the release workflow + +### 📝 Documentation + +- Document prefix and ordering rules for platform-specific changelog entries + +### 🔧 Chores + +- 【CLI】【MCP】Complete the configuration and release notes needed for npm publishing + +## v0.20.0 (2026-05-19) + +> This update unifies the core multi-platform architecture and improves AI, import, sync, and desktop build stability. It also prepares for the standalone Web, CLI, and MCP capabilities in the next release; please update before using the CLI so data can be pre-migrated. + +### ✨ Features + +- Add standalone CLI, HTTP API service, and MCP Server foundations for command-line use, Web, and AI agent integrations +- Add a standalone Web build and one-command startup flow, including launching the Web UI from the CLI and opening the browser automatically +- Support core Web-mode workflows, including chat import, demo import, session queries, member queries, search, and analysis +- Connect Web mode to AI chat, model configuration, custom providers and models, context compression, and streamed event display +- Upgrade imports to a shared streaming pipeline with multi-format parsing, incremental import, import analysis, and automatic session index generation +- Add server-side capabilities for merge workflows, Markdown export, and session caching +- Add the shared sync package and CLI automation support as groundwork for future multi-platform sync +- Add backend persistence for preferences and automatically detect and migrate desktop data on first CLI run + +### 🐛 Bug Fixes + +- Fix Web-mode issues around session indexes, CORS proxying, demo safeguards, and runtime errors +- Fix the app version showing as empty in Web mode +- Fix AI Agent evidence retrieval, Web event streaming, and error formatting issues +- Fix inconsistencies between sync display and import logic +- Fix ESM module resolution in CLI development mode +- Fix cases where the CLI could not find existing desktop data after installation +- Fix data migration path mismatches after extracting the Electron package +- Fix packaged Worker crashes caused by an indirect dependency on the electron module +- Fix Electron build, dependency installation, and better-sqlite3 native rebuild issues + +### ♻️ Refactoring + +- Move the project to a multi-platform workspace structure with apps/desktop, apps/cli, and shared packages +- Extract parser, configuration, database adapters, queries, migrations, NLP, session cache, import, merge, export, and sync logic into shared modules +- Extract AI Agent, tools, preprocessing, context compression, RAG, LLM configuration, assistant, and skill management into shared runtime capabilities +- Unify AI tool naming, tool registration, and data access across Electron, CLI Web, and MCP +- Unify core logic for data directories, message queries, member queries, session indexes, SQL execution, and import deduplication +- Move the CLI from packages/server to apps/cli and complete the npm publishing build pipeline +- Move frontend-only chart modules into src/features so packages only contain reusable shared libraries +- Remove several Electron-side passthrough files and obsolete code to reduce duplicate implementations + +### build + +- Upgrade Vite and related build tooling +- Add CLI tsup builds, Web asset bundling, and npm publishing configuration +- Adjust Electron desktop build settings and remove Linux desktop build targets + +### 📝 Documentation + +- Document versioning, commit scope conventions, and related development notes + +## v0.19.0 (2026-05-06) + +> This update adds AI context auto-compression, introduces demo data for new users, and improves model configuration and debugging. + +### ✨ Features + +- Add model context window presets and custom configuration. +- Add AI chat context auto-compression and related settings. +- Improve the context compression flow and status display. +- Add raw data viewing in debug mode and record full LLM context. +- Improve AI model configuration copy, forms, and settings displays. +- Remove vector model configuration to simplify related settings. +- Let new users try demo data directly from the empty state. +- Migrate the project structure to pnpm workspace. + +### 🐛 Bug Fixes + +- Fix the fast model follow-assistant mode using the wrong model in some cases. +- Fix the demo button not appearing in the empty data state. +- Fix a startup risk caused by undeclared axios imports in the main process. + +## v0.18.4 (2026-04-29) + +> This update optimizes model stability, supports fetching remote model lists, improves AI error detail presentation, and refines some styles. + +### ✨ Features + +- Support fetching remote model lists. +- Auto-complete `/v1` for OpenAI-compatible API addresses with real-time preview support. +- Improve the presentation of AI chat error details. +- Refine parts of the display styling. + +### 🐛 Bug Fixes + +- Fix some logic vulnerabilities. + +### 🔧 Chores + +- Optimize the logic of the sync-changelog skill. + +## v0.18.3 (2026-04-28) + +> This release adds configurable quick tool entry placement, improves the default time filter, and fixes modal layering and data directory safety messaging. + +### ✨ Features + +- Add a setting for the placement of the quick tool entry. +- Improve the default time filter experience. +- Prevent data directories from being set inside the app installation directory. + +### 🐛 Bug Fixes + +- Fix modal style override issues. +- Fix modal layering issues in the settings page. + +## v0.18.2 (2026-04-26) + +> This release adds session type filters for subscriptions, paginated remote session discovery, and configurable per-request message limits. + +### ✨ Features + +- Add session type filtering and selection when subscribing to sessions. +- Add paginated remote session discovery with on-demand loading. +- Allow each data source to configure how many messages are fetched per request. + +## v0.18.1 (2026-04-24) + +> This release adds DeepSeek V4 support and a launch-at-startup option, improves the settings and overall UI experience, and fixes how links open in AI chat.
Starting with this release, the project is officially moving to the ChatLab organization. + +### ✨ Features + +- Move the project to the ChatLab organization. +- Refine the global visual style. +- Improve how quick prompts are presented. +- Improve how the settings modal opens. +- Add launch-at-startup support. +- Add support for the DeepSeek V4 model. + +### 🐛 Bug Fixes + +- Fix links in AI chat so they open in the browser. + +## v0.18.0 (2026-04-23) + +> This release improves AI chat interactions, consolidates several analysis entry points, and makes data source sync and Windows updates more reliable. + +### ✨ Features + +- Improve AI assistant interactions. +- Update the helper text in the Add Data Source form. +- Add a Mini mode to the Tool Panel. +- Move the chat history viewer into the Tool Panel. +- Unify the relationship analysis tabs. +- Move the Quotes module into Insights. +- Move keyword analysis into Labs. + +### 🐛 Bug Fixes + +- Fix existing type warnings. +- Add a 60-second overlap window to Pull incremental sync to prevent missed messages. +- Set Pull requests to `limit=1000` to avoid slowdowns when remote sources export too much data at once. +- Fix a Windows update issue where an NSIS popup could interrupt silent installation. + +## v0.17.5 (2026-04-21) + +> This release focuses on a broad set of bug fixes to improve overall stability. + +### ✨ Features + +- Refined the visual style of relationship cards. +- Replaced the `node-machine-id` dependency with native machine identity logic to improve API key update reliability on Linux. +- Added an option to keep original records when merging chat histories. +- Added a verification button next to API endpoints for preset and third-party services. + +### 🐛 Bug Fixes + +- Hardened the data source migration strategy for safer migrations. +- Fixed incorrect empty-state rendering for topics with very few messages. +- Fixed local model validation failures. +- Fixed an issue where the selected tab reset after switching conversations. +- Fixed a white-screen issue in Automation pages after upgrading legacy `dataSources` structures. + +## v0.17.4 (2026-04-19) + +> Implemented the full Import API v1 protocol and added hierarchical data source management, now supporting automatic chat history sync. + +### ✨ Features + +- Implement the full Import API v1 protocol with hierarchical data source management. + +## v0.17.3 (2026-04-17) + +> This release adds a language preference tab for private chats, introduces sorting and filtering in the sidebar conversation list, improves AI provider and model configuration, and fixes a time filter reset issue. + +### ✨ Features + +- Add sorting and filtering to the conversation list. +- Add a Language Preference tab for viewing language preferences. +- Refine UI details for better visual consistency. +- Add Anthropic as an AI provider option. +- Allow selecting API interface types for third-party model services. +- Allow custom display names for AI model configurations. + +### 🐛 Bug Fixes + +- Fix an issue where the time filter reset to "All" after returning from Settings or the AI Chat page. + +### ♻️ Refactoring + +- Extract language preference definitions into shared types to reduce duplication. + +## v0.17.2 (2026-04-15) + +> This update adds cross-platform data merge and member message merge, strengthens dictionary and update security checks, improves the dark theme experience and logging, and fixes several bugs. + +### ✨ Features + +- Support merging member messages in member management. +- Support merging chat data across platforms. +- Add sorting support to selected table columns in data management. +- Move the topic analysis entry point to Insights. +- Add original file path recording for AI log files. +- Improve dark theme colors for better visual comfort. + +### 🐛 Bug Fixes + +- Fix dictionary refresh and merge ID collision issues. +- Add runtime User-Agent headers for OpenAI-compatible requests. +- Fix transparent background issues when exporting dark-theme screenshots. +- Add SHA256 integrity verification for dictionary downloads. +- Tighten remote config fetching and strengthen update installation confirmation. + +### 🔧 Chores + +- Add deb package build support for ARM Linux. +- Optimize the changelog sync flow. + +### 📝 Documentation + +- Add Traditional Chinese documentation. + +## v0.17.1 (2026-04-13) + +> Refactored the Topics module with a new topic card view, improved word cloud keyword filtering and query caching, added remote tokenizer dictionary downloads with Traditional Chinese support, and improved WhatsApp detection. + +### ✨ Features + +- Refactor the Topics module and add a topic card view. +- Add keyword filtering to the word cloud. +- Support remote tokenizer dictionary downloads with Traditional Chinese dictionary included. +- Improve query cache logic for faster lookups. +- Standardize loading indicators across components. +- Improve WhatsApp chat detection reliability. + +### 👷 CI + +- Launch the official documentation site with automated sync and deployment. + +## v0.17.0 (2026-04-12) + +> This release strengthens WhatsApp import parsing and specified-format imports, while refreshing Overview cards and adding sharing, screenshots, and debugging utilities. + +### ✨ Features + +- Add a flexible WhatsApp V2 timestamp parser that adapts to export variants across regions. +- Improve WhatsApp chat log detection. +- Add specified-format import support. +- Add a sharing card in the Messages tab. +- Add quick debugging tools in DEBUG mode. +- Improve the Overview identity card and unify time-range query logic. +- Refactor Overview module cards and extract a theme color card with reserved palette modes. +- Unify maximum card width and elevate Home tools into a global tools sidebar. +- Add screenshot support for theme cards and disable mobile screenshot adaptation by default. +- Remove diagnostic suggestions and add new prompts. + +### 🐛 Bug Fixes + +- Fix inconsistency between WhatsApp time-parsing regex rules and line-matching regex rules. +- Fix compatibility issues when parsing WhatsApp 12-hour time formats and NNBSP characters. + +### 🔧 Chores + +- Cache electron and electron-builder binaries to speed up CI packaging. + +## v0.16.0 (2026-04-10) + +> This update adds a new initiative analysis view for private chats and fixes missing custom models in the model-edit dialog. + +### ✨ Features + +- Add a new initiative analysis view for private chats. +- Improve the footer presentation and interactions. +- Refine the logic in the lower section of the quotes module. + +### 🐛 Bug Fixes + +- Fix an issue where multiple custom models could disappear in the third-party/local service edit dialog. + +## v0.15.0 (2026-04-08) + +> This release significantly improves search and query performance with context-aware search, streamlines AI model configuration with added providers, and adds Linux platform support. + +### ✨ Features + +- Add query caching to speed up access. +- Enable automatic context carry-over in search tools. +- Refactor the model configuration flow. +- Show a language selection dialog on first launch for new users. +- Add basic debugging tools in the Lab. +- Remove legacy prompts. + +### 🐛 Bug Fixes + +- Fix inconsistent title bar button background color in Windows light mode. +- Fix CI packaging workflow alignment issues between Node 24 and pnpm. +- Fill in missing i18n translations for tool invocation display names. + +### ♻️ Refactoring + +- Improve the code organization of the AI configuration modal. + +### 🔧 Chores + +- Upgrade to Node 24. +- Add Linux packaging support. + +### 📝 Documentation + +- Update documentation. + +## v0.14.2 (2026-04-07) + +> This update improves the AI chat experience with copy support, cleaner UI, new FTS5 full-text search tools, leaner search parameters, and clearer error feedback with stronger test coverage. + +### ✨ Features + +- Add a 7-day memory for assistant selection. +- Add one-click message copying in AI chat. +- Improve AI chat styling and overall interaction flow. +- Add FTS5 full-text search support with a quick search tool. +- Trim search parameters in selected tools to reduce token usage. +- Add an E2E testing framework for Electron apps with port management and instance isolation. + +### 🐛 Bug Fixes + +- Improve AI chat error messages to make issues easier to diagnose. + +### ♻️ Refactoring + +- Reorganize the AI chat module code structure. +- Extract shared logic from the session analysis page and unify header copy. + +### test + +- Add reusable smoke-test coverage for the E2E app launcher. + +### 📝 Documentation + +- Update intro images in project documentation. + +### 💄 Styles + +- Standardize parts of the code formatting to improve readability. + +## v0.14.1 (2026-04-02) + +> This update refines the Home information architecture and UI, while improving SQL conversation UX, stats read performance, and AI tool quality. + +### ✨ Features + +- Improve the Overview page styling. +- Improve interaction flows in the SQL conversation module. +- Move member management to Home and adjust related tab layouts. +- Add new AI tools, including a tool for chat overview retrieval. +- Add a conversation data cache manager to speed up stats loading. +- Improve changelog modal type presentation. + +### 🐛 Bug Fixes + +- Fix silently swallowed AI errors in SQL Lab and summary generation. + +### ♻️ Refactoring + +- Refactor AI tool categorization to improve maintainability. + +### 🔧 Chores + +- Deprecate low-value AI tools to keep the toolset focused. + +## v0.14.0 (2026-03-28) + +> Add API import/export and preset prompts, improve Overview and settings flows, and fix message deduplication, AI conversation flow, and daily trend display. + +### ✨ Features + +- Add API import +- Add API export +- Let preset questions send immediately when selected +- Add a Settings option for the default tab when opening a chat session +- Improve the Overview page styling +- Refine the overall UI and the API service settings screen +- Improve identity cards and assistant selection interactions + +### 🐛 Bug Fixes + +- Fix false positives in message deduplication and unify empty-string deduplication behavior +- Fix AI conversation flow issues and frontend type-check errors +- Add a fallback default assistant for edge cases +- Fix daily message trends not rendering + +### ♻️ Refactoring + +- Clean up legacy typing issues across the parser, worker, RAG, and merger modules + +### 🔧 Chores + +- Add a skill for generating assistant configurations + +## v0.13.0 (2026-03-16) + +> Assistant Mode is here with skills in chat, quick input actions, improved chat and settings UI, Traditional Chinese and Japanese support, UI refinements, and multiple stability fixes. + +### ✨ Features + +- Shipped the first Assistant Mode release with improved assistant logic and analysis tools +- Launched the Assistant and Skill marketplaces; chats can now use skills +- Added @-mention member selection for collaboration +- Added Traditional Chinese and Japanese localization +- Refactored Settings and refined UI details +- Improved Overview styling and chat experience +- Moved the export chat history entry point +- Removed the legacy prompt system and custom AI filtering +- Model calls no longer stop when switching pages + +### 🐛 Bug Fixes + +- Fixed Gemini API configuration issues +- Fixed an error caused by stopword processing order in NLP + +### ♻️ Refactoring + +- Refactored AIChat organization +- Restructured directory and project layout + +### 📝 Documentation + +- Updated the user agreement and project docs + +### 🔧 Chores + +- Improved the changelog build pipeline + +### 💄 Styles + +- Standardized code formatting and lint output + +## v0.12.1 (2026-02-27) + +> Add chat-history preprocessing and AI debugging, refactor the Agent/LLM architecture, and fix i18n and Windows theme consistency issues. + +### ✨ Features + +- Add a chat-history preprocessing pipeline. +- Add preprocessing settings UI and configuration management. +- Add session-based context timelines and runtime status for the Agent. +- Add an AI debug mode with improved log observability. + +### 🐛 Bug Fixes + +- Fix partial UI text not being localized under English settings. +- Fix overlay color updates not matching the active theme on Windows. + +### ♻️ Refactoring + +- Split the monolithic Agent implementation into a modular architecture. +- Refactor the tool system to AgentTool + TypeBox and complete i18n support. +- Unify the LLM access layer under the pi-ai implementation. +- Refactor data-flow direction and IPC contracts, with corresponding frontend adaptation. +- Introduce shared types and improve ChatStatusBar i18n. +- Refactor parts of the chart stack into a plugin-based architecture. + +### 🔧 Chores + +- Remove the over-engineered sessionLog module. +- Remove @ai-sdk dependencies and legacy LLM service implementations. +- Temporarily hide the vector model configuration entry. +- Update project description copy. + +### 💄 Styles + +- Run ESLint auto-fix to unify code style. + +## v0.11.2 (2026-02-15) + +> Improve chat import workflows and management tools, while enhancing cross-platform parser compatibility. + +### ✨ Features + +- Improve parser compatibility for LINE and WhatsApp formats. +- Improve the chat sniffing layer with polling detection and a fallback strategy. +- Support Shift multi-select in the Manage page. +- Show chat summary count and AI conversation count in the Manage page. +- Optimize the main-page layout to provide more usable space. +- Improve top-right window controls styling on Windows. + +### 📝 Documentation + +- Update project documentation. + +## v0.11.0 (2026-02-13) + +> Add Telegram import, improve incremental import UX, strengthen i18n coverage, and fix indexing and page flicker issues. + +### ✨ Features + +- Expand i18n support across AI calls, logs, and main-process configuration. +- Add support for importing Telegram chat history. +- Improve the incremental import flow and related copy. +- Improve the interaction flow when opening protocol links. + +### 🐛 Bug Fixes + +- Fix index invalidation after incremental imports (resolve #81). +- Fix WhatsApp iPhone-exported chats not being recognized (resolve #82). +- Fix a double-flicker issue when switching to the chat page. + +### 🔧 Chores + +- Optimize TypeScript configuration. +- Adjust i18n build configuration. +- Improve skill-related project configuration. + +## v0.10.0 (2026-02-11) + +> Add interaction frequency analysis, improve the session query pipeline, and fix issues in incremental indexing and database scanning. + +### ✨ Features + +- Add an interaction frequency analysis view to make member interaction trends easier to understand. +- Improve session query logic and processing flow. + +### 🐛 Bug Fixes + +- Fix inaccurate session index generation scope after incremental updates (fix #79). +- Fix non-chat SQLite files being incorrectly processed during migration and session scanning. + +### ♻️ Refactoring + +- Refactor the session query module to improve maintainability. + +### 🔧 Chores + +- Remove transformers-related dependencies and update project configuration. + +## v0.9.4 (2026-02-08) + +> Improved time filtering and AI configuration UX, added local API key encryption, and fixed LINE chat log parsing. + +### ✨ Features + +- Add more flexible time-filtering options. +- Store API keys with local encryption. +- Hide release notes for first-time users. +- Improve the configuration status display in the AI chat footer. +- Allow the app to restart immediately after data directory migration. + +### 🐛 Bug Fixes + +- Fix parsing issues for LINE chat logs. + +### 📝 Documentation + +- Update project documentation. + +## v0.9.3 (2026-02-03) + +> Support custom data directories and fix many known issues. + +### ✨ Features + +- Add a data directory location setting +- Optimize data directory migration logic +- Add a confirmation dialog for directory switching +- Improve parser logic (WeFlow / Echotrace) + +### 🐛 Bug Fixes + +- Fix crashes on Windows when custom filtering processes large message volumes +- Fix conversations ending early when third-party relay APIs call tool_call +- Fix some WhatsApp chat logs not being detected correctly +- Fix manage page header stacking above settings + +### ♻️ Refactoring + +- Refactor session query module +- Improve migration logging + +## v0.9.2 (2026-02-02) + +> Rankings are now displayed as charts; word cloud generation and the local AI inference model are optimized; chat record filtering and the date picker are improved; and key routes are preloaded after launch. + +### ✨ Features + +- Refactor rankings to chart-based views +- Optimize word cloud output +- Optimize inference models +- Improve linked search + filter in chat records +- Enhance date picker interactions +- Preload key routes after app launch + +### 🔧 Chores + +- Modularize preload APIs +- Optimize analytics logic +- Upgrade ESLint and format code + +## v0.9.1 (2026-01-30) + +> Add LINE chat import, batch management, and chat search, plus fixes for known issues. + +### ✨ Features + +- Add batch management with batch delete and merge +- Support chat conversation search +- Support LINE chat import +- Compatible with WeFlow exported JSON format +- Member list uses backend pagination +- Improve some copy + +### 🐛 Bug Fixes + +- Fix Windows app not closing during updates due to Worker occupation + +## v0.9.0 (2026-01-28) + +> Add NLP capabilities with a word cloud page under the Quotes tab; add a Views tab for more charts; support following system proxy settings; and refine some pages and styles. + +### ✨ Features + +- Optimize user selector performance with virtualized loading +- Move rankings to the Views tab +- Introduce tokenization and add a word cloud sub-tab +- Improve group chat tab copy +- Network proxy follows system proxy settings +- Optimize release notes display logic + +### 💄 Styles + +- Improve Markdown rendering styles + +## v0.8.0 (2026-01-26) + +> This update adds session summaries and vector retrieval; shows release notes after each update; improves parts of the UI; and fixes some known issues. + +### ✨ Features + +- Remove Help & Feedback from the sidebar +- Add a footer on the home page with common links +- Automatically open release notes after updating to a new version +- Optimize batch session summary generation +- Add session summaries in chat +- Support vector model configuration and retrieval +- Log more detailed errors when chat import fails + +### 🐛 Bug Fixes + +- Fix shuakami-jsonl parsing error (fix #50) + +## v0.7.0 (2026-01-23) + +> Improve the AI chat experience, and refine update logic and charting. + +### ✨ Features + +- Improve update logic +- Improve AI chat error logs +- Quick model selection at the bottom of chat +- Improve default prompts with a touch of humor +- Replace chart.js with ECharts +- Remove registration agreement logic + +## v0.6.0 (2026-01-21) + +> Integrate AI SDK to improve AI chat stability; add a thinking content block; and refine some styles + +### ✨ Features + +- Add a log locator feature +- Integrate AI SDK +- Add a thinking content block +- Fix global modals being covered by the home page drag area +- Improve top-right close button style on Windows + +## v0.5.2 (2026-01-20) + +> Support merged imports; fix several issues + +### ✨ Features + +- Support merged imports +- Show chat log start/end time on the main panel +- Improve the drag-and-drop area + +### 🐛 Bug Fixes + +- Improve build config to fix macOS x64 compilation +- Fix close button style in the message viewer on Windows +- Require building on the target architecture for macOS packaging (fixes #36) + +## v0.5.1 (2026-01-16) + +> Fix several issues + +### ✨ Features + +- Improve copy + +### 🐛 Bug Fixes + +- Fix app process not exiting on Windows when closing (#33) +- Fix number input bug (resolve #34) + +## v0.5.0 (2026-01-14) + +> Support Instagram chat import; add batch and incremental import + +### ✨ Features + +- Support Instagram chat import +- Logic improvements +- Improve system prompt presets +- Support incremental import +- Support batch import +- Style improvements +- Support native window controls and theme sync on Windows (#31) + +### 🔧 Chores + +- Remove componenst.d.ts + +## v0.4.1 (2026-01-13) + +> This release focuses on style and interaction improvements, with no major new features + +### ✨ Features + +- Prompt preview support +- Improve AI chat status bar +- Improve table migration logic +- Show avatars in the sidebar +- Style improvements +- Replace native window controls bar +- Improve global background color +- Clean up Worker on app exit + +### 🐛 Bug Fixes + +- Fix theme-follow-system setting not working +- Fix update modal layout issues + +## v0.4.0 (2026-01-12) + +> Import now supports shuakami-jsonl; AI chat is optimized to save tokens; imports can generate session indexes and the viewer can jump by index; updates now support acceleration mirrors + +### ✨ Features + +- Compatibility with shuakami-jsonl +- Improve loading state +- Add custom filters +- Refactor preset system with shared presets +- Trim system prompts to save tokens +- Add session-related function calling +- Handle message jumps with context +- Message viewer supports session index and quick jump +- Refactor settings modal and add session index settings +- Generate session index when importing chats +- Refactor settings modal +- Improve base component interactions +- Improve home page styling +- Improve update acceleration logic +- Add acceleration mirrors + +## v0.3.1 (2026-01-09) + +> Add Discord import support; parsers now import reply messages; storage moves to a more standard location; role import is supported; import errors provide more detailed diagnostics; and various improvements + +### ✨ Features + +- Move table upgrades to the main process +- Ignore beta versions during auto-update checks +- Move data storage to userData +- Parsers re-enable reply message import +- Support platform message IDs and reply IDs with table migration +- Support Tyrrrz/DiscordChatExporter import format +- Support roles in the member table +- Enhance ChatLab format detection +- Align click import and drag import behaviors +- Provide more detailed format diagnostics + +### 🐛 Bug Fixes + +- Fix some users having empty platformId + +## v0.3.0 (2026-01-08) + +> Add English support and various improvements + +### ✨ Features + +- SQL Lab supports export +- AI chat supports export +- Finalize localization +- Show explicit errors for AI model failures +- SQL results can jump to the message viewer +- Improve system prompts and support a prompt marketplace + +## v0.2.0 (2025-12-29) + +> Support proxy configuration; show error logs on import; improve some UI interactions; and add feature updates + +### ✨ Features + +- Message manager shows system messages +- Improve import flow and show logs on errors +- WhatsApp supports English-format message import +- Support proxy configuration (resolve #7) +- Improve AI model UI interactions +- Add API tutorial for user configuration +- Add two free GLM models; add Doubao provider and latest models +- AI replies no longer output think content + +## v0.1.3 (2025-12-25) + +> Fix several issues + +### 🐛 Bug Fixes + +- Fix Echotrace parser errors + +## v0.1.2 (2025-12-25) + +> Add dark mode and allow passing user identity in system prompts during AI chats + +### ✨ Features + +- Allow passing user identity in system prompts during AI chats +- Show Owner on the right in the message viewer +- Support database upgrades +- Allow Owner view in the Members tab +- Support dark mode + +### 🐛 Bug Fixes + +- Fix private chats misidentified as group chats + +## v0.1.1 (2025-12-24) + +> Support WhatsApp and legacy QQ chat analysis + +### ✨ Features + +- Show token usage at the bottom of chat sessions +- Support native WhatsApp message format +- Support legacy QQ txt group format + +### 🐛 Bug Fixes + +- Fix message manager z-index being too low + +## v0.1.0 (2025-12-23) + +> Project launch + +### ✨ Features + +- Initial release diff --git a/changelogs/ja.json b/changelogs/ja.json new file mode 100644 index 000000000..4b2cac8f7 --- /dev/null +++ b/changelogs/ja.json @@ -0,0 +1,1819 @@ +[ + { + "version": "0.29.0", + "date": "2026-07-01", + "summary": "連絡先機能と関係ギャラクシーを追加し、連絡先計算、アバター読み込み、サイドバー性能を改善しました。", + "changes": [ + { + "type": "feat", + "items": [ + "人間関係モジュールを追加し、連絡先をサブページとして配置。セッション横断の連絡先集計、期間フィルター、ページング、仮想スクロールに対応", + "連絡先ページでグループメンバーを手動で友達としてマークできるようにし、元の会話への移動とチャット記録表示への入口を追加", + "インタラクション関係ギャラクシーを追加し、3D パノラマ、ノード絞り込み、検索、詳細パネル、関連連絡先/グループの探索に対応", + "関係ギャラクシーに高関連、友達のみなどの表示フィルターを追加し、プライバシーモードでは名前をマスク", + "【デスクトップ】アプリ更新をバックグラウンドでサイレントダウンロード" + ] + }, + { + "type": "perf", + "items": [ + "連絡先リストをページングと仮想スクロールに変更し、多数の友達やグループメンバーによる描画負荷を軽減", + "アバターを遅延読み込みにし、サイドバーのセッション一覧を仮想化して、起動時やページ切り替え時のもたつきを軽減" + ] + }, + { + "type": "style", + "items": ["ダークモードのメイン背景、関係ページのヘッダー、サイドバーの選択状態を統一"] + } + ] + }, + { + "version": "0.28.1", + "date": "2026-06-26", + "summary": "チャット記録の表示を改善し、インポート API を追加しました。インポート、同期、ローカルモデルのプロキシダウンロードも修正しています。", + "changes": [ + { + "type": "feat", + "items": [ + "Push Import API を追加し、セッション単位の追記、重複排除、増分インデックス生成に対応", + "CLI、デスクトップ、pull sync の各インポート経路で、インポート後にセッションインデックスを自動生成", + "チャット記録ビューアーの操作部とハイライト表示を改善", + "サイドバーのスクロールバーを通常は非表示にし、ホバー時に表示" + ] + }, + { + "type": "fix", + "items": [ + "Push/Pull インポートの検証、重複排除、同時実行ロックを強化し、不正な書き込みや重複インポートを防止", + "GET /api/v1/sessions/:id に lastPlatformMessageId と importedAt を追加し、増分インポートの境界判定に対応", + "バックグラウンドの pull sync 完了後、セッション一覧を自動更新", + "ローカルのセマンティックインデックスモデルのダウンロードでプロキシが正しく使われない問題を修正し、Worker ログの初期化を追加", + "【デスクトップ】API のデフォルトポートを CLI と同じ 3110 に合わせ、使用中の場合は後続ポートを自動で試行", + "【デスクトップ】システムプロキシの HTTP、HTTPS、SOCKS 結果を解決し、ローカルモデルのダウンロードがプロキシを静かに回避しないよう修正" + ] + }, + { + "type": "refactor", + "items": [ + "【CLI】pull/sync のインポート経路を共有 streaming importer に移行し、旧パーサーと書き込み実装を削除", + "【デスクトップ】増分インポート後の重複したセッションインデックス生成呼び出しを削除" + ] + }, + { + "type": "docs", + "items": ["ドキュメント内の API ポート、サンプル、未実装機能の説明を修正"] + } + ] + }, + { + "version": "0.28.0", + "date": "2026-06-25", + "summary": "Google Chat のインポートに対応し、統合ログモジュールを追加しました。セマンティックインデックスの管理操作と設定も改善しています。", + "changes": [ + { + "type": "feat", + "items": [ + "CLI・デスクトップで Google Chat Takeout アーカイブのインポートに対応", + "ファイルサイズによる自動ローテーションとグローバルエラーキャプチャを備えた統合ログモジュールを追加", + "セマンティックインデックス管理で「無効化」を「削除」操作に変更し、legacy hash の修正とリスト高さの上限を設定", + "モデル設定の保存時にセマンティックインデックスを自動プリロードし、AI ディレクトリに統合", + "セマンティックインデックス設定から検索結果数の設定項目を削除し、内部デフォルト値を使用するよう簡素化" + ] + }, + { + "type": "fix", + "items": ["stream-json/stream-chain の依存関係を宣言し、マルチチャットスキャンのフォールスルーを修正"] + }, + { + "type": "docs", + "items": ["サポートプラットフォームのドキュメントに Google Chat を追加"] + } + ] + }, + { + "version": "0.27.2", + "date": "2026-06-24", + "summary": "AI auth profileのライフサイクル管理に関する複数の問題を修正し、分析サービスの安定性を向上しました。", + "changes": [ + { + "type": "feat", + "items": ["【CLI Web】共有AnalyticsServiceを通じてデイリーアクティブユーザーデータを一元送信するよう改善"] + }, + { + "type": "fix", + "items": ["AIサービス設定を削除する際に、対応するauth profileを同時に削除するよう修正"] + }, + { + "type": "refactor", + "items": ["廃止済みの分析設定マイグレーションロジックを削除"] + } + ] + }, + { + "version": "0.27.1", + "date": "2026-06-23", + "summary": "APIによるバッチ埋め込みでインデックス構築を高速化し、ローカルモデルの遅延ロードに対応。セマンティックインデックスの再開時のクラッシュ、同期カーソルの不具合、AI会話エクスポートなど複数の問題を修正。", + "changes": [ + { + "type": "feat", + "items": [ + "APIプロバイダーによるバッチ埋め込みに対応し、インデックス構築を大幅に高速化", + "ローカル埋め込みモデルを遅延Workerロードに変更し、起動時のリソース消費を削減" + ] + }, + { + "type": "fix", + "items": [ + "AI会話エクスポートを修正:現在表示中のAI会話を正しくエクスポート可能に", + "セマンティックインデックスのバッチ書き込み途中でクラッシュした後の再開時に、一意制約エラーが発生してセッションが停止する問題を修正", + "セマンティックWorkerの複数の問題を修正:設定変更が実行中のWorkerに反映されない、可用性プローブのエラーが通常のAIチャットに影響する、アクティブビルド状態の追跡が不正確でWorkerが早期終了する", + "同期プルのカーソル修正:空レスポンス時のリトライ前に初期ページのサーバー水位が保存されず、末尾ウィンドウが繰り返し取得される問題を修正", + "同期プルのページネーションカーソルが誤って戻る問題を修正し、重複インポートを防止" + ] + }, + { + "type": "refactor", + "items": ["デッドコードと過剰な抽象化を削除し、ツールカタログ構造とディープマージロジックを整理"] + } + ] + }, + { + "version": "0.27.0", + "date": "2026-06-22", + "summary": "ベクトルインデックスと証拠検索機能を追加。セマンティック検索でチャットの根拠を特定・表示できるようになり、インデックス管理画面も新設。", + "changes": [ + { + "type": "feat", + "items": [ + "セマンティックインデックス管理画面を追加。対話を選択してベクトルインデックスを構築でき、多言語対応済み", + "retrieve_chat_evidence ツールを追加:AI がセマンティック検索で時刻情報付きのチャット証拠を取得可能に", + "プランナーが証拠関連の質問を自動検出し、セマンティック検索ツールへルーティング", + "証拠ブロックの展開・折りたたみと、元のメッセージへのクリックジャンプに対応", + "セマンティック検索に時間範囲フィルターを追加", + "セマンティックインデックスのモデル選択画面をリデザイン", + "AI 処理過程のセグメントを折りたたみ可能に", + "証拠ソース行と Agent ツール呼び出し結果の表示を簡略化" + ] + }, + { + "type": "fix", + "items": [ + "複数キーワードによるメッセージ検索を修正", + "ワードクラウドの辞書読み込みにおける UX 回帰を修正", + "ランキングラベルと絵文字クリーニングの問題を修正", + "v8 データベース移行時の i18n テキスト欠落を修正", + "【CLI】ツールアダプターでのセッションコンテキスト転送と前処理の欠落を修正" + ] + } + ] + }, + { + "version": "0.26.3", + "date": "2026-06-15", + "summary": "ディスクキャッシュと古いリクエストのキャンセルにより分析パフォーマンスを改善。セッション切り替えがよりスムーズに。ワードクラウド・頻出ワード統計・タイムラインパネルなど複数の不具合を修正。", + "changes": [ + { + "type": "feat", + "items": ["【CLI Web】ファビコンを追加"] + }, + { + "type": "perf", + "items": [ + "分析結果をデータベースファイルのバージョンをキーとしてディスクにキャッシュし、2回目以降の読み込み速度を大幅に向上", + "セッションの切り替えやフィルター変更時に古い分析リクエストを自動キャンセル", + "ワードクラウドの表示語数変更時に再分かち書きをスキップし、不要な処理を削減" + ] + }, + { + "type": "fix", + "items": [ + "頻出ワード統計にプラットフォームの返信プレースホルダーが含まれる問題を修正", + "ワードクラウドのテキストにメディアプレースホルダーが含まれる問題を修正", + "Jieba カスタム辞書変更後に言語設定キャッシュと単語頻度キャッシュが無効化されない問題を修正", + "時間依存の分析キャッシュが1日ごとに期限切れにならない問題を修正", + "タイムラインパネルのデフォルトスクロール位置とセッションジャンプが機能しない問題を修正", + "セグメントメッセージのコンテキストが欠落する問題を修正", + "AI の会話数が常に 0 を返す問題を修正", + "ホーム画面のチュートリアルリンクがローカライズされたパスを使用していない問題を修正", + "AI チャットパネル上部のスクリーンショットボタンを削除", + "【デスクトップ】開発モードでの 504 エラーを防ぐため、遅延ロードルートの依存関係を事前バンドル", + "【CLI】パッケージバージョンの不一致による誤った更新通知を修正", + "【CLI】アップデートプロンプトで矢印キーによる選択をサポート" + ] + }, + { + "type": "refactor", + "items": ["タイムラインパネルのラベルを「サマリー」に変更"] + } + ] + }, + { + "version": "0.26.2", + "date": "2026-06-13", + "summary": "「自分は誰か」セッションのオーナー識別機能を改善。手動でプロフィールを設定し、同プラットフォームのセッションへ自動一括適用できるように。チャートレンダリングと AI キャッシュの不具合を複数修正。", + "changes": [ + { + "type": "feat", + "items": [ + "「自分は誰か」オーナー識別機能を追加:確認後にプラットフォームのオーナープロフィールを保存し、同プラットフォームの未設定セッションへ自動で一括適用", + "インポートや pull sync 完了後に保存済みのオーナープロフィールを自動で適用し、手動設定の手間を削減" + ] + }, + { + "type": "fix", + "items": [ + "name-match プラットフォーム(WhatsApp / Line / Instagram)での一括適用時に ownerId のキャッシュとデータベース書き込みが一致しない問題を修正", + "複数のルートが独立した PreferencesManager インスタンスを使用することでオーナープロフィールが古い値に上書きされる問題を修正", + "【CLI】長期起動時に sync モジュールの preferences キャッシュが古くなり、pull インポート後にオーナープロフィールが反映されない問題を修正", + "インポート後に一時ファイルが完全に削除されない問題を修正", + "ストリーミング生成中に AI チャートがちらつく問題を修正", + "AI チャートのレンダリング順序が不安定になる問題を修正", + "AI チャートに不要な空白行が表示される問題を修正", + "AI チャート生成中の進捗表示を追加", + "スクリーンショット前にチャートのサイズが調整されず表示が不完全になる問題を修正", + "last_message_time の意味を修正:グループの最終活動時間ではなくデータのカバー終了時刻を示す", + "増分インポート後に概要と分析のキャッシュが無効化されず古いデータが表示される問題を修正" + ] + } + ] + }, + { + "version": "0.26.1", + "date": "2026-06-10", + "summary": "ツール呼び出し履歴の再生に関する複数の問題を修正し、ツール呼び出しの永続化と再生に対応。ステータスバーへの Token キャッシュ統計表示とメッセージの複数形式エクスポートを追加。", + "changes": [ + { + "type": "feat", + "items": [ + "チャットステータスバーに Token キャッシュのヒット/ミス使用量を表示", + "メッセージのエクスポートで TXT・JSON・Markdown 形式に対応" + ] + }, + { + "type": "fix", + "items": [ + "ツール呼び出し記録の永続化と再生に対応し、複数ターンの AI コンテキストを維持", + "テキスト出力のないアシスタントターンが履歴再生でフィルタリングされ、ツール結果が失われる問題を修正", + "同一セッションを連続エクスポートする際、ファイル名の衝突でサイレント上書きされる問題を修正", + "Desktop: 大規模セッションのエクスポートが 60 秒の IPC タイムアウトで失敗する問題を修正", + "DeepSeek のツール呼び出しターンで reasoning_content が履歴再生されない問題を修正", + "履歴圧縮時にリプレイされたツール結果の token 数が正しく計上されない問題を修正", + "ツール結果としてモデルに送信されるテキストに、公開すべきでない生メッセージデータが含まれる問題を修正", + "ツールパネルが外側クリックで自動的に閉じない問題を修正" + ] + }, + { + "type": "refactor", + "items": ["セッションページのヘッダーを共有コンポーネントとして切り出し"] + } + ] + }, + { + "version": "0.26.0", + "date": "2026-06-10", + "summary": "AI 分析プランナーを追加し、ストリーミング形式の構造化プラン生成とチャート計画の連携に対応。チャートモードのツール可用性を改善し、AI とインポートに関する複数の問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "AI 分析プランナーを追加し、ストリーミング形式の構造化分析プラン生成とブロックレンダリングに対応しました", + "チャート計画のサポートを統合し、AI 分析プランから自動的にチャート生成へ移行できるようにしました", + "起動コンテキストと拡張データスナップショットをプランナーに組み込み、分析の深度と精度を向上しました", + "チャートレンダリング時に生の SQL より高レベルのツールを優先使用し、必要な権限を低減しました", + "AI 分析とチャート全体の動作を改善しました", + "チャートの自動モード設定オプションを追加しました", + "メッセージ ID のコピー操作を追加しました", + "シャドウルーティングへの LLM フォールバックを追加し、ルーティング決定をログに記録するようにしました", + "【CLI】エージェントのストリームイベントをすべて公開しました" + ] + }, + { + "type": "fix", + "items": [ + "チャートスキルを明示的に選択した際に render_chart ツールが消える問題を修正しました", + "明示チャートモードでのツール可用性の欠落とエラー後のプラン状態の不整合を修正しました", + "ECharts 6 の containLabel 設定を移行し、円グラフの重複凡例を非表示にしました", + "設定ページのナビゲーション順序、skillSettings キー名、SubTabs の折り返し問題を修正しました", + "AI アシスタントのメッセージがチャット領域の幅全体を埋めない問題を修正しました", + "分析プラン生成ブロックの表示スタイルを改善しました", + "再起動後に思考レベルの選択が保持されない問題を修正しました", + "JSONL のタイムスタンプ正規化により差分インポートが失敗する問題を修正しました", + "【デスクトップ】execute_sql ツールがデスクトップのツールレジストリに登録されない問題を修正しました" + ] + }, + { + "type": "test", + "items": ["エージェントのルーティング決定に関する評価セットを追加しました"] + } + ] + }, + { + "version": "0.25.1", + "date": "2026-06-08", + "summary": "データディレクトリの互換性ゲートを導入し、古いランタイムによるデータの上書きを防止。CLI に ai chat コマンドを追加しました。", + "changes": [ + { + "type": "feat", + "items": [ + "データディレクトリ互換性ゲートを追加:v6 スキーマのデータ書き込み後に最低互換ランタイムバージョンを記録し、古いバージョンによる上書きを防ぎます", + "データベース操作前に互換バージョンを検証し、要件を満たさない場合はアクセスを拒否します", + "【デスクトップ】起動時にデータディレクトリの互換バージョンを確認し、要件を満たさない場合はエラーダイアログを表示して終了します", + "【CLI】起動時にデータディレクトリの互換バージョンを確認し、要件を満たさない場合は即座に終了します", + "【CLI】ai chat コマンドを追加し、ターミナルから AI とのマルチターン対話が可能になりました" + ] + }, + { + "type": "fix", + "items": [ + "互換性ゲートの書き込みに Electron 開発モードの 0.0.0 ではなくパッケージ版バージョン番号を使用するよう修正し、ランタイム ID を必須化しました", + "データベース移行とインポート完了後に互換性ゲートを正しく書き込むよう修正し、起動時の移行失敗は即座にエラーとして報告するようにしました", + "【MCP】起動時に互換バージョンを確認し、要件未満の場合は早期終了して不整合なデータの書き込みを防ぎます", + "【デスクトップ】HTTP API で互換性ゲートエラーを 409 レスポンスにマッピングし、フロントエンドでアップグレード案内を表示しやすくしました", + "【CLI】AI ツール名を正規化し、ai chat コマンドのエッジケースを修正しました" + ] + }, + { + "type": "refactor", + "items": ["AI チャットと segment 識別子の命名を統一しました"] + }, + { + "type": "test", + "items": ["パーサー、設定移行、データベース移行、HTTP ルート、AI ツールのテストカバレッジを追加しました"] + }, + { + "type": "docs", + "items": [ + "データディレクトリ互換性ゲートの公開ドキュメントを追加し、バージョン間でデータディレクトリを共有する際の制限とアップグレード手順を説明しました" + ] + } + ] + }, + { + "version": "0.25.0", + "date": "2026-06-07", + "summary": "AI チャットでスキルからグラフを生成できるようになり、ツール実行回数、デスクトップのタイトルバー、開発サービスのライフサイクルに関する問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "スラッシュ操作で起動できる AI グラフ実行環境を追加し、会話内で ECharts グラフを生成・表示できるようにしました", + "AI グラフ結果の表示体験を改善し、レンダリング状態と複数のグラフ種別への対応を追加しました" + ] + }, + { + "type": "fix", + "items": [ + "AI Agent の既定のツール実行回数を増やし、複雑なタスクが途中で止まりにくくしました", + "プリセット質問チップとサイドバー要素の重なり順が崩れる問題を修正しました", + "【CLI Web】開発バックエンドのライフサイクル cleanup が不完全になる問題を修正しました", + "【デスクトップ】自動スキルでグラフツールが登録されず、グラフスキルを正しく実行できない問題を修正しました", + "【デスクトップ】Windows のタイトルバー overlay がテーマ切り替え後にキャッシュを更新せず、表示が不安定になる問題を修正しました" + ] + }, + { + "type": "refactor", + "items": [ + "AI グラフ実行ポリシーを一元化し、CLI とデスクトップでのグラフツール有効化ルールを統一しました", + "スキルメニューと Skill Manager の重複ロジックを整理しました" + ] + }, + { + "type": "docs", + "items": ["iMessage のチャット履歴エクスポート手順を更新しました"] + }, + { + "type": "chore", + "items": [ + "公開リポジトリからメンテナー専用スキルを削除し、非公開のメンテナンスコンテキストで管理するようにしました" + ] + } + ] + }, + { + "version": "0.24.1", + "date": "2026-06-04", + "summary": "アプリ内更新通知と AI 前処理のデフォルトルールを追加し、更新バッジと匿名化設定の保存に関する問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "アプリ内の更新通知入口を追加し、サイドバーから最新バージョンの内容を確認できるようにしました", + "AI 前処理のデフォルト設定を追加し、データクリーニング、ノイズ除去、匿名化の基本ルールを自動で有効化しました", + "AI 匿名化ルールをグループ単位で管理できるようにし、組み込みルールとカスタムルールを扱いやすくしました" + ] + }, + { + "type": "fix", + "items": [ + "更新チェック失敗時の結果、古いキャッシュ、CLI Web の開発用プレースホルダーバージョンによって New バッジが誤表示される問題を修正", + "AI 前処理の実行前に組み込み匿名化ルールが正しく適用されない問題を修正", + "匿名化ルールの設定を空の上書きとして保存した際、以前の組み込みルール切り替えが消えない問題を修正", + "旧形式の組み込み匿名化ルールを移行した後、上書き設定が失われる問題を修正" + ] + }, + { + "type": "docs", + "items": ["公開開発ガイドを追加し、ローカル開発、ディレクトリの役割、協作ルールを整理しました"] + }, + { + "type": "ci", + "items": ["リリースフローに Markdown 版の更新履歴リンクを追加しました"] + } + ] + }, + { + "version": "0.24.0", + "date": "2026-06-03", + "summary": "CLI Web の認証とデータディレクトリ移行に対応し、複数環境の HTTP ルートを統一。データ移行、AI設定、インポート後の更新に関する問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "多言語リリースノートから Markdown を生成できるようにしました", + "ストレージ管理で旧データ移行の案内を無視できるようにしました", + "【CLI Web】ログインページ、Token 認証、ログイン状態の保持に対応", + "【CLI Web】Web 設定からデータディレクトリを切り替え、移行できるようにしました", + "【CLI】/_web/* API へのアクセスを保護する --require-auth フラグを追加", + "【デスクトップ】内部 HTTP サービスを追加し、フロントエンドから共有サービスアダプター経由で共通 API を利用できるようにしました" + ] + }, + { + "type": "fix", + "items": [ + "データディレクトリ移行とデータベース移行の複数の境界ケースを修正し、旧データベースの列不足やパス切り替え後の読み取り失敗を防止", + "旧データが存在しない場合やディレクトリ変更後にも移行案内が誤って表示される問題を修正", + "増分インポート後にサイドバーのメッセージ数が更新されない問題を修正", + "サイドバーの折りたたみ状態が sessionStorage のみに保存され、再読み込み後に失われる問題を修正", + "保存済みキーで AI 設定を編集する際、モデル取得と検証ボタンが正しく有効化されない問題を修正", + "共有 AI SSE ストリーミングの安定性に関する問題を修正", + "カスタムデータソース追加時に Token が必須になっていなかった問題を修正", + "【デスクトップ】グラフプラグインの計算を Worker に移し、メインスレッドのブロックを回避" + ] + }, + { + "type": "refactor", + "items": [ + "@openchatlab/http-routes 共有パッケージを抽出し、CLI Web とデスクトップの HTTP ルート実装を統一", + "AI 設定、アシスタント、スキル、会話、ストリーミングレスポンス、キャッシュ、マージ関連 API を共有 HTTP ルートへ移行", + "デスクトップの IPC ブリッジを整理し、旧 AI、セッションインデックス、LLM、Assistant、Skill、NLP などの互換ハンドラーを削除", + "フロントエンドのサービス層を統一し、Electron / Web モード間の分岐を削減" + ] + }, + { + "type": "perf", + "items": [ + "メインプロセスのビルド成果物を圧縮し、tiktoken rank table を遅延読み込みにして、起動時とパッケージサイズの負荷を軽減" + ] + }, + { + "type": "ci", + "items": ["Windows リリースフローの zstd キャッシュ問題を修正し、CLI 更新に関するリリースノートを追加"] + }, + { + "type": "chore", + "items": ["Node の型チェック対象を調整し、デスクトップと共有 Node コードをカバーするようにしました"] + } + ] + }, + { + "version": "0.23.1", + "date": "2026-06-01", + "summary": "clb コマンドの短縮エイリアスを追加し、ポート競合を起動前に検出して案内するようにしました。デーモンのサイレント終了やダークモードのタイトルバーなど複数の問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "ページ上部のタイトルバーとツールバーのレイアウトを改善", + "【CLI】起動前にポートの使用状況を確認し、競合時にポート変更や lsof コマンドを案内するよう変更(遅延エラーの解消)", + "【CLI】chatlab コマンドの短縮エイリアスとして clb を追加" + ] + }, + { + "type": "fix", + "items": [ + "サイドバーの Tooltip 位置のズレと Nuxt UI v4 API の互換性問題を修正", + "ダークモード時のタイトルバーに赤い背景と重なり順の誤りが生じる問題を修正", + "AI メッセージのロールパラメータの型を整理し、会話テストのアサーションを強化", + "【CLI】デーモンの起動エントリが誤っていたため、サービス起動後にサイレント終了する問題を修正", + "【CLI】ポート競合検出のエラー処理とメッセージを改善", + "【CLI】Web モードで chatlab.fun へのリバースプロキシルートが欠落していた問題を修正" + ] + } + ] + }, + { + "version": "0.23.0", + "date": "2026-05-31", + "summary": "メッセージ編集をフォーク/再生成モデルに刷新し、モデルごとの推論レベル設定を追加。CLIの起動コマンドを統一し、推論検出・計算に関する複数の問題を修正。", + "changes": [ + { + "type": "feat", + "items": [ + "AI返答の任意の箇所から会話を分岐できるFork機能を追加", + "ステータスバーに推論強度セレクターを追加し、モデルごとに思考レベルを記憶・切替可能に", + "推論レベル制御にdefault/autoオプションを追加し、Kimi・Doubao・Geminiなど多数のモデルファミリーに対応", + "メッセージ分析ビューを「タイプ分析」と「時間分析」の2タブに分割し、統計インサイトカードを強化", + "セッションインデックスの一括再生成前に確認ダイアログを追加し、既存サマリーの誤削除を防止", + "デモデータを4ファイルに拡充し、グループおよび複数のプライベートチャットシナリオをカバー", + "【CLI】統一起動コマンド chatlab start を追加。--headlessおよび--no-openフラグに対応", + "【CLI】chatlab start --daemonでシステムサービスとして登録するデーモンモードを追加。stopおよびstatusコマンドも利用可能" + ] + }, + { + "type": "fix", + "items": [ + "メッセージ編集時にデータ損失やステート不整合を引き起こす可能性のある並行処理の問題を複数修正", + "思考レベルとコンテキストウィンドウの計算でアクティブなモデルIDが使用されない問題を修正", + "chat能力のみを持つカスタムモデルで推論検出が失敗する問題を修正し、ヒューリスティックフォールバックを追加", + "KimiやDoubaoなどのモデルでautoレベルを選択すると思考が無効化される問題を修正", + "【CLI】startコマンドがWebバックエンドを起動しない問題を修正", + "【CLI】Linuxでサービスパスにスペースが含まれる場合にデーモン起動が失敗する問題を修正" + ] + }, + { + "type": "refactor", + "items": [ + "メッセージ分岐システムを編集・再生成モデルに刷新。現在のラウンドのみの更新と後続メッセージの上書きの2モードに対応", + "モデル設定の「推論モデル」「思考無効化」トグルを廃止し、モデルの能力に基づく自動推論に変更", + "モデル切替ボタンのUIを簡素化し、セッションインデックスの読み込みパフォーマンスを改善" + ] + } + ] + }, + { + "version": "0.22.1", + "date": "2026-05-29", + "summary": "セッションサマリーの詳細レベル設定を追加。CLI Web のバッチサマリーフリーズ、AI設定編集時の誤判定、API認証情報の検出に関する複数の問題を修正。", + "changes": [ + { + "type": "feat", + "items": [ + "セッションサマリーの詳細レベル設定を追加。「簡潔」と「標準」の2種類から選択でき、AI設定で切り替え可能" + ] + }, + { + "type": "fix", + "items": [ + "OpenAI互換モードでBase URLを変更した後に認証情報が再検証されない問題を修正", + "APIキー設定済みの判定ロジックを修正し、古いキーの誤再利用を防止", + "バッチサマリー生成中に停止ボタンが即座に反応しない問題を修正", + "【CLI Web】バッチサマリー生成によってページがフリーズする問題を修正", + "【CLI Web】バッチサマリー生成時に選択済みセッションの範囲が反映されない問題を修正", + "【CLI Web】AI設定の編集時にサードパーティサービスがローカルサービスと誤判定される問題を修正" + ] + }, + { + "type": "refactor", + "items": ["セッションインデックスのi18nキーをstorageネームスペースからaiネームスペースへ移動"] + }, + { + "type": "style", + "items": ["チャット記録リストの密度とメッセージバブルのスタイルを改善"] + }, + { + "type": "docs", + "items": ["ドキュメントサイトのナビゲーション構造を整理し、クイックスタートをUsageセクションへ移動"] + } + ] + }, + { + "version": "0.22.0", + "date": "2026-05-26", + "summary": "既定状態の UI スタイルを改善し、CLI Web の更新・ストレージ管理を追加。ホームのインポート、ドキュメントサイト、複数プラットフォームの安定性も改善しました。", + "changes": [ + { + "type": "feat", + "items": [ + "ホームのインポートエリアを入口別に整理し、API インポートと自動同期の入口を追加", + "更新履歴をアプリに同梱し、実行時のリモート依存を削減", + "【CLI Web】Web 設定でデータキャッシュを確認・管理できるストレージ管理機能を追加", + "【CLI】更新チェックと自動更新フローを追加し、CLI Web の更新チェックにも対応", + "【ドキュメント】独立したドキュメントサイト docs.chatlab.fun を追加" + ] + }, + { + "type": "fix", + "items": [ + "ホームのクイックスタートボタンが表示されない問題を修正", + "i18n key パスの誤りと、非推奨の ECharts api.style() 使用を修正", + "自己更新とマイグレーション再試行フローの安全保護を強化", + "【デスクトップ】統一マイグレーションでデータディレクトリの変更が戻る可能性がある問題を修正", + "【CLI Web】新しいバージョン検出時に必ず失敗する「今すぐ更新」操作を表示しないように修正", + "【CLI Web】利用できない Web 自己更新の実行フローを無効化", + "【CLI Web】ファイル管理操作が shell 実行に依存していた問題を修正し、互換性と安全性を改善", + "【CLI Web】データディレクトリ警告後も Web サービスを継続できるように修正", + "【CLI Web】マージ関連 API の互換レイヤーを補完", + "【CLI】更新チェックの非同期キャッシュ、キー入力の操作性、開発モードのスキップ処理を改善" + ] + }, + { + "type": "refactor", + "items": [ + "ドキュメントリンクを docs.chatlab.fun へ移行", + "【設定】セッションインデックスを AI 設定へ移動し、設定タブの並び順を調整" + ] + }, + { + "type": "style", + "items": ["サイドバーの密度を調整し、ダークモードでのタブ選択 UI のコントラストを改善"] + }, + { + "type": "docs", + "items": ["公開ドキュメントサイトの構成とエクスポート説明を整理"] + }, + { + "type": "chore", + "items": [ + "ワークスペースパッケージを ESM に移行", + "ドキュメントサイトのワークスペース依存関係を分離", + "リリース更新履歴を docs ディレクトリ外へ移動" + ] + } + ] + }, + { + "version": "0.21.1", + "date": "2026-05-23", + "summary": "Pull 同期の信頼性とデータの安全性を改善し、サブスクリプション削除時にインポート済みチャットをクリーンアップするオプションを追加。UI アニメーションとモーダルの操作に関する問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "Pull 同期後にセッションインデックスを自動生成", + "サブスクリプション削除時にインポート済みチャットを削除するオプションを追加", + "【CLI Web】更新履歴モーダルでバージョンごとのスクリーンショットに対応、Markdown リスト修正をオプションに変更", + "【MCP】ci 変更タイプのアイコンと多言語対応を追加" + ] + }, + { + "type": "fix", + "items": [ + "Pull 同期時の小ページでデータが失われる可能性がある問題を修正し、リトライインポート結果の検証を追加", + "Pull 同期完了後にセッションインデックスが自動生成されない問題を修正", + "Pull 完了またはデータ削除後にサイドバーのセッションリストが更新されない問題を修正", + "リモートサーバーがページネーション未対応の場合に全セッションを取得できない問題を修正", + "Pull 同期のリトライロジックとページネーション戦略を最適化し、安定性を向上", + "セッション存在チェック時のスキーマ検証が不足し、テーブル未検出エラーが発生する問題を修正", + "強制セッションインデックス生成モーダルが生成失敗時に予期せず閉じる問題を修正", + "セッションが空の状態でセッションインデックスモーダルがページをブロックする問題を修正", + "【デスクトップ】app.getVersion が 0.0.0 を返す場合に package.json のバージョンにフォールバック", + "【CLI Web】独立したローダーを表示せず、同期アイコンをその場で回転アニメーションに変更" + ] + }, + { + "type": "docs", + "items": ["Pull プロトコルのドキュメントを since+nextSince ページネーションモデルに更新"] + } + ] + }, + { + "version": "0.21.0", + "date": "2026-05-22", + "summary": "MCP 対応を追加し、複数プラットフォームのインポートとサービス層を統一しました。Web、AI、同期、リリースの安定性も改善しています。", + "changes": [ + { + "type": "feat", + "items": [ + "Electron と Web モードで共通のフォルダーインポートフローに対応し、複数ファイルのチャット履歴形式を扱えるように改善", + "【MCP】独立したコマンドエントリを追加し、設定画面から MCP 設定を扱えるように改善", + "【MCP】Server のツール数を 19 個に拡張し、コンパクトなテキスト出力と JSON 出力に対応" + ] + }, + { + "type": "fix", + "items": [ + "ディレクトリインポートのパス処理を強化し、インポート失敗のリスクを低減", + "新しいサブスクリプション追加時に発生する可能性がある同期の競合を修正", + "MiniMax のストリーミング応答に含まれる 内容を思考イベントとして正しく処理するように修正", + "【CLI Web】差分インポートが使えない問題を修正", + "【CLI Web】セッション未検出やメンバー履歴の挙動がデスクトップ版と一致しない問題を修正", + "【CLI Web】開発サーバーの Node ランタイム設定を修正", + "【MCP】起動時のネイティブモジュール ABI バインディング不一致を修正" + ] + }, + { + "type": "refactor", + "items": [ + "CLI Web と Electron の共通サービス層を統一し、ルートと IPC に重複していた業務ロジックを削減", + "【MCP】外部に公開するツール登録を整理し、外部 AI Agent 向けのツール schema コストを削減", + "【MCP】中核機能を独立した共通パッケージへ抽出し、CLI とデスクトップ版の連携を簡素化", + "【MCP】設定画面への統合方法を整理し、デスクトップ側の補助コードを簡素化" + ] + }, + { + "type": "ci", + "items": ["【CLI】リリースフローで npm パッケージをあわせて公開できるように改善"] + }, + { + "type": "docs", + "items": ["プラットフォーム固有の changelog 項目に使う接頭辞と並び順のルールを補足"] + }, + { + "type": "chore", + "items": ["【CLI】【MCP】npm 公開に必要な設定とリリース説明を整備"] + } + ] + }, + { + "version": "0.20.0", + "date": "2026-05-19", + "summary": "今回の更新では、複数プラットフォーム向けの中核アーキテクチャを統一し、AI、インポート、同期、デスクトップ版ビルドの安定性を改善しました。次回バージョンで提供予定の独立した Web、CLI、MCP 機能に向けた準備も含まれるため、CLI を使う前に本バージョンへ更新してデータの事前移行を完了してください。", + "changes": [ + { + "type": "feat", + "items": [ + "独立した CLI、HTTP API サービス、MCP Server の基盤を追加し、コマンドライン、Web、AI エージェント連携に対応", + "独立した Web ビルドとワンコマンド起動を追加し、CLI から Web UI を起動してブラウザを自動で開けるように改善", + "Web モードでチャット履歴のインポート、Demo インポート、セッション照会、メンバー照会、検索、分析などの主要フローに対応", + "Web モードで AI チャット、モデル設定、カスタム Provider/Model、コンテキスト圧縮、ストリーミングイベント表示に対応", + "インポート処理を共通のストリーミングパイプラインへ更新し、複数形式の解析、差分インポート、インポート分析、セッションインデックスの自動生成に対応", + "マージ処理、Markdown エクスポート、セッションキャッシュに関するサーバー機能を追加", + "今後の複数プラットフォーム同期に向けて、共通同期パッケージと CLI 自動化サポートを追加", + "バックエンドでの設定永続化を追加し、CLI 初回起動時にデスクトップ版データを自動検出して移行できるように改善" + ] + }, + { + "type": "fix", + "items": [ + "Web モードのセッションインデックス、CORS プロキシ、Demo 保護、実行時エラーに関する問題を修正", + "Web モードでアプリのバージョンが空表示になる問題を修正", + "AI Agent の根拠取得、Web 側イベントストリーム、エラー整形に関する問題を修正", + "同期表示とインポート処理の不整合を修正", + "CLI 開発モードでの ESM モジュール解決問題を修正", + "CLI インストール後に既存のデスクトップ版データを見つけられない場合がある問題を修正", + "Electron パッケージ分割後のデータ移行パス不整合を修正", + "パッケージ版で Worker スレッドが electron モジュールへ間接依存してクラッシュする問題を修正", + "Electron のビルド、依存関係のインストール、better-sqlite3 ネイティブモジュールの再ビルドに関する問題を修正" + ] + }, + { + "type": "refactor", + "items": [ + "プロジェクトを複数プラットフォーム向けのワークスペース構成へ移行し、apps/desktop、apps/cli、複数の共通 packages に分割", + "パーサー、設定、データベースアダプター、クエリ、マイグレーション、NLP、セッションキャッシュ、インポート、マージ、エクスポート、同期処理を共通モジュールへ抽出", + "AI Agent、ツールシステム、前処理、コンテキスト圧縮、RAG、LLM 設定、アシスタント、スキル管理を共通ランタイム機能として抽出", + "Electron、CLI Web、MCP の AI ツール名、ツール登録、データアクセス方法を統一", + "データディレクトリ、メッセージ照会、メンバー照会、セッションインデックス、SQL 実行、インポート重複排除などの中核ロジックを統一", + "CLI を packages/server から apps/cli へ移行し、npm 公開向けのビルドフローを整備", + "フロントエンド専用のチャートモジュールを src/features へ移動し、packages には再利用可能な共通ライブラリのみを配置", + "Electron 側の単純な転送ファイルと不要になったコードを削除し、重複実装を削減" + ] + }, + { + "type": "build", + "items": [ + "Vite などのビルドツールチェーンを更新", + "CLI の tsup ビルド、Web アセットのバンドル、npm 公開に必要な設定を追加", + "Electron デスクトップ版のビルド設定を調整し、Linux デスクトップ版のビルド対象を削除" + ] + }, + { + "type": "docs", + "items": ["バージョン運用、コミット scope のルール、関連する開発ドキュメントを補足"] + } + ] + }, + { + "version": "0.19.0", + "date": "2026-05-06", + "summary": "AI コンテキストの自動圧縮に対応し、Demo サンプルを追加しました。あわせてモデル設定とデバッグ体験を改善しています。", + "changes": [ + { + "type": "feat", + "items": [ + "モデルのコンテキストウィンドウのプリセットとカスタム設定に対応しました。", + "AI チャットのコンテキスト自動圧縮と関連設定に対応しました。", + "コンテキスト圧縮の流れとステータス表示を改善しました。", + "デバッグモードで元データを確認できるようにし、完全な LLM コンテキストの記録に対応しました。", + "AI モデル設定の文言、フォーム、設定画面の表示を改善しました。", + "ベクトルモデル設定を削除し、関連設定を整理しました。", + "新規ユーザーが空の状態から Demo サンプルを直接確認できるようにしました。", + "プロジェクト構成を pnpm workspace に移行しました。" + ] + }, + { + "type": "fix", + "items": [ + "高速モデルがデフォルトアシスタントに追従する際、一部のケースで誤ったモデルを使う問題を修正しました。", + "データが空の状態で Demo ボタンが表示されない問題を修正しました。", + "メインプロセスで未宣言の axios に直接依存し、起動に失敗する可能性がある問題を修正しました。" + ] + } + ] + }, + { + "version": "0.18.4", + "date": "2026-04-29", + "summary": "今回の更新では、モデルの安定性を最適化し、リモートのモデル一覧取得に対応しました。あわせてAIのエラー詳細表示と一部のスタイルを改善しました。", + "changes": [ + { + "type": "feat", + "items": [ + "リモートのモデル一覧取得に対応しました。", + "OpenAI 互換 API アドレスの /v1 自動補完とリアルタイムプレビューに対応しました。", + "AI チャットのエラー詳細表示を改善しました。", + "一部の表示スタイルを改善しました。" + ] + }, + { + "type": "fix", + "items": ["一部のロジックの脆弱性を修正しました。"] + }, + { + "type": "chore", + "items": ["同期ログスキルのロジックを最適化しました。"] + } + ] + }, + { + "version": "0.18.3", + "date": "2026-04-28", + "summary": "クイックツール入口の配置設定に対応し、デフォルトの時間フィルターを改善しました。あわせて、モーダルの重なり順とデータ保存先の安全案内も修正しています。", + "changes": [ + { + "type": "feat", + "items": [ + "クイックツール入口の配置を設定できるようにしました。", + "デフォルトの時間フィルターの使い勝手を改善しました。", + "データ保存先をアプリのインストールディレクトリ内に設定できないようにしました。" + ] + }, + { + "type": "fix", + "items": [ + "モーダルのスタイルが上書きされる問題を修正しました。", + "設定ページ内でモーダルが背面に隠れる重なり順の問題を修正しました。" + ] + } + ] + }, + { + "version": "0.18.2", + "date": "2026-04-26", + "summary": "購読時の種類選択、リモート会話のページ分割取得、1 回あたりの取得件数設定に対応しました。", + "changes": [ + { + "type": "feat", + "items": [ + "会話を購読する際に、種類で絞り込んで選択できるようにしました。", + "リモート会話のページ分割取得と、必要に応じた追加読み込みに対応しました。", + "データソースごとに、1 回の取得で読み込むメッセージ件数を設定できるようにしました。" + ] + } + ] + }, + { + "version": "0.18.1", + "date": "2026-04-24", + "summary": "DeepSeek V4 への対応と自動起動オプションを追加し、設定まわりと全体の UI 体験を改善しました。あわせて、AI チャット内リンクの開き方も修正しています。
このバージョンから、プロジェクトは正式に ChatLab 組織へ移行します。", + "changes": [ + { + "type": "feat", + "items": [ + "プロジェクトを ChatLab 組織へ移行", + "全体のスタイル表現を改善", + "クイック質問の表示ロジックを改善", + "設定モーダルの開き方を改善", + "自動起動に対応", + "DeepSeek V4 モデルに対応" + ] + }, + { + "type": "fix", + "items": ["AI チャット内のリンクがブラウザで開かない問題を修正"] + } + ] + }, + { + "version": "0.18.0", + "date": "2026-04-23", + "summary": "AI チャットの操作性を改善し、複数の分析導線を整理するとともに、データソース同期と Windows 更新の安定性を向上しました。", + "changes": [ + { + "type": "feat", + "items": [ + "AI アシスタントの操作フローを改善", + "データソース追加フォームの案内文を調整", + "Tool Panel に Mini モードを追加", + "チャット履歴ビューアを Tool Panel へ移動", + "関係分析まわりのタブ構成を統一", + "Quotes モジュールを Insights へ移動", + "キーワード分析を Labs へ移動" + ] + }, + { + "type": "fix", + "items": [ + "既存の型警告を修正", + "Pull の増分同期に 60 秒の重複ウィンドウを追加し、メッセージ取りこぼしを防止", + "Pull リクエストの `limit=1000` を固定し、リモートデータソースの一括出力による重さを抑制", + "Windows 更新時に NSIS ポップアップでサイレントインストールが中断される問題を修正" + ] + } + ] + }, + { + "version": "0.17.5", + "date": "2026-04-21", + "summary": "本リリースでは多数の不具合を修正し、全体の安定性を改善しました。", + "changes": [ + { + "type": "feat", + "items": [ + "関係カードのスタイルを改善しました。", + "`node-machine-id` 依存をネイティブなマシン識別ロジックに置き換え、Linux での API キー更新の安定性を向上しました。", + "チャット履歴の統合時に、元データを保持するオプションを追加しました。", + "プリセットサービスとサードパーティサービスの API エンドポイント横に検証ボタンを追加しました。" + ] + }, + { + "type": "fix", + "items": [ + "dataSource の移行戦略を見直し、移行の安全性を向上しました。", + "メッセージ数が少ない話題で空状態の表示が崩れる問題を修正しました。", + "ローカルモデルの検証が失敗する問題を修正しました。", + "会話を切り替えた後に選択中タブがリセットされる不具合を修正しました。", + "旧 dataSources 構造からのアップグレード後に自動化ページが白画面になる問題を修正しました。" + ] + } + ] + }, + { + "version": "0.17.4", + "date": "2026-04-19", + "summary": "Import API v1 の完全な仕様実装と階層型データソース管理を追加し、チャット履歴の自動同期に対応しました。", + "changes": [ + { + "type": "feat", + "items": ["Import API v1 の完全な仕様と階層型データソース管理を実装"] + } + ] + }, + { + "version": "0.17.3", + "date": "2026-04-17", + "summary": "今回の更新では、プライベートチャットに言語設定タブを追加し、サイドバーの会話リストで並び替えと絞り込みに対応。AIプロバイダーとモデル設定を拡充し、時間フィルターがリセットされる不具合を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "会話リストで並び替えと絞り込みに対応", + "言語設定タブを追加し、言語設定を確認可能に", + "UIスタイルの細部を調整し、視覚的一貫性を向上", + "AIプロバイダーに Anthropic を追加", + "モデルのサードパーティサービスでインターフェース種別の選択に対応", + "AIモデル設定でカスタム名の設定に対応" + ] + }, + { + "type": "fix", + "items": ["設定画面またはAIチャット画面から戻った際に、時間フィルターが「すべて」にリセットされる問題を修正"] + }, + { + "type": "refactor", + "items": ["言語設定を共通型として切り出し、重複定義を削減"] + } + ] + }, + { + "version": "0.17.2", + "date": "2026-04-15", + "summary": "今回の更新では、クロスプラットフォームのデータ統合とメンバーメッセージ統合を追加し、辞書と更新処理の安全性チェックを強化。ダークモード体験とログ機能を改善し、複数の不具合を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "メンバー管理でメンバーメッセージの統合に対応", + "プラットフォームをまたいだチャットデータ統合に対応", + "データ管理で一部テーブル列のソートに対応", + "トピック分析の入口をインサイトモジュールへ移動", + "AI ログファイルの元パス記録を追加", + "ダークモードの配色を調整し、視認性を改善" + ] + }, + { + "type": "fix", + "items": [ + "辞書更新時のリフレッシュ処理と統合 ID 衝突の問題を修正", + "OpenAI 互換リクエストに実行時 User-Agent ヘッダーを追加", + "ダークモードでのスクリーンショット書き出し時に背景が透過する問題を修正", + "辞書ダウンロードに SHA256 整合性検証を追加", + "リモート設定取得を厳格化し、更新インストール確認を強化" + ] + }, + { + "type": "chore", + "items": ["ARM Linux 向け deb パッケージビルド対応を追加", "更新ログ同期フローを最適化"] + }, + { + "type": "docs", + "items": ["繁体字中国語ドキュメントを追加"] + } + ] + }, + { + "version": "0.17.1", + "date": "2026-04-13", + "summary": "トピックモジュールを再設計してトピックカードを追加し、ワードクラウドのキーワードフィルタリングとクエリキャッシュを改善。リモート分かち書き辞書のダウンロードと繁体字中国語辞書に対応し、WhatsApp 検出ロジックも強化しました。", + "changes": [ + { + "type": "feat", + "items": [ + "トピックモジュールを再設計し、トピックカード表示を追加", + "ワードクラウドにキーワードフィルタリングを追加", + "リモート分かち書き辞書のダウンロードに対応し、繁体字中国語辞書を追加", + "クエリキャッシュロジックを改善", + "ローディング表示を統一", + "WhatsApp 検出の精度を向上" + ] + }, + { + "type": "ci", + "items": ["公式ドキュメントサイトを開設し、自動同期・デプロイに対応"] + } + ] + }, + { + "version": "0.17.0", + "date": "2026-04-12", + "summary": "今回の更新では、WhatsAppインポート解析と指定形式インポートを強化し、概要カード構成を刷新するとともに、共有・スクリーンショット・デバッグ機能を追加しました。", + "changes": [ + { + "type": "feat", + "items": [ + "WhatsApp V2 のタイムスタンプを柔軟に解析し、地域ごとのエクスポート差分に自動対応", + "WhatsApp チャット履歴の検出ロジックを改善", + "指定形式インポートに対応", + "メッセージタブに共有カードを追加", + "DEBUG モードにクイックデバッグツールを追加", + "概要のプロフィールカードを改善し、期間指定クエリのロジックを統一", + "概要モジュールのカード構成を再設計し、テーマカラーカードを分離して配色モード拡張に備えた", + "カード最大幅を統一し、ホームのツールをグローバルサイドバーへ昇格", + "テーマカードのスクリーンショットに対応し、モバイル向けスクリーンショット最適化を既定で無効化", + "診断提案を削除し、新しいヒント表示を追加" + ] + }, + { + "type": "fix", + "items": [ + "WhatsApp の時刻解析正規表現と行マッチ正規表現の厳密さが一致しない問題を修正", + "WhatsApp の12時間表記時刻と NNBSP 文字の解析互換性問題を修正" + ] + }, + { + "type": "chore", + "items": ["CI パッケージング高速化のため、electron と electron-builder のバイナリをキャッシュ"] + } + ] + }, + { + "version": "0.16.0", + "date": "2026-04-10", + "summary": "今回の更新では、個別チャットに能動性分析ビューを追加し、モデル編集ダイアログでカスタムモデルが消える問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "個別チャットに能動性分析ビューを追加", + "フッター領域の表示と操作性を改善", + "引用モジュール下部のロジックを改善" + ] + }, + { + "type": "fix", + "items": ["サードパーティー/ローカルサービスの編集ダイアログで、複数のカスタムモデルが失われる問題を修正"] + } + ] + }, + { + "version": "0.15.0", + "date": "2026-04-08", + "summary": "今回のリリースでは、検索・クエリ性能を大幅に改善し、検索ツールのコンテキスト自動引き継ぎ、AI モデル設定の最適化と一部プロバイダー追加、Linux 対応を行いました。", + "changes": [ + { + "type": "feat", + "items": [ + "クエリキャッシュを追加し、アクセス速度を向上", + "検索ツールでコンテキストの自動引き継ぎに対応", + "モデル設定ロジックを再構成", + "新規ユーザーの初回起動時に言語選択ダイアログを優先表示", + "ラボに基本的なデバッグツールを追加", + "旧プロンプトを削除" + ] + }, + { + "type": "fix", + "items": [ + "Windows ライトモードでタイトルバー右上ボタン領域の背景色が不一致になる問題を修正", + "CI パッケージングワークフローにおける Node 24 と pnpm の整合性問題を修正", + "ツール呼び出し表示名の i18n 翻訳不足を補完" + ] + }, + { + "type": "refactor", + "items": ["AI 設定モーダルのコード構成を改善"] + }, + { + "type": "chore", + "items": ["Node 24 へアップグレード", "Linux パッケージングに対応"] + }, + { + "type": "docs", + "items": ["ドキュメントを更新"] + } + ] + }, + { + "version": "0.14.2", + "date": "2026-04-07", + "summary": "今回の更新では、AI 会話体験を中心に改善し、会話コピー、UI 最適化、FTS5 全文検索ツール対応に加えて、検索パラメータの整理とエラー表示・テスト体制の強化を行いました。", + "changes": [ + { + "type": "feat", + "items": [ + "アシスタント選択を 7 日間記憶する機能を追加", + "AI 会話でメッセージのワンクリックコピーに対応", + "AI 会話のスタイルと全体の操作体験を改善", + "FTS5 全文検索に対応し、クイック検索ツールを追加", + "一部ツールの検索パラメータを整理し、token 消費を削減", + "Electron アプリ向けに、ポート管理とインスタンス分離に対応した E2E テスト基盤を追加" + ] + }, + { + "type": "fix", + "items": ["AI 会話のエラー表示を改善し、原因の特定をしやすく修正"] + }, + { + "type": "refactor", + "items": [ + "AI 会話モジュールのコード構成を整理", + "セッション分析ページの共通ロジックを抽出し、ヘッダー文言を統一" + ] + }, + { + "type": "test", + "items": ["再利用可能な E2E ランチャーのスモークテストを追加"] + }, + { + "type": "docs", + "items": ["プロジェクトドキュメント内の導入画像を更新"] + }, + { + "type": "style", + "items": ["一部コードのフォーマットを統一し、可読性を向上"] + } + ] + }, + { + "version": "0.14.1", + "date": "2026-04-02", + "summary": "今回の更新では、ホームの情報構成と UI を見直し、SQL 会話の操作性、統計読み込み性能、AI ツール品質を改善しました。", + "changes": [ + { + "type": "feat", + "items": [ + "概要ページのスタイルを改善", + "SQL 会話モジュールの操作フローを改善", + "メンバー管理をホームへ移し、関連タブ構成を調整", + "チャット概要取得を含む一部 AI ツールを追加", + "会話データのキャッシュ管理モジュールを追加し、統計読み込みを高速化", + "更新ログモーダルのタイプ表示を改善" + ] + }, + { + "type": "fix", + "items": ["SQL Lab と要約生成で AI エラーが黙って握りつぶされる問題を修正"] + }, + { + "type": "refactor", + "items": ["AI ツール分類を再設計し、保守性を向上"] + }, + { + "type": "chore", + "items": ["利用価値の低い AI ツールを廃止し、ツールセットを整理"] + } + ] + }, + { + "version": "0.14.0", + "date": "2026-03-28", + "summary": "APIのインポート/エクスポートとプリセット質問を追加し、概要画面と設定体験を改善。あわせて重複排除、AI会話フロー、日次メッセージ推移の表示問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "APIインポートに対応", + "APIエクスポートに対応", + "プリセット質問を選ぶとそのまま送信できるように", + "設定画面で会話の既定タブを選択可能に", + "概要画面のスタイルを改善", + "全体のUIとAPIサービス設定画面を改善", + "プロフィールカードとアシスタント選択の操作感を改善" + ] + }, + { + "type": "fix", + "items": [ + "メッセージ重複判定の誤検知を修正し、空文字列の重複排除ルールを統一", + "AI会話フローの不具合とフロントエンドのtype-checkエラーを修正", + "例外時のための既定assistantフォールバックを追加", + "日次メッセージ推移が表示されない問題を修正" + ] + }, + { + "type": "refactor", + "items": ["parser、worker、RAG、merger に残っていた古い型の問題を整理"] + }, + { + "type": "chore", + "items": ["assistant 設定生成スキルを追加"] + } + ] + }, + { + "version": "0.13.0", + "date": "2026-03-16", + "summary": "AIチャットにアシスタントモードを追加し、チャットでスキル利用と入力欄のクイック選択に対応。会話/設定画面を改善し、繁体字中国語と日本語に対応、UIの調整と安定性修正を行いました。", + "changes": [ + { + "type": "feat", + "items": [ + "アシスタントモード初版をリリースし、アシスタントロジックと分析ツール機能を強化", + "アシスタントマーケットとスキルマーケットを公開、チャットでスキルを利用可能に", + "「@」でメンバー選択して協業できるように", + "繁体字中国語と日本語のローカライズに対応", + "設定画面を再構成し、一部 UI を改善", + "概要モジュールのスタイルと会話画面体験を改善", + "チャット履歴エクスポートの表示位置を調整", + "旧プロンプトシステムと AI のカスタムフィルタ機能を削除", + "ページ切替時にモデル呼び出しが中断しないように" + ] + }, + { + "type": "fix", + "items": ["Gemini API の設定問題を修正", "NLP のストップワード呼び出し順序によるエラーを修正"] + }, + { + "type": "refactor", + "items": ["AIChat の構成をリファクタリング", "ディレクトリ配置とプロジェクト構成を整理"] + }, + { + "type": "docs", + "items": ["利用規約とプロジェクトドキュメントを更新"] + }, + { + "type": "chore", + "items": ["バージョンログのビルドフローを改善"] + }, + { + "type": "style", + "items": ["コードフォーマットと lint 規則の出力を統一"] + } + ] + }, + { + "version": "0.12.1", + "date": "2026-02-27", + "summary": "チャット履歴の前処理とデバッグ機能を追加し、Agent/LLM アーキテクチャを再構成。あわせて国際化と Windows テーマ表示の不整合を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "チャット履歴の前処理パイプラインを追加", + "前処理設定画面と設定管理機能を追加", + "Agent でセッション単位のコンテキストタイムラインと実行状態を表示", + "AI デバッグモードを追加し、ログの可観測性を向上" + ] + }, + { + "type": "fix", + "items": [ + "英語設定時に一部 UI が未翻訳だった問題を修正", + "Windows で overlay 色を動的更新した際にテーマ表示が揃わない問題を修正" + ] + }, + { + "type": "refactor", + "items": [ + "Agent の単一実装をモジュール構成へ分割", + "ツールシステムを AgentTool + TypeBox 構成へ再編し、i18n を補完", + "LLM アクセス層を統一し、pi-ai ベースへ集約", + "データフローと IPC 契約を見直し、フロントエンド側も対応", + "共有型を導入し、ChatStatusBar の国際化を改善", + "一部のチャートをプラグイン構成へ再編" + ] + }, + { + "type": "chore", + "items": [ + "過剰設計だった sessionLog モジュールを削除", + "@ai-sdk 関連依存と旧 LLM サービス実装を削除", + "ベクトルモデル設定の入口を一時的に非表示化", + "プロジェクト説明文を更新" + ] + }, + { + "type": "style", + "items": ["ESLint の自動修正を実行し、コードスタイルを統一"] + } + ] + }, + { + "version": "0.11.2", + "date": "2026-02-15", + "summary": "チャット履歴の取り込みと管理画面を改善し、複数プラットフォームの履歴互換性を強化しました。", + "changes": [ + { + "type": "feat", + "items": [ + "LINE と WhatsApp パーサーの形式互換性を強化", + "チャット履歴の判定レイヤーを改善し、ポーリング検出とフォールバック機構に対応", + "管理画面で Shift 複数選択に対応", + "管理画面でチャット要約数と AI チャット数を表示", + "ホーム画面のレイアウトを見直し、使えるスペースを拡大", + "Windows の右上コントロールバーの見た目を改善" + ] + }, + { + "type": "docs", + "items": ["プロジェクト文書を更新"] + } + ] + }, + { + "version": "0.11.0", + "date": "2026-02-13", + "summary": "Telegram の取り込みに対応し、増分インポート体験と国際化設定を改善。あわせて索引不整合や画面のちらつきも修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "AI 呼び出し、ログ、メインプロセス設定まわりの国際化対応を拡充", + "Telegram チャット履歴のインポートに対応", + "増分インポートの操作フローと関連文言を改善", + "利用規約リンクを開く際の体験を改善" + ] + }, + { + "type": "fix", + "items": [ + "増分インポート後に索引が無効になる問題を修正(resolve #81)", + "iPhone から書き出した WhatsApp 履歴を認識できない問題を修正(resolve #82)", + "チャット画面切り替え時の二重ちらつきを修正" + ] + }, + { + "type": "chore", + "items": ["TypeScript 設定を最適化", "i18n ビルド設定を調整", "skill 関連の開発設定を整理"] + } + ] + }, + { + "version": "0.10.0", + "date": "2026-02-11", + "summary": "やり取り頻度の分析機能を追加し、セッション検索の流れを改善。あわせて増分索引とデータベース走査まわりの問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "やり取り頻度を分析できるビューを追加し、メンバー間の動きが見やすくなりました", + "セッション検索のロジックと処理フローを改善" + ] + }, + { + "type": "fix", + "items": [ + "増分更新後にセッション索引の生成範囲が不正確になる問題を修正(fix #79)", + "移行やセッション走査時に非チャット用 SQLite ファイルを誤処理する問題を修正" + ] + }, + { + "type": "refactor", + "items": ["セッション検索モジュールを再構成し、保守性を向上"] + }, + { + "type": "chore", + "items": ["transformers 関連依存を削除し、開発設定を更新"] + } + ] + }, + { + "version": "0.9.4", + "date": "2026-02-08", + "summary": "期間絞り込みと AI 設定まわりを改善し、API Key のローカル暗号化に対応。加えて LINE 履歴の解析問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "期間絞り込みの選択肢をより柔軟に拡充", + "API Key のローカル暗号化保存に対応", + "初回利用ユーザーには更新履歴を表示しないよう変更", + "AI チャット下部の設定ステータス表示を改善", + "データディレクトリ移行後にすぐ再起動できるよう対応" + ] + }, + { + "type": "fix", + "items": ["LINE チャット履歴の解析問題を修正"] + }, + { + "type": "docs", + "items": ["プロジェクト文書を更新"] + } + ] + }, + { + "version": "0.9.3", + "date": "2026-02-03", + "summary": "カスタムデータディレクトリに対応し、多数の既知問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "設定画面にデータディレクトリ位置の設定項目を追加", + "データ保存先の移行ロジックを改善", + "ディレクトリ切り替え時の確認ダイアログを追加", + "パーサーロジックを改善(WeFlow / Echotrace)" + ] + }, + { + "type": "fix", + "items": [ + "Windows で大量メッセージを絞り込むとクラッシュする問題を修正", + "外部中継 API が tool_call を返した際に会話が異常終了する問題を修正", + "一部の WhatsApp 履歴を正しく認識できない問題を修正", + "管理画面のヘッダー階層表示の問題を修正" + ] + }, + { + "type": "refactor", + "items": ["session 検索モジュールを再構成", "移行ログ出力を改善"] + } + ] + }, + { + "version": "0.9.2", + "date": "2026-02-02", + "summary": "ランキングをグラフ表示へ刷新し、ワードクラウドやローカル AI 推論モデルを改善。履歴絞り込みと日付選択も見直し、起動後の主要ルート事前読込にも対応しました。", + "changes": [ + { + "type": "feat", + "items": [ + "ランキングをチャート表示へ刷新", + "ワードクラウドの見え方を改善", + "推論モデルを最適化", + "チャット履歴の検索と絞り込みの連携を改善", + "日付選択 UI の操作性を改善", + "起動後に主要ルートを先読み" + ] + }, + { + "type": "chore", + "items": ["preload をモジュール化", "analytics ロジックを改善", "ESLint を更新し、コード整形を実施"] + } + ] + }, + { + "version": "0.9.1", + "date": "2026-01-30", + "summary": "LINE 履歴の取り込み、バッチ管理、チャット検索に対応し、既知の問題もいくつか修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "一括管理を追加し、まとめて削除・結合に対応", + "チャット検索に対応", + "LINE チャット履歴のインポートに対応", + "WeFlow が出力する JSON 形式に対応", + "メンバー一覧をバックエンド分页読み込みへ変更", + "一部文言を改善" + ] + }, + { + "type": "fix", + "items": ["Windows 更新時に Worker が残ってアプリを閉じられない問題を修正"] + } + ] + }, + { + "version": "0.9.0", + "date": "2026-01-28", + "summary": "NLP 分かち書きに対応し、名言集タブにワードクラウドを追加。Views タブでより多くのグラフを表示できるようになり、システムプロキシ追従にも対応しました。", + "changes": [ + { + "type": "feat", + "items": [ + "ユーザーセレクターの性能を改善し、仮想リスト読み込みに対応", + "ランキングを Views タブへ移動", + "分かち書きを導入し、ワードクラウド用のサブタブを追加", + "グループチャットタブの文言を改善", + "ネットワークプロキシがシステム設定に追従", + "更新履歴の表示判定ロジックを改善" + ] + }, + { + "type": "style", + "items": ["Markdown 表示スタイルを改善"] + } + ] + }, + { + "version": "0.8.0", + "date": "2026-01-26", + "summary": "セッション要約とベクトル検索を追加し、更新後のリリースノート表示や一部 UI 体験も改善。あわせて既知の問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "チャットセッションに要約機能を追加", + "セッション要約の一括生成ロジックを追加", + "ベクトルモデル設定と関連検索に対応", + "チャット履歴の取り込み失敗時に、より詳しいログを記録", + "新バージョン更新後に更新履歴を自動表示", + "ホームに共通リンク付き Footer を追加", + "サイドバーから Help & Feedback を削除" + ] + }, + { + "type": "fix", + "items": ["shuakami-jsonl の解析エラーを修正(fix #50)"] + } + ] + }, + { + "version": "0.7.0", + "date": "2026-01-23", + "summary": "AI チャット体験と更新処理を改善し、チャート基盤を chart.js から ECharts へ移行しました。", + "changes": [ + { + "type": "feat", + "items": [ + "更新ロジックを改善", + "AI チャットのエラーログを改善", + "チャット下部からモデルを素早く切り替え可能に", + "既定プロンプトを見直し、少しユーモアを加味", + "chart.js を ECharts に置き換え", + "登録規約ロジックを削除" + ] + } + ] + }, + { + "version": "0.6.0", + "date": "2026-01-21", + "summary": "AI SDK を導入して AI チャットの安定性を高め、思考内容ブロックを追加。あわせて一部スタイルも調整しました。", + "changes": [ + { + "type": "feat", + "items": [ + "ログ位置を特定しやすくする機能を追加", + "AI SDK を導入", + "思考内容ブロックを追加", + "ホーム上部のドラッグ領域にグローバルモーダルが隠れる問題を解消", + "Windows 右上の閉じるボタンの見た目を改善" + ] + } + ] + }, + { + "version": "0.5.2", + "date": "2026-01-20", + "summary": "結合インポートに対応し、いくつかの問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": [ + "結合インポートに対応", + "メインパネルにチャット履歴の開始日時と終了日時を表示", + "ドラッグ&ドロップ領域を改善" + ] + }, + { + "type": "fix", + "items": [ + "macOS x64 ビルド問題を解消するためビルド設定を改善", + "Windows のメッセージビューアで閉じるボタンの見た目が崩れる問題を修正", + "macOS パッケージング時は対象アーキテクチャ上でビルドが必要なよう修正(fixes #36)" + ] + } + ] + }, + { + "version": "0.5.1", + "date": "2026-01-16", + "summary": "いくつかの問題を修正しました。", + "changes": [ + { + "type": "feat", + "items": ["文言を改善"] + }, + { + "type": "fix", + "items": [ + "Windows でアプリを閉じてもプロセスが残る問題を修正(#33)", + "数値入力欄の不具合を修正(resolve #34)" + ] + } + ] + }, + { + "version": "0.5.0", + "date": "2026-01-14", + "summary": "Instagram 履歴の取り込みに対応し、ホームでは一括インポート、チャット画面では増分インポートが使えるようになりました。", + "changes": [ + { + "type": "feat", + "items": [ + "Instagram チャット履歴のインポートに対応", + "各種ロジックを改善", + "システムプロンプトのプリセット機能を改善", + "増分インポートに対応", + "一括インポートに対応", + "スタイルを改善", + "Windows でネイティブウィンドウ操作とテーマ同期に対応(#31)" + ] + }, + { + "type": "chore", + "items": ["componenst.d.ts を削除"] + } + ] + }, + { + "version": "0.4.1", + "date": "2026-01-13", + "summary": "見た目と操作まわりを中心に改善しました。", + "changes": [ + { + "type": "feat", + "items": [ + "プロンプトのプレビューに対応", + "AI チャットのステータスバーを改善", + "マイグレーションテーブルのロジックを改善", + "サイドバーでアバター表示に対応", + "スタイルを改善", + "ネイティブウィンドウコントロールバーを置き換え", + "グローバル背景色を改善", + "アプリ終了時に Worker をクリーンアップ" + ] + }, + { + "type": "fix", + "items": ["テーマ設定の「システムに従う」が効かない問題を修正", "アップデートダイアログのレイアウト崩れを修正"] + } + ] + }, + { + "version": "0.4.0", + "date": "2026-01-12", + "summary": "shuakami-jsonl の取り込みに対応し、AI チャットをより省 Token 化。取り込み時のセッション索引生成とビューアからの快速ジャンプにも対応し、アップデート高速化ミラーも追加しました。", + "changes": [ + { + "type": "feat", + "items": [ + "shuakami-jsonl に対応", + "Loading 表示を改善", + "カスタムフィルターを追加", + "プリセット文言システムを再構成し、共通プリセットに対応", + "Token 節約のためシステムプロンプトを簡潔化", + "セッション関連の function calling を追加", + "メッセージから前後文脈へジャンプする処理を追加", + "チャット履歴ビューアでセッション索引表示と高速ジャンプに対応", + "設定ダイアログを再構成し、セッション索引設定を追加", + "チャット履歴取り込み時にセッション索引を生成", + "設定ダイアログを再構成", + "基本コンポーネントの操作スタイルを改善", + "ホーム画面の見た目を改善", + "アップデート高速化ロジックを改善", + "高速化ミラーを追加" + ] + } + ] + }, + { + "version": "0.3.1", + "date": "2026-01-09", + "summary": "Discord インポートに対応し、各パーサーで返信メッセージを取り込めるよう改善。保存先もより標準的な場所へ移し、取り込み診断も詳しくなりました。", + "changes": [ + { + "type": "feat", + "items": [ + "テーブル更新処理をメインプロセス側へ移動", + "自動更新確認時に beta 版を無視", + "データ保存先を userData 配下へ移動", + "各パーサーで返信メッセージ取り込みを再対応", + "プラットフォームメッセージ ID と返信 ID に対応し、同時にテーブル移行も実施", + "Tyrrrz/DiscordChatExporter 形式の取り込みに対応", + "member テーブルでロールに対応", + "ChatLab 形式の検出挙動を強化", + "クリック取り込みとドラッグ取り込みの挙動を統一", + "より詳細な形式診断に対応" + ] + }, + { + "type": "fix", + "items": ["一部ユーザーで platformId が空になる問題を修正"] + } + ] + }, + { + "version": "0.3.0", + "date": "2026-01-08", + "summary": "国際化対応をひと通り整え、中国語と英語の切り替えに対応。あわせていくつかの機能も改善しました。", + "changes": [ + { + "type": "feat", + "items": [ + "SQL ラボのエクスポートに対応", + "AI チャットのエクスポートに対応", + "国際化対応をひと通り完了", + "AI モデルエラー時に明示的なエラーを表示", + "SQL 結果からメッセージビューアへジャンプ可能に", + "システム prompt を改善し、prompt マーケットに対応" + ] + } + ] + }, + { + "version": "0.2.0", + "date": "2025-12-29", + "summary": "プロキシ設定に対応し、インポート時のエラーログ表示を追加。あわせて UI 操作と一部機能を改善しました。", + "changes": [ + { + "type": "feat", + "items": [ + "メッセージマネージャーでシステムメッセージ表示に対応", + "インポート処理を改善し、エラー時にログを表示", + "WhatsApp の英語形式メッセージ取り込みに対応", + "プロキシ設定に対応(resolve #7)", + "AI モデル画面の操作性を改善", + "ユーザー設定 API のチュートリアルを追加", + "無料の GLM モデル 2 種を追加し、Doubao プロバイダーと最新モデルを追加", + "AI 応答で think 内容を出力しないよう調整" + ] + } + ] + }, + { + "version": "0.1.3", + "date": "2025-12-25", + "summary": "いくつかの問題を修正しました。", + "changes": [ + { + "type": "fix", + "items": ["Echotrace パーサーの不具合を修正"] + } + ] + }, + { + "version": "0.1.2", + "date": "2025-12-25", + "summary": "ダークモードに対応し、AI チャットのシステムプロンプトでユーザー情報を渡せるようになりました。", + "changes": [ + { + "type": "feat", + "items": [ + "AI チャットのシステムプロンプトでユーザー情報を受け渡し可能に", + "チャット履歴ビューア右側に Owner を表示", + "データベース更新に対応", + "メンバータブで Owner 視点の設定に対応", + "ダークモードに対応" + ] + }, + { + "type": "fix", + "items": ["個人チャットをグループチャットと誤判定する問題を修正"] + } + ] + }, + { + "version": "0.1.1", + "date": "2025-12-24", + "summary": "WhatsApp チャット履歴の取り込みに対応し、旧版 QQ 討論組形式の分析も可能になりました。", + "changes": [ + { + "type": "feat", + "items": [ + "チャットセッション下部に Token 使用量を表示", + "WhatsApp ネイティブ形式メッセージに対応", + "旧版 QQ txt 討論組形式に対応" + ] + }, + { + "type": "fix", + "items": ["メッセージマネージャーの z-index が低すぎる問題を修正"] + } + ] + }, + { + "version": "0.1.0", + "date": "2025-12-23", + "summary": "プロジェクトを公開し、初回リリースしました。", + "changes": [ + { + "type": "feat", + "items": ["init"] + } + ] + } +] diff --git a/changelogs/ja.md b/changelogs/ja.md new file mode 100644 index 000000000..1f9a11471 --- /dev/null +++ b/changelogs/ja.md @@ -0,0 +1,1427 @@ +# 変更履歴 + +## v0.29.0 (2026-07-01) + +> 連絡先機能と関係ギャラクシーを追加し、連絡先計算、アバター読み込み、サイドバー性能を改善しました。 + +### ✨ 新機能 + +- 人間関係モジュールを追加し、連絡先をサブページとして配置。セッション横断の連絡先集計、期間フィルター、ページング、仮想スクロールに対応 +- 連絡先ページでグループメンバーを手動で友達としてマークできるようにし、元の会話への移動とチャット記録表示への入口を追加 +- インタラクション関係ギャラクシーを追加し、3D パノラマ、ノード絞り込み、検索、詳細パネル、関連連絡先/グループの探索に対応 +- 関係ギャラクシーに高関連、友達のみなどの表示フィルターを追加し、プライバシーモードでは名前をマスク +- 【デスクトップ】アプリ更新をバックグラウンドでサイレントダウンロード + +### ⚡ パフォーマンス + +- 連絡先リストをページングと仮想スクロールに変更し、多数の友達やグループメンバーによる描画負荷を軽減 +- アバターを遅延読み込みにし、サイドバーのセッション一覧を仮想化して、起動時やページ切り替え時のもたつきを軽減 + +### 💄 スタイル + +- ダークモードのメイン背景、関係ページのヘッダー、サイドバーの選択状態を統一 + +## v0.28.1 (2026-06-26) + +> チャット記録の表示を改善し、インポート API を追加しました。インポート、同期、ローカルモデルのプロキシダウンロードも修正しています。 + +### ✨ 新機能 + +- Push Import API を追加し、セッション単位の追記、重複排除、増分インデックス生成に対応 +- CLI、デスクトップ、pull sync の各インポート経路で、インポート後にセッションインデックスを自動生成 +- チャット記録ビューアーの操作部とハイライト表示を改善 +- サイドバーのスクロールバーを通常は非表示にし、ホバー時に表示 + +### 🐛 バグ修正 + +- Push/Pull インポートの検証、重複排除、同時実行ロックを強化し、不正な書き込みや重複インポートを防止 +- GET /api/v1/sessions/:id に lastPlatformMessageId と importedAt を追加し、増分インポートの境界判定に対応 +- バックグラウンドの pull sync 完了後、セッション一覧を自動更新 +- ローカルのセマンティックインデックスモデルのダウンロードでプロキシが正しく使われない問題を修正し、Worker ログの初期化を追加 +- 【デスクトップ】API のデフォルトポートを CLI と同じ 3110 に合わせ、使用中の場合は後続ポートを自動で試行 +- 【デスクトップ】システムプロキシの HTTP、HTTPS、SOCKS 結果を解決し、ローカルモデルのダウンロードがプロキシを静かに回避しないよう修正 + +### ♻️ リファクタリング + +- 【CLI】pull/sync のインポート経路を共有 streaming importer に移行し、旧パーサーと書き込み実装を削除 +- 【デスクトップ】増分インポート後の重複したセッションインデックス生成呼び出しを削除 + +### 📝 ドキュメント + +- ドキュメント内の API ポート、サンプル、未実装機能の説明を修正 + +## v0.28.0 (2026-06-25) + +> Google Chat のインポートに対応し、統合ログモジュールを追加しました。セマンティックインデックスの管理操作と設定も改善しています。 + +### ✨ 新機能 + +- CLI・デスクトップで Google Chat Takeout アーカイブのインポートに対応 +- ファイルサイズによる自動ローテーションとグローバルエラーキャプチャを備えた統合ログモジュールを追加 +- セマンティックインデックス管理で「無効化」を「削除」操作に変更し、legacy hash の修正とリスト高さの上限を設定 +- モデル設定の保存時にセマンティックインデックスを自動プリロードし、AI ディレクトリに統合 +- セマンティックインデックス設定から検索結果数の設定項目を削除し、内部デフォルト値を使用するよう簡素化 + +### 🐛 バグ修正 + +- stream-json/stream-chain の依存関係を宣言し、マルチチャットスキャンのフォールスルーを修正 + +### 📝 ドキュメント + +- サポートプラットフォームのドキュメントに Google Chat を追加 + +## v0.27.2 (2026-06-24) + +> AI auth profileのライフサイクル管理に関する複数の問題を修正し、分析サービスの安定性を向上しました。 + +### ✨ 新機能 + +- 【CLI Web】共有AnalyticsServiceを通じてデイリーアクティブユーザーデータを一元送信するよう改善 + +### 🐛 バグ修正 + +- AIサービス設定を削除する際に、対応するauth profileを同時に削除するよう修正 + +### ♻️ リファクタリング + +- 廃止済みの分析設定マイグレーションロジックを削除 + +## v0.27.1 (2026-06-23) + +> APIによるバッチ埋め込みでインデックス構築を高速化し、ローカルモデルの遅延ロードに対応。セマンティックインデックスの再開時のクラッシュ、同期カーソルの不具合、AI会話エクスポートなど複数の問題を修正。 + +### ✨ 新機能 + +- APIプロバイダーによるバッチ埋め込みに対応し、インデックス構築を大幅に高速化 +- ローカル埋め込みモデルを遅延Workerロードに変更し、起動時のリソース消費を削減 + +### 🐛 バグ修正 + +- AI会話エクスポートを修正:現在表示中のAI会話を正しくエクスポート可能に +- セマンティックインデックスのバッチ書き込み途中でクラッシュした後の再開時に、一意制約エラーが発生してセッションが停止する問題を修正 +- セマンティックWorkerの複数の問題を修正:設定変更が実行中のWorkerに反映されない、可用性プローブのエラーが通常のAIチャットに影響する、アクティブビルド状態の追跡が不正確でWorkerが早期終了する +- 同期プルのカーソル修正:空レスポンス時のリトライ前に初期ページのサーバー水位が保存されず、末尾ウィンドウが繰り返し取得される問題を修正 +- 同期プルのページネーションカーソルが誤って戻る問題を修正し、重複インポートを防止 + +### ♻️ リファクタリング + +- デッドコードと過剰な抽象化を削除し、ツールカタログ構造とディープマージロジックを整理 + +## v0.27.0 (2026-06-22) + +> ベクトルインデックスと証拠検索機能を追加。セマンティック検索でチャットの根拠を特定・表示できるようになり、インデックス管理画面も新設。 + +### ✨ 新機能 + +- セマンティックインデックス管理画面を追加。対話を選択してベクトルインデックスを構築でき、多言語対応済み +- retrieve_chat_evidence ツールを追加:AI がセマンティック検索で時刻情報付きのチャット証拠を取得可能に +- プランナーが証拠関連の質問を自動検出し、セマンティック検索ツールへルーティング +- 証拠ブロックの展開・折りたたみと、元のメッセージへのクリックジャンプに対応 +- セマンティック検索に時間範囲フィルターを追加 +- セマンティックインデックスのモデル選択画面をリデザイン +- AI 処理過程のセグメントを折りたたみ可能に +- 証拠ソース行と Agent ツール呼び出し結果の表示を簡略化 + +### 🐛 バグ修正 + +- 複数キーワードによるメッセージ検索を修正 +- ワードクラウドの辞書読み込みにおける UX 回帰を修正 +- ランキングラベルと絵文字クリーニングの問題を修正 +- v8 データベース移行時の i18n テキスト欠落を修正 +- 【CLI】ツールアダプターでのセッションコンテキスト転送と前処理の欠落を修正 + +## v0.26.3 (2026-06-15) + +> ディスクキャッシュと古いリクエストのキャンセルにより分析パフォーマンスを改善。セッション切り替えがよりスムーズに。ワードクラウド・頻出ワード統計・タイムラインパネルなど複数の不具合を修正。 + +### ✨ 新機能 + +- 【CLI Web】ファビコンを追加 + +### ⚡ パフォーマンス + +- 分析結果をデータベースファイルのバージョンをキーとしてディスクにキャッシュし、2回目以降の読み込み速度を大幅に向上 +- セッションの切り替えやフィルター変更時に古い分析リクエストを自動キャンセル +- ワードクラウドの表示語数変更時に再分かち書きをスキップし、不要な処理を削減 + +### 🐛 バグ修正 + +- 頻出ワード統計にプラットフォームの返信プレースホルダーが含まれる問題を修正 +- ワードクラウドのテキストにメディアプレースホルダーが含まれる問題を修正 +- Jieba カスタム辞書変更後に言語設定キャッシュと単語頻度キャッシュが無効化されない問題を修正 +- 時間依存の分析キャッシュが1日ごとに期限切れにならない問題を修正 +- タイムラインパネルのデフォルトスクロール位置とセッションジャンプが機能しない問題を修正 +- セグメントメッセージのコンテキストが欠落する問題を修正 +- AI の会話数が常に 0 を返す問題を修正 +- ホーム画面のチュートリアルリンクがローカライズされたパスを使用していない問題を修正 +- AI チャットパネル上部のスクリーンショットボタンを削除 +- 【デスクトップ】開発モードでの 504 エラーを防ぐため、遅延ロードルートの依存関係を事前バンドル +- 【CLI】パッケージバージョンの不一致による誤った更新通知を修正 +- 【CLI】アップデートプロンプトで矢印キーによる選択をサポート + +### ♻️ リファクタリング + +- タイムラインパネルのラベルを「サマリー」に変更 + +## v0.26.2 (2026-06-13) + +> 「自分は誰か」セッションのオーナー識別機能を改善。手動でプロフィールを設定し、同プラットフォームのセッションへ自動一括適用できるように。チャートレンダリングと AI キャッシュの不具合を複数修正。 + +### ✨ 新機能 + +- 「自分は誰か」オーナー識別機能を追加:確認後にプラットフォームのオーナープロフィールを保存し、同プラットフォームの未設定セッションへ自動で一括適用 +- インポートや pull sync 完了後に保存済みのオーナープロフィールを自動で適用し、手動設定の手間を削減 + +### 🐛 バグ修正 + +- name-match プラットフォーム(WhatsApp / Line / Instagram)での一括適用時に ownerId のキャッシュとデータベース書き込みが一致しない問題を修正 +- 複数のルートが独立した PreferencesManager インスタンスを使用することでオーナープロフィールが古い値に上書きされる問題を修正 +- 【CLI】長期起動時に sync モジュールの preferences キャッシュが古くなり、pull インポート後にオーナープロフィールが反映されない問題を修正 +- インポート後に一時ファイルが完全に削除されない問題を修正 +- ストリーミング生成中に AI チャートがちらつく問題を修正 +- AI チャートのレンダリング順序が不安定になる問題を修正 +- AI チャートに不要な空白行が表示される問題を修正 +- AI チャート生成中の進捗表示を追加 +- スクリーンショット前にチャートのサイズが調整されず表示が不完全になる問題を修正 +- last_message_time の意味を修正:グループの最終活動時間ではなくデータのカバー終了時刻を示す +- 増分インポート後に概要と分析のキャッシュが無効化されず古いデータが表示される問題を修正 + +## v0.26.1 (2026-06-10) + +> ツール呼び出し履歴の再生に関する複数の問題を修正し、ツール呼び出しの永続化と再生に対応。ステータスバーへの Token キャッシュ統計表示とメッセージの複数形式エクスポートを追加。 + +### ✨ 新機能 + +- チャットステータスバーに Token キャッシュのヒット/ミス使用量を表示 +- メッセージのエクスポートで TXT・JSON・Markdown 形式に対応 + +### 🐛 バグ修正 + +- ツール呼び出し記録の永続化と再生に対応し、複数ターンの AI コンテキストを維持 +- テキスト出力のないアシスタントターンが履歴再生でフィルタリングされ、ツール結果が失われる問題を修正 +- 同一セッションを連続エクスポートする際、ファイル名の衝突でサイレント上書きされる問題を修正 +- Desktop: 大規模セッションのエクスポートが 60 秒の IPC タイムアウトで失敗する問題を修正 +- DeepSeek のツール呼び出しターンで reasoning_content が履歴再生されない問題を修正 +- 履歴圧縮時にリプレイされたツール結果の token 数が正しく計上されない問題を修正 +- ツール結果としてモデルに送信されるテキストに、公開すべきでない生メッセージデータが含まれる問題を修正 +- ツールパネルが外側クリックで自動的に閉じない問題を修正 + +### ♻️ リファクタリング + +- セッションページのヘッダーを共有コンポーネントとして切り出し + +## v0.26.0 (2026-06-10) + +> AI 分析プランナーを追加し、ストリーミング形式の構造化プラン生成とチャート計画の連携に対応。チャートモードのツール可用性を改善し、AI とインポートに関する複数の問題を修正しました。 + +### ✨ 新機能 + +- AI 分析プランナーを追加し、ストリーミング形式の構造化分析プラン生成とブロックレンダリングに対応しました +- チャート計画のサポートを統合し、AI 分析プランから自動的にチャート生成へ移行できるようにしました +- 起動コンテキストと拡張データスナップショットをプランナーに組み込み、分析の深度と精度を向上しました +- チャートレンダリング時に生の SQL より高レベルのツールを優先使用し、必要な権限を低減しました +- AI 分析とチャート全体の動作を改善しました +- チャートの自動モード設定オプションを追加しました +- メッセージ ID のコピー操作を追加しました +- シャドウルーティングへの LLM フォールバックを追加し、ルーティング決定をログに記録するようにしました +- 【CLI】エージェントのストリームイベントをすべて公開しました + +### 🐛 バグ修正 + +- チャートスキルを明示的に選択した際に render_chart ツールが消える問題を修正しました +- 明示チャートモードでのツール可用性の欠落とエラー後のプラン状態の不整合を修正しました +- ECharts 6 の containLabel 設定を移行し、円グラフの重複凡例を非表示にしました +- 設定ページのナビゲーション順序、skillSettings キー名、SubTabs の折り返し問題を修正しました +- AI アシスタントのメッセージがチャット領域の幅全体を埋めない問題を修正しました +- 分析プラン生成ブロックの表示スタイルを改善しました +- 再起動後に思考レベルの選択が保持されない問題を修正しました +- JSONL のタイムスタンプ正規化により差分インポートが失敗する問題を修正しました +- 【デスクトップ】execute_sql ツールがデスクトップのツールレジストリに登録されない問題を修正しました + +### test + +- エージェントのルーティング決定に関する評価セットを追加しました + +## v0.25.1 (2026-06-08) + +> データディレクトリの互換性ゲートを導入し、古いランタイムによるデータの上書きを防止。CLI に ai chat コマンドを追加しました。 + +### ✨ 新機能 + +- データディレクトリ互換性ゲートを追加:v6 スキーマのデータ書き込み後に最低互換ランタイムバージョンを記録し、古いバージョンによる上書きを防ぎます +- データベース操作前に互換バージョンを検証し、要件を満たさない場合はアクセスを拒否します +- 【デスクトップ】起動時にデータディレクトリの互換バージョンを確認し、要件を満たさない場合はエラーダイアログを表示して終了します +- 【CLI】起動時にデータディレクトリの互換バージョンを確認し、要件を満たさない場合は即座に終了します +- 【CLI】ai chat コマンドを追加し、ターミナルから AI とのマルチターン対話が可能になりました + +### 🐛 バグ修正 + +- 互換性ゲートの書き込みに Electron 開発モードの 0.0.0 ではなくパッケージ版バージョン番号を使用するよう修正し、ランタイム ID を必須化しました +- データベース移行とインポート完了後に互換性ゲートを正しく書き込むよう修正し、起動時の移行失敗は即座にエラーとして報告するようにしました +- 【MCP】起動時に互換バージョンを確認し、要件未満の場合は早期終了して不整合なデータの書き込みを防ぎます +- 【デスクトップ】HTTP API で互換性ゲートエラーを 409 レスポンスにマッピングし、フロントエンドでアップグレード案内を表示しやすくしました +- 【CLI】AI ツール名を正規化し、ai chat コマンドのエッジケースを修正しました + +### ♻️ リファクタリング + +- AI チャットと segment 識別子の命名を統一しました + +### test + +- パーサー、設定移行、データベース移行、HTTP ルート、AI ツールのテストカバレッジを追加しました + +### 📝 ドキュメント + +- データディレクトリ互換性ゲートの公開ドキュメントを追加し、バージョン間でデータディレクトリを共有する際の制限とアップグレード手順を説明しました + +## v0.25.0 (2026-06-07) + +> AI チャットでスキルからグラフを生成できるようになり、ツール実行回数、デスクトップのタイトルバー、開発サービスのライフサイクルに関する問題を修正しました。 + +### ✨ 新機能 + +- スラッシュ操作で起動できる AI グラフ実行環境を追加し、会話内で ECharts グラフを生成・表示できるようにしました +- AI グラフ結果の表示体験を改善し、レンダリング状態と複数のグラフ種別への対応を追加しました + +### 🐛 バグ修正 + +- AI Agent の既定のツール実行回数を増やし、複雑なタスクが途中で止まりにくくしました +- プリセット質問チップとサイドバー要素の重なり順が崩れる問題を修正しました +- 【CLI Web】開発バックエンドのライフサイクル cleanup が不完全になる問題を修正しました +- 【デスクトップ】自動スキルでグラフツールが登録されず、グラフスキルを正しく実行できない問題を修正しました +- 【デスクトップ】Windows のタイトルバー overlay がテーマ切り替え後にキャッシュを更新せず、表示が不安定になる問題を修正しました + +### ♻️ リファクタリング + +- AI グラフ実行ポリシーを一元化し、CLI とデスクトップでのグラフツール有効化ルールを統一しました +- スキルメニューと Skill Manager の重複ロジックを整理しました + +### 📝 ドキュメント + +- iMessage のチャット履歴エクスポート手順を更新しました + +### 🔧 雑務 + +- 公開リポジトリからメンテナー専用スキルを削除し、非公開のメンテナンスコンテキストで管理するようにしました + +## v0.24.1 (2026-06-04) + +> アプリ内更新通知と AI 前処理のデフォルトルールを追加し、更新バッジと匿名化設定の保存に関する問題を修正しました。 + +### ✨ 新機能 + +- アプリ内の更新通知入口を追加し、サイドバーから最新バージョンの内容を確認できるようにしました +- AI 前処理のデフォルト設定を追加し、データクリーニング、ノイズ除去、匿名化の基本ルールを自動で有効化しました +- AI 匿名化ルールをグループ単位で管理できるようにし、組み込みルールとカスタムルールを扱いやすくしました + +### 🐛 バグ修正 + +- 更新チェック失敗時の結果、古いキャッシュ、CLI Web の開発用プレースホルダーバージョンによって New バッジが誤表示される問題を修正 +- AI 前処理の実行前に組み込み匿名化ルールが正しく適用されない問題を修正 +- 匿名化ルールの設定を空の上書きとして保存した際、以前の組み込みルール切り替えが消えない問題を修正 +- 旧形式の組み込み匿名化ルールを移行した後、上書き設定が失われる問題を修正 + +### 📝 ドキュメント + +- 公開開発ガイドを追加し、ローカル開発、ディレクトリの役割、協作ルールを整理しました + +### 👷 CI + +- リリースフローに Markdown 版の更新履歴リンクを追加しました + +## v0.24.0 (2026-06-03) + +> CLI Web の認証とデータディレクトリ移行に対応し、複数環境の HTTP ルートを統一。データ移行、AI設定、インポート後の更新に関する問題を修正しました。 + +### ✨ 新機能 + +- 多言語リリースノートから Markdown を生成できるようにしました +- ストレージ管理で旧データ移行の案内を無視できるようにしました +- 【CLI Web】ログインページ、Token 認証、ログイン状態の保持に対応 +- 【CLI Web】Web 設定からデータディレクトリを切り替え、移行できるようにしました +- 【CLI】/\_web/\* API へのアクセスを保護する --require-auth フラグを追加 +- 【デスクトップ】内部 HTTP サービスを追加し、フロントエンドから共有サービスアダプター経由で共通 API を利用できるようにしました + +### 🐛 バグ修正 + +- データディレクトリ移行とデータベース移行の複数の境界ケースを修正し、旧データベースの列不足やパス切り替え後の読み取り失敗を防止 +- 旧データが存在しない場合やディレクトリ変更後にも移行案内が誤って表示される問題を修正 +- 増分インポート後にサイドバーのメッセージ数が更新されない問題を修正 +- サイドバーの折りたたみ状態が sessionStorage のみに保存され、再読み込み後に失われる問題を修正 +- 保存済みキーで AI 設定を編集する際、モデル取得と検証ボタンが正しく有効化されない問題を修正 +- 共有 AI SSE ストリーミングの安定性に関する問題を修正 +- カスタムデータソース追加時に Token が必須になっていなかった問題を修正 +- 【デスクトップ】グラフプラグインの計算を Worker に移し、メインスレッドのブロックを回避 + +### ♻️ リファクタリング + +- @openchatlab/http-routes 共有パッケージを抽出し、CLI Web とデスクトップの HTTP ルート実装を統一 +- AI 設定、アシスタント、スキル、会話、ストリーミングレスポンス、キャッシュ、マージ関連 API を共有 HTTP ルートへ移行 +- デスクトップの IPC ブリッジを整理し、旧 AI、セッションインデックス、LLM、Assistant、Skill、NLP などの互換ハンドラーを削除 +- フロントエンドのサービス層を統一し、Electron / Web モード間の分岐を削減 + +### ⚡ パフォーマンス + +- メインプロセスのビルド成果物を圧縮し、tiktoken rank table を遅延読み込みにして、起動時とパッケージサイズの負荷を軽減 + +### 👷 CI + +- Windows リリースフローの zstd キャッシュ問題を修正し、CLI 更新に関するリリースノートを追加 + +### 🔧 雑務 + +- Node の型チェック対象を調整し、デスクトップと共有 Node コードをカバーするようにしました + +## v0.23.1 (2026-06-01) + +> clb コマンドの短縮エイリアスを追加し、ポート競合を起動前に検出して案内するようにしました。デーモンのサイレント終了やダークモードのタイトルバーなど複数の問題を修正しました。 + +### ✨ 新機能 + +- ページ上部のタイトルバーとツールバーのレイアウトを改善 +- 【CLI】起動前にポートの使用状況を確認し、競合時にポート変更や lsof コマンドを案内するよう変更(遅延エラーの解消) +- 【CLI】chatlab コマンドの短縮エイリアスとして clb を追加 + +### 🐛 バグ修正 + +- サイドバーの Tooltip 位置のズレと Nuxt UI v4 API の互換性問題を修正 +- ダークモード時のタイトルバーに赤い背景と重なり順の誤りが生じる問題を修正 +- AI メッセージのロールパラメータの型を整理し、会話テストのアサーションを強化 +- 【CLI】デーモンの起動エントリが誤っていたため、サービス起動後にサイレント終了する問題を修正 +- 【CLI】ポート競合検出のエラー処理とメッセージを改善 +- 【CLI】Web モードで chatlab.fun へのリバースプロキシルートが欠落していた問題を修正 + +## v0.23.0 (2026-05-31) + +> メッセージ編集をフォーク/再生成モデルに刷新し、モデルごとの推論レベル設定を追加。CLIの起動コマンドを統一し、推論検出・計算に関する複数の問題を修正。 + +### ✨ 新機能 + +- AI返答の任意の箇所から会話を分岐できるFork機能を追加 +- ステータスバーに推論強度セレクターを追加し、モデルごとに思考レベルを記憶・切替可能に +- 推論レベル制御にdefault/autoオプションを追加し、Kimi・Doubao・Geminiなど多数のモデルファミリーに対応 +- メッセージ分析ビューを「タイプ分析」と「時間分析」の2タブに分割し、統計インサイトカードを強化 +- セッションインデックスの一括再生成前に確認ダイアログを追加し、既存サマリーの誤削除を防止 +- デモデータを4ファイルに拡充し、グループおよび複数のプライベートチャットシナリオをカバー +- 【CLI】統一起動コマンド chatlab start を追加。--headlessおよび--no-openフラグに対応 +- 【CLI】chatlab start --daemonでシステムサービスとして登録するデーモンモードを追加。stopおよびstatusコマンドも利用可能 + +### 🐛 バグ修正 + +- メッセージ編集時にデータ損失やステート不整合を引き起こす可能性のある並行処理の問題を複数修正 +- 思考レベルとコンテキストウィンドウの計算でアクティブなモデルIDが使用されない問題を修正 +- chat能力のみを持つカスタムモデルで推論検出が失敗する問題を修正し、ヒューリスティックフォールバックを追加 +- KimiやDoubaoなどのモデルでautoレベルを選択すると思考が無効化される問題を修正 +- 【CLI】startコマンドがWebバックエンドを起動しない問題を修正 +- 【CLI】Linuxでサービスパスにスペースが含まれる場合にデーモン起動が失敗する問題を修正 + +### ♻️ リファクタリング + +- メッセージ分岐システムを編集・再生成モデルに刷新。現在のラウンドのみの更新と後続メッセージの上書きの2モードに対応 +- モデル設定の「推論モデル」「思考無効化」トグルを廃止し、モデルの能力に基づく自動推論に変更 +- モデル切替ボタンのUIを簡素化し、セッションインデックスの読み込みパフォーマンスを改善 + +## v0.22.1 (2026-05-29) + +> セッションサマリーの詳細レベル設定を追加。CLI Web のバッチサマリーフリーズ、AI設定編集時の誤判定、API認証情報の検出に関する複数の問題を修正。 + +### ✨ 新機能 + +- セッションサマリーの詳細レベル設定を追加。「簡潔」と「標準」の2種類から選択でき、AI設定で切り替え可能 + +### 🐛 バグ修正 + +- OpenAI互換モードでBase URLを変更した後に認証情報が再検証されない問題を修正 +- APIキー設定済みの判定ロジックを修正し、古いキーの誤再利用を防止 +- バッチサマリー生成中に停止ボタンが即座に反応しない問題を修正 +- 【CLI Web】バッチサマリー生成によってページがフリーズする問題を修正 +- 【CLI Web】バッチサマリー生成時に選択済みセッションの範囲が反映されない問題を修正 +- 【CLI Web】AI設定の編集時にサードパーティサービスがローカルサービスと誤判定される問題を修正 + +### ♻️ リファクタリング + +- セッションインデックスのi18nキーをstorageネームスペースからaiネームスペースへ移動 + +### 💄 スタイル + +- チャット記録リストの密度とメッセージバブルのスタイルを改善 + +### 📝 ドキュメント + +- ドキュメントサイトのナビゲーション構造を整理し、クイックスタートをUsageセクションへ移動 + +## v0.22.0 (2026-05-26) + +> 既定状態の UI スタイルを改善し、CLI Web の更新・ストレージ管理を追加。ホームのインポート、ドキュメントサイト、複数プラットフォームの安定性も改善しました。 + +### ✨ 新機能 + +- ホームのインポートエリアを入口別に整理し、API インポートと自動同期の入口を追加 +- 更新履歴をアプリに同梱し、実行時のリモート依存を削減 +- 【CLI Web】Web 設定でデータキャッシュを確認・管理できるストレージ管理機能を追加 +- 【CLI】更新チェックと自動更新フローを追加し、CLI Web の更新チェックにも対応 +- 【ドキュメント】独立したドキュメントサイト docs.chatlab.fun を追加 + +### 🐛 バグ修正 + +- ホームのクイックスタートボタンが表示されない問題を修正 +- i18n key パスの誤りと、非推奨の ECharts api.style() 使用を修正 +- 自己更新とマイグレーション再試行フローの安全保護を強化 +- 【デスクトップ】統一マイグレーションでデータディレクトリの変更が戻る可能性がある問題を修正 +- 【CLI Web】新しいバージョン検出時に必ず失敗する「今すぐ更新」操作を表示しないように修正 +- 【CLI Web】利用できない Web 自己更新の実行フローを無効化 +- 【CLI Web】ファイル管理操作が shell 実行に依存していた問題を修正し、互換性と安全性を改善 +- 【CLI Web】データディレクトリ警告後も Web サービスを継続できるように修正 +- 【CLI Web】マージ関連 API の互換レイヤーを補完 +- 【CLI】更新チェックの非同期キャッシュ、キー入力の操作性、開発モードのスキップ処理を改善 + +### ♻️ リファクタリング + +- ドキュメントリンクを docs.chatlab.fun へ移行 +- 【設定】セッションインデックスを AI 設定へ移動し、設定タブの並び順を調整 + +### 💄 スタイル + +- サイドバーの密度を調整し、ダークモードでのタブ選択 UI のコントラストを改善 + +### 📝 ドキュメント + +- 公開ドキュメントサイトの構成とエクスポート説明を整理 + +### 🔧 雑務 + +- ワークスペースパッケージを ESM に移行 +- ドキュメントサイトのワークスペース依存関係を分離 +- リリース更新履歴を docs ディレクトリ外へ移動 + +## v0.21.1 (2026-05-23) + +> Pull 同期の信頼性とデータの安全性を改善し、サブスクリプション削除時にインポート済みチャットをクリーンアップするオプションを追加。UI アニメーションとモーダルの操作に関する問題を修正しました。 + +### ✨ 新機能 + +- Pull 同期後にセッションインデックスを自動生成 +- サブスクリプション削除時にインポート済みチャットを削除するオプションを追加 +- 【CLI Web】更新履歴モーダルでバージョンごとのスクリーンショットに対応、Markdown リスト修正をオプションに変更 +- 【MCP】ci 変更タイプのアイコンと多言語対応を追加 + +### 🐛 バグ修正 + +- Pull 同期時の小ページでデータが失われる可能性がある問題を修正し、リトライインポート結果の検証を追加 +- Pull 同期完了後にセッションインデックスが自動生成されない問題を修正 +- Pull 完了またはデータ削除後にサイドバーのセッションリストが更新されない問題を修正 +- リモートサーバーがページネーション未対応の場合に全セッションを取得できない問題を修正 +- Pull 同期のリトライロジックとページネーション戦略を最適化し、安定性を向上 +- セッション存在チェック時のスキーマ検証が不足し、テーブル未検出エラーが発生する問題を修正 +- 強制セッションインデックス生成モーダルが生成失敗時に予期せず閉じる問題を修正 +- セッションが空の状態でセッションインデックスモーダルがページをブロックする問題を修正 +- 【デスクトップ】app.getVersion が 0.0.0 を返す場合に package.json のバージョンにフォールバック +- 【CLI Web】独立したローダーを表示せず、同期アイコンをその場で回転アニメーションに変更 + +### 📝 ドキュメント + +- Pull プロトコルのドキュメントを since+nextSince ページネーションモデルに更新 + +## v0.21.0 (2026-05-22) + +> MCP 対応を追加し、複数プラットフォームのインポートとサービス層を統一しました。Web、AI、同期、リリースの安定性も改善しています。 + +### ✨ 新機能 + +- Electron と Web モードで共通のフォルダーインポートフローに対応し、複数ファイルのチャット履歴形式を扱えるように改善 +- 【MCP】独立したコマンドエントリを追加し、設定画面から MCP 設定を扱えるように改善 +- 【MCP】Server のツール数を 19 個に拡張し、コンパクトなテキスト出力と JSON 出力に対応 + +### 🐛 バグ修正 + +- ディレクトリインポートのパス処理を強化し、インポート失敗のリスクを低減 +- 新しいサブスクリプション追加時に発生する可能性がある同期の競合を修正 +- MiniMax のストリーミング応答に含まれる 内容を思考イベントとして正しく処理するように修正 +- 【CLI Web】差分インポートが使えない問題を修正 +- 【CLI Web】セッション未検出やメンバー履歴の挙動がデスクトップ版と一致しない問題を修正 +- 【CLI Web】開発サーバーの Node ランタイム設定を修正 +- 【MCP】起動時のネイティブモジュール ABI バインディング不一致を修正 + +### ♻️ リファクタリング + +- CLI Web と Electron の共通サービス層を統一し、ルートと IPC に重複していた業務ロジックを削減 +- 【MCP】外部に公開するツール登録を整理し、外部 AI Agent 向けのツール schema コストを削減 +- 【MCP】中核機能を独立した共通パッケージへ抽出し、CLI とデスクトップ版の連携を簡素化 +- 【MCP】設定画面への統合方法を整理し、デスクトップ側の補助コードを簡素化 + +### 👷 CI + +- 【CLI】リリースフローで npm パッケージをあわせて公開できるように改善 + +### 📝 ドキュメント + +- プラットフォーム固有の changelog 項目に使う接頭辞と並び順のルールを補足 + +### 🔧 雑務 + +- 【CLI】【MCP】npm 公開に必要な設定とリリース説明を整備 + +## v0.20.0 (2026-05-19) + +> 今回の更新では、複数プラットフォーム向けの中核アーキテクチャを統一し、AI、インポート、同期、デスクトップ版ビルドの安定性を改善しました。次回バージョンで提供予定の独立した Web、CLI、MCP 機能に向けた準備も含まれるため、CLI を使う前に本バージョンへ更新してデータの事前移行を完了してください。 + +### ✨ 新機能 + +- 独立した CLI、HTTP API サービス、MCP Server の基盤を追加し、コマンドライン、Web、AI エージェント連携に対応 +- 独立した Web ビルドとワンコマンド起動を追加し、CLI から Web UI を起動してブラウザを自動で開けるように改善 +- Web モードでチャット履歴のインポート、Demo インポート、セッション照会、メンバー照会、検索、分析などの主要フローに対応 +- Web モードで AI チャット、モデル設定、カスタム Provider/Model、コンテキスト圧縮、ストリーミングイベント表示に対応 +- インポート処理を共通のストリーミングパイプラインへ更新し、複数形式の解析、差分インポート、インポート分析、セッションインデックスの自動生成に対応 +- マージ処理、Markdown エクスポート、セッションキャッシュに関するサーバー機能を追加 +- 今後の複数プラットフォーム同期に向けて、共通同期パッケージと CLI 自動化サポートを追加 +- バックエンドでの設定永続化を追加し、CLI 初回起動時にデスクトップ版データを自動検出して移行できるように改善 + +### 🐛 バグ修正 + +- Web モードのセッションインデックス、CORS プロキシ、Demo 保護、実行時エラーに関する問題を修正 +- Web モードでアプリのバージョンが空表示になる問題を修正 +- AI Agent の根拠取得、Web 側イベントストリーム、エラー整形に関する問題を修正 +- 同期表示とインポート処理の不整合を修正 +- CLI 開発モードでの ESM モジュール解決問題を修正 +- CLI インストール後に既存のデスクトップ版データを見つけられない場合がある問題を修正 +- Electron パッケージ分割後のデータ移行パス不整合を修正 +- パッケージ版で Worker スレッドが electron モジュールへ間接依存してクラッシュする問題を修正 +- Electron のビルド、依存関係のインストール、better-sqlite3 ネイティブモジュールの再ビルドに関する問題を修正 + +### ♻️ リファクタリング + +- プロジェクトを複数プラットフォーム向けのワークスペース構成へ移行し、apps/desktop、apps/cli、複数の共通 packages に分割 +- パーサー、設定、データベースアダプター、クエリ、マイグレーション、NLP、セッションキャッシュ、インポート、マージ、エクスポート、同期処理を共通モジュールへ抽出 +- AI Agent、ツールシステム、前処理、コンテキスト圧縮、RAG、LLM 設定、アシスタント、スキル管理を共通ランタイム機能として抽出 +- Electron、CLI Web、MCP の AI ツール名、ツール登録、データアクセス方法を統一 +- データディレクトリ、メッセージ照会、メンバー照会、セッションインデックス、SQL 実行、インポート重複排除などの中核ロジックを統一 +- CLI を packages/server から apps/cli へ移行し、npm 公開向けのビルドフローを整備 +- フロントエンド専用のチャートモジュールを src/features へ移動し、packages には再利用可能な共通ライブラリのみを配置 +- Electron 側の単純な転送ファイルと不要になったコードを削除し、重複実装を削減 + +### build + +- Vite などのビルドツールチェーンを更新 +- CLI の tsup ビルド、Web アセットのバンドル、npm 公開に必要な設定を追加 +- Electron デスクトップ版のビルド設定を調整し、Linux デスクトップ版のビルド対象を削除 + +### 📝 ドキュメント + +- バージョン運用、コミット scope のルール、関連する開発ドキュメントを補足 + +## v0.19.0 (2026-05-06) + +> AI コンテキストの自動圧縮に対応し、Demo サンプルを追加しました。あわせてモデル設定とデバッグ体験を改善しています。 + +### ✨ 新機能 + +- モデルのコンテキストウィンドウのプリセットとカスタム設定に対応しました。 +- AI チャットのコンテキスト自動圧縮と関連設定に対応しました。 +- コンテキスト圧縮の流れとステータス表示を改善しました。 +- デバッグモードで元データを確認できるようにし、完全な LLM コンテキストの記録に対応しました。 +- AI モデル設定の文言、フォーム、設定画面の表示を改善しました。 +- ベクトルモデル設定を削除し、関連設定を整理しました。 +- 新規ユーザーが空の状態から Demo サンプルを直接確認できるようにしました。 +- プロジェクト構成を pnpm workspace に移行しました。 + +### 🐛 バグ修正 + +- 高速モデルがデフォルトアシスタントに追従する際、一部のケースで誤ったモデルを使う問題を修正しました。 +- データが空の状態で Demo ボタンが表示されない問題を修正しました。 +- メインプロセスで未宣言の axios に直接依存し、起動に失敗する可能性がある問題を修正しました。 + +## v0.18.4 (2026-04-29) + +> 今回の更新では、モデルの安定性を最適化し、リモートのモデル一覧取得に対応しました。あわせてAIのエラー詳細表示と一部のスタイルを改善しました。 + +### ✨ 新機能 + +- リモートのモデル一覧取得に対応しました。 +- OpenAI 互換 API アドレスの /v1 自動補完とリアルタイムプレビューに対応しました。 +- AI チャットのエラー詳細表示を改善しました。 +- 一部の表示スタイルを改善しました。 + +### 🐛 バグ修正 + +- 一部のロジックの脆弱性を修正しました。 + +### 🔧 雑務 + +- 同期ログスキルのロジックを最適化しました。 + +## v0.18.3 (2026-04-28) + +> クイックツール入口の配置設定に対応し、デフォルトの時間フィルターを改善しました。あわせて、モーダルの重なり順とデータ保存先の安全案内も修正しています。 + +### ✨ 新機能 + +- クイックツール入口の配置を設定できるようにしました。 +- デフォルトの時間フィルターの使い勝手を改善しました。 +- データ保存先をアプリのインストールディレクトリ内に設定できないようにしました。 + +### 🐛 バグ修正 + +- モーダルのスタイルが上書きされる問題を修正しました。 +- 設定ページ内でモーダルが背面に隠れる重なり順の問題を修正しました。 + +## v0.18.2 (2026-04-26) + +> 購読時の種類選択、リモート会話のページ分割取得、1 回あたりの取得件数設定に対応しました。 + +### ✨ 新機能 + +- 会話を購読する際に、種類で絞り込んで選択できるようにしました。 +- リモート会話のページ分割取得と、必要に応じた追加読み込みに対応しました。 +- データソースごとに、1 回の取得で読み込むメッセージ件数を設定できるようにしました。 + +## v0.18.1 (2026-04-24) + +> DeepSeek V4 への対応と自動起動オプションを追加し、設定まわりと全体の UI 体験を改善しました。あわせて、AI チャット内リンクの開き方も修正しています。
このバージョンから、プロジェクトは正式に ChatLab 組織へ移行します。 + +### ✨ 新機能 + +- プロジェクトを ChatLab 組織へ移行 +- 全体のスタイル表現を改善 +- クイック質問の表示ロジックを改善 +- 設定モーダルの開き方を改善 +- 自動起動に対応 +- DeepSeek V4 モデルに対応 + +### 🐛 バグ修正 + +- AI チャット内のリンクがブラウザで開かない問題を修正 + +## v0.18.0 (2026-04-23) + +> AI チャットの操作性を改善し、複数の分析導線を整理するとともに、データソース同期と Windows 更新の安定性を向上しました。 + +### ✨ 新機能 + +- AI アシスタントの操作フローを改善 +- データソース追加フォームの案内文を調整 +- Tool Panel に Mini モードを追加 +- チャット履歴ビューアを Tool Panel へ移動 +- 関係分析まわりのタブ構成を統一 +- Quotes モジュールを Insights へ移動 +- キーワード分析を Labs へ移動 + +### 🐛 バグ修正 + +- 既存の型警告を修正 +- Pull の増分同期に 60 秒の重複ウィンドウを追加し、メッセージ取りこぼしを防止 +- Pull リクエストの `limit=1000` を固定し、リモートデータソースの一括出力による重さを抑制 +- Windows 更新時に NSIS ポップアップでサイレントインストールが中断される問題を修正 + +## v0.17.5 (2026-04-21) + +> 本リリースでは多数の不具合を修正し、全体の安定性を改善しました。 + +### ✨ 新機能 + +- 関係カードのスタイルを改善しました。 +- `node-machine-id` 依存をネイティブなマシン識別ロジックに置き換え、Linux での API キー更新の安定性を向上しました。 +- チャット履歴の統合時に、元データを保持するオプションを追加しました。 +- プリセットサービスとサードパーティサービスの API エンドポイント横に検証ボタンを追加しました。 + +### 🐛 バグ修正 + +- dataSource の移行戦略を見直し、移行の安全性を向上しました。 +- メッセージ数が少ない話題で空状態の表示が崩れる問題を修正しました。 +- ローカルモデルの検証が失敗する問題を修正しました。 +- 会話を切り替えた後に選択中タブがリセットされる不具合を修正しました。 +- 旧 dataSources 構造からのアップグレード後に自動化ページが白画面になる問題を修正しました。 + +## v0.17.4 (2026-04-19) + +> Import API v1 の完全な仕様実装と階層型データソース管理を追加し、チャット履歴の自動同期に対応しました。 + +### ✨ 新機能 + +- Import API v1 の完全な仕様と階層型データソース管理を実装 + +## v0.17.3 (2026-04-17) + +> 今回の更新では、プライベートチャットに言語設定タブを追加し、サイドバーの会話リストで並び替えと絞り込みに対応。AIプロバイダーとモデル設定を拡充し、時間フィルターがリセットされる不具合を修正しました。 + +### ✨ 新機能 + +- 会話リストで並び替えと絞り込みに対応 +- 言語設定タブを追加し、言語設定を確認可能に +- UIスタイルの細部を調整し、視覚的一貫性を向上 +- AIプロバイダーに Anthropic を追加 +- モデルのサードパーティサービスでインターフェース種別の選択に対応 +- AIモデル設定でカスタム名の設定に対応 + +### 🐛 バグ修正 + +- 設定画面またはAIチャット画面から戻った際に、時間フィルターが「すべて」にリセットされる問題を修正 + +### ♻️ リファクタリング + +- 言語設定を共通型として切り出し、重複定義を削減 + +## v0.17.2 (2026-04-15) + +> 今回の更新では、クロスプラットフォームのデータ統合とメンバーメッセージ統合を追加し、辞書と更新処理の安全性チェックを強化。ダークモード体験とログ機能を改善し、複数の不具合を修正しました。 + +### ✨ 新機能 + +- メンバー管理でメンバーメッセージの統合に対応 +- プラットフォームをまたいだチャットデータ統合に対応 +- データ管理で一部テーブル列のソートに対応 +- トピック分析の入口をインサイトモジュールへ移動 +- AI ログファイルの元パス記録を追加 +- ダークモードの配色を調整し、視認性を改善 + +### 🐛 バグ修正 + +- 辞書更新時のリフレッシュ処理と統合 ID 衝突の問題を修正 +- OpenAI 互換リクエストに実行時 User-Agent ヘッダーを追加 +- ダークモードでのスクリーンショット書き出し時に背景が透過する問題を修正 +- 辞書ダウンロードに SHA256 整合性検証を追加 +- リモート設定取得を厳格化し、更新インストール確認を強化 + +### 🔧 雑務 + +- ARM Linux 向け deb パッケージビルド対応を追加 +- 更新ログ同期フローを最適化 + +### 📝 ドキュメント + +- 繁体字中国語ドキュメントを追加 + +## v0.17.1 (2026-04-13) + +> トピックモジュールを再設計してトピックカードを追加し、ワードクラウドのキーワードフィルタリングとクエリキャッシュを改善。リモート分かち書き辞書のダウンロードと繁体字中国語辞書に対応し、WhatsApp 検出ロジックも強化しました。 + +### ✨ 新機能 + +- トピックモジュールを再設計し、トピックカード表示を追加 +- ワードクラウドにキーワードフィルタリングを追加 +- リモート分かち書き辞書のダウンロードに対応し、繁体字中国語辞書を追加 +- クエリキャッシュロジックを改善 +- ローディング表示を統一 +- WhatsApp 検出の精度を向上 + +### 👷 CI + +- 公式ドキュメントサイトを開設し、自動同期・デプロイに対応 + +## v0.17.0 (2026-04-12) + +> 今回の更新では、WhatsAppインポート解析と指定形式インポートを強化し、概要カード構成を刷新するとともに、共有・スクリーンショット・デバッグ機能を追加しました。 + +### ✨ 新機能 + +- WhatsApp V2 のタイムスタンプを柔軟に解析し、地域ごとのエクスポート差分に自動対応 +- WhatsApp チャット履歴の検出ロジックを改善 +- 指定形式インポートに対応 +- メッセージタブに共有カードを追加 +- DEBUG モードにクイックデバッグツールを追加 +- 概要のプロフィールカードを改善し、期間指定クエリのロジックを統一 +- 概要モジュールのカード構成を再設計し、テーマカラーカードを分離して配色モード拡張に備えた +- カード最大幅を統一し、ホームのツールをグローバルサイドバーへ昇格 +- テーマカードのスクリーンショットに対応し、モバイル向けスクリーンショット最適化を既定で無効化 +- 診断提案を削除し、新しいヒント表示を追加 + +### 🐛 バグ修正 + +- WhatsApp の時刻解析正規表現と行マッチ正規表現の厳密さが一致しない問題を修正 +- WhatsApp の12時間表記時刻と NNBSP 文字の解析互換性問題を修正 + +### 🔧 雑務 + +- CI パッケージング高速化のため、electron と electron-builder のバイナリをキャッシュ + +## v0.16.0 (2026-04-10) + +> 今回の更新では、個別チャットに能動性分析ビューを追加し、モデル編集ダイアログでカスタムモデルが消える問題を修正しました。 + +### ✨ 新機能 + +- 個別チャットに能動性分析ビューを追加 +- フッター領域の表示と操作性を改善 +- 引用モジュール下部のロジックを改善 + +### 🐛 バグ修正 + +- サードパーティー/ローカルサービスの編集ダイアログで、複数のカスタムモデルが失われる問題を修正 + +## v0.15.0 (2026-04-08) + +> 今回のリリースでは、検索・クエリ性能を大幅に改善し、検索ツールのコンテキスト自動引き継ぎ、AI モデル設定の最適化と一部プロバイダー追加、Linux 対応を行いました。 + +### ✨ 新機能 + +- クエリキャッシュを追加し、アクセス速度を向上 +- 検索ツールでコンテキストの自動引き継ぎに対応 +- モデル設定ロジックを再構成 +- 新規ユーザーの初回起動時に言語選択ダイアログを優先表示 +- ラボに基本的なデバッグツールを追加 +- 旧プロンプトを削除 + +### 🐛 バグ修正 + +- Windows ライトモードでタイトルバー右上ボタン領域の背景色が不一致になる問題を修正 +- CI パッケージングワークフローにおける Node 24 と pnpm の整合性問題を修正 +- ツール呼び出し表示名の i18n 翻訳不足を補完 + +### ♻️ リファクタリング + +- AI 設定モーダルのコード構成を改善 + +### 🔧 雑務 + +- Node 24 へアップグレード +- Linux パッケージングに対応 + +### 📝 ドキュメント + +- ドキュメントを更新 + +## v0.14.2 (2026-04-07) + +> 今回の更新では、AI 会話体験を中心に改善し、会話コピー、UI 最適化、FTS5 全文検索ツール対応に加えて、検索パラメータの整理とエラー表示・テスト体制の強化を行いました。 + +### ✨ 新機能 + +- アシスタント選択を 7 日間記憶する機能を追加 +- AI 会話でメッセージのワンクリックコピーに対応 +- AI 会話のスタイルと全体の操作体験を改善 +- FTS5 全文検索に対応し、クイック検索ツールを追加 +- 一部ツールの検索パラメータを整理し、token 消費を削減 +- Electron アプリ向けに、ポート管理とインスタンス分離に対応した E2E テスト基盤を追加 + +### 🐛 バグ修正 + +- AI 会話のエラー表示を改善し、原因の特定をしやすく修正 + +### ♻️ リファクタリング + +- AI 会話モジュールのコード構成を整理 +- セッション分析ページの共通ロジックを抽出し、ヘッダー文言を統一 + +### test + +- 再利用可能な E2E ランチャーのスモークテストを追加 + +### 📝 ドキュメント + +- プロジェクトドキュメント内の導入画像を更新 + +### 💄 スタイル + +- 一部コードのフォーマットを統一し、可読性を向上 + +## v0.14.1 (2026-04-02) + +> 今回の更新では、ホームの情報構成と UI を見直し、SQL 会話の操作性、統計読み込み性能、AI ツール品質を改善しました。 + +### ✨ 新機能 + +- 概要ページのスタイルを改善 +- SQL 会話モジュールの操作フローを改善 +- メンバー管理をホームへ移し、関連タブ構成を調整 +- チャット概要取得を含む一部 AI ツールを追加 +- 会話データのキャッシュ管理モジュールを追加し、統計読み込みを高速化 +- 更新ログモーダルのタイプ表示を改善 + +### 🐛 バグ修正 + +- SQL Lab と要約生成で AI エラーが黙って握りつぶされる問題を修正 + +### ♻️ リファクタリング + +- AI ツール分類を再設計し、保守性を向上 + +### 🔧 雑務 + +- 利用価値の低い AI ツールを廃止し、ツールセットを整理 + +## v0.14.0 (2026-03-28) + +> APIのインポート/エクスポートとプリセット質問を追加し、概要画面と設定体験を改善。あわせて重複排除、AI会話フロー、日次メッセージ推移の表示問題を修正しました。 + +### ✨ 新機能 + +- APIインポートに対応 +- APIエクスポートに対応 +- プリセット質問を選ぶとそのまま送信できるように +- 設定画面で会話の既定タブを選択可能に +- 概要画面のスタイルを改善 +- 全体のUIとAPIサービス設定画面を改善 +- プロフィールカードとアシスタント選択の操作感を改善 + +### 🐛 バグ修正 + +- メッセージ重複判定の誤検知を修正し、空文字列の重複排除ルールを統一 +- AI会話フローの不具合とフロントエンドのtype-checkエラーを修正 +- 例外時のための既定assistantフォールバックを追加 +- 日次メッセージ推移が表示されない問題を修正 + +### ♻️ リファクタリング + +- parser、worker、RAG、merger に残っていた古い型の問題を整理 + +### 🔧 雑務 + +- assistant 設定生成スキルを追加 + +## v0.13.0 (2026-03-16) + +> AIチャットにアシスタントモードを追加し、チャットでスキル利用と入力欄のクイック選択に対応。会話/設定画面を改善し、繁体字中国語と日本語に対応、UIの調整と安定性修正を行いました。 + +### ✨ 新機能 + +- アシスタントモード初版をリリースし、アシスタントロジックと分析ツール機能を強化 +- アシスタントマーケットとスキルマーケットを公開、チャットでスキルを利用可能に +- 「@」でメンバー選択して協業できるように +- 繁体字中国語と日本語のローカライズに対応 +- 設定画面を再構成し、一部 UI を改善 +- 概要モジュールのスタイルと会話画面体験を改善 +- チャット履歴エクスポートの表示位置を調整 +- 旧プロンプトシステムと AI のカスタムフィルタ機能を削除 +- ページ切替時にモデル呼び出しが中断しないように + +### 🐛 バグ修正 + +- Gemini API の設定問題を修正 +- NLP のストップワード呼び出し順序によるエラーを修正 + +### ♻️ リファクタリング + +- AIChat の構成をリファクタリング +- ディレクトリ配置とプロジェクト構成を整理 + +### 📝 ドキュメント + +- 利用規約とプロジェクトドキュメントを更新 + +### 🔧 雑務 + +- バージョンログのビルドフローを改善 + +### 💄 スタイル + +- コードフォーマットと lint 規則の出力を統一 + +## v0.12.1 (2026-02-27) + +> チャット履歴の前処理とデバッグ機能を追加し、Agent/LLM アーキテクチャを再構成。あわせて国際化と Windows テーマ表示の不整合を修正しました。 + +### ✨ 新機能 + +- チャット履歴の前処理パイプラインを追加 +- 前処理設定画面と設定管理機能を追加 +- Agent でセッション単位のコンテキストタイムラインと実行状態を表示 +- AI デバッグモードを追加し、ログの可観測性を向上 + +### 🐛 バグ修正 + +- 英語設定時に一部 UI が未翻訳だった問題を修正 +- Windows で overlay 色を動的更新した際にテーマ表示が揃わない問題を修正 + +### ♻️ リファクタリング + +- Agent の単一実装をモジュール構成へ分割 +- ツールシステムを AgentTool + TypeBox 構成へ再編し、i18n を補完 +- LLM アクセス層を統一し、pi-ai ベースへ集約 +- データフローと IPC 契約を見直し、フロントエンド側も対応 +- 共有型を導入し、ChatStatusBar の国際化を改善 +- 一部のチャートをプラグイン構成へ再編 + +### 🔧 雑務 + +- 過剰設計だった sessionLog モジュールを削除 +- @ai-sdk 関連依存と旧 LLM サービス実装を削除 +- ベクトルモデル設定の入口を一時的に非表示化 +- プロジェクト説明文を更新 + +### 💄 スタイル + +- ESLint の自動修正を実行し、コードスタイルを統一 + +## v0.11.2 (2026-02-15) + +> チャット履歴の取り込みと管理画面を改善し、複数プラットフォームの履歴互換性を強化しました。 + +### ✨ 新機能 + +- LINE と WhatsApp パーサーの形式互換性を強化 +- チャット履歴の判定レイヤーを改善し、ポーリング検出とフォールバック機構に対応 +- 管理画面で Shift 複数選択に対応 +- 管理画面でチャット要約数と AI チャット数を表示 +- ホーム画面のレイアウトを見直し、使えるスペースを拡大 +- Windows の右上コントロールバーの見た目を改善 + +### 📝 ドキュメント + +- プロジェクト文書を更新 + +## v0.11.0 (2026-02-13) + +> Telegram の取り込みに対応し、増分インポート体験と国際化設定を改善。あわせて索引不整合や画面のちらつきも修正しました。 + +### ✨ 新機能 + +- AI 呼び出し、ログ、メインプロセス設定まわりの国際化対応を拡充 +- Telegram チャット履歴のインポートに対応 +- 増分インポートの操作フローと関連文言を改善 +- 利用規約リンクを開く際の体験を改善 + +### 🐛 バグ修正 + +- 増分インポート後に索引が無効になる問題を修正(resolve #81) +- iPhone から書き出した WhatsApp 履歴を認識できない問題を修正(resolve #82) +- チャット画面切り替え時の二重ちらつきを修正 + +### 🔧 雑務 + +- TypeScript 設定を最適化 +- i18n ビルド設定を調整 +- skill 関連の開発設定を整理 + +## v0.10.0 (2026-02-11) + +> やり取り頻度の分析機能を追加し、セッション検索の流れを改善。あわせて増分索引とデータベース走査まわりの問題を修正しました。 + +### ✨ 新機能 + +- やり取り頻度を分析できるビューを追加し、メンバー間の動きが見やすくなりました +- セッション検索のロジックと処理フローを改善 + +### 🐛 バグ修正 + +- 増分更新後にセッション索引の生成範囲が不正確になる問題を修正(fix #79) +- 移行やセッション走査時に非チャット用 SQLite ファイルを誤処理する問題を修正 + +### ♻️ リファクタリング + +- セッション検索モジュールを再構成し、保守性を向上 + +### 🔧 雑務 + +- transformers 関連依存を削除し、開発設定を更新 + +## v0.9.4 (2026-02-08) + +> 期間絞り込みと AI 設定まわりを改善し、API Key のローカル暗号化に対応。加えて LINE 履歴の解析問題を修正しました。 + +### ✨ 新機能 + +- 期間絞り込みの選択肢をより柔軟に拡充 +- API Key のローカル暗号化保存に対応 +- 初回利用ユーザーには更新履歴を表示しないよう変更 +- AI チャット下部の設定ステータス表示を改善 +- データディレクトリ移行後にすぐ再起動できるよう対応 + +### 🐛 バグ修正 + +- LINE チャット履歴の解析問題を修正 + +### 📝 ドキュメント + +- プロジェクト文書を更新 + +## v0.9.3 (2026-02-03) + +> カスタムデータディレクトリに対応し、多数の既知問題を修正しました。 + +### ✨ 新機能 + +- 設定画面にデータディレクトリ位置の設定項目を追加 +- データ保存先の移行ロジックを改善 +- ディレクトリ切り替え時の確認ダイアログを追加 +- パーサーロジックを改善(WeFlow / Echotrace) + +### 🐛 バグ修正 + +- Windows で大量メッセージを絞り込むとクラッシュする問題を修正 +- 外部中継 API が tool_call を返した際に会話が異常終了する問題を修正 +- 一部の WhatsApp 履歴を正しく認識できない問題を修正 +- 管理画面のヘッダー階層表示の問題を修正 + +### ♻️ リファクタリング + +- session 検索モジュールを再構成 +- 移行ログ出力を改善 + +## v0.9.2 (2026-02-02) + +> ランキングをグラフ表示へ刷新し、ワードクラウドやローカル AI 推論モデルを改善。履歴絞り込みと日付選択も見直し、起動後の主要ルート事前読込にも対応しました。 + +### ✨ 新機能 + +- ランキングをチャート表示へ刷新 +- ワードクラウドの見え方を改善 +- 推論モデルを最適化 +- チャット履歴の検索と絞り込みの連携を改善 +- 日付選択 UI の操作性を改善 +- 起動後に主要ルートを先読み + +### 🔧 雑務 + +- preload をモジュール化 +- analytics ロジックを改善 +- ESLint を更新し、コード整形を実施 + +## v0.9.1 (2026-01-30) + +> LINE 履歴の取り込み、バッチ管理、チャット検索に対応し、既知の問題もいくつか修正しました。 + +### ✨ 新機能 + +- 一括管理を追加し、まとめて削除・結合に対応 +- チャット検索に対応 +- LINE チャット履歴のインポートに対応 +- WeFlow が出力する JSON 形式に対応 +- メンバー一覧をバックエンド分页読み込みへ変更 +- 一部文言を改善 + +### 🐛 バグ修正 + +- Windows 更新時に Worker が残ってアプリを閉じられない問題を修正 + +## v0.9.0 (2026-01-28) + +> NLP 分かち書きに対応し、名言集タブにワードクラウドを追加。Views タブでより多くのグラフを表示できるようになり、システムプロキシ追従にも対応しました。 + +### ✨ 新機能 + +- ユーザーセレクターの性能を改善し、仮想リスト読み込みに対応 +- ランキングを Views タブへ移動 +- 分かち書きを導入し、ワードクラウド用のサブタブを追加 +- グループチャットタブの文言を改善 +- ネットワークプロキシがシステム設定に追従 +- 更新履歴の表示判定ロジックを改善 + +### 💄 スタイル + +- Markdown 表示スタイルを改善 + +## v0.8.0 (2026-01-26) + +> セッション要約とベクトル検索を追加し、更新後のリリースノート表示や一部 UI 体験も改善。あわせて既知の問題を修正しました。 + +### ✨ 新機能 + +- チャットセッションに要約機能を追加 +- セッション要約の一括生成ロジックを追加 +- ベクトルモデル設定と関連検索に対応 +- チャット履歴の取り込み失敗時に、より詳しいログを記録 +- 新バージョン更新後に更新履歴を自動表示 +- ホームに共通リンク付き Footer を追加 +- サイドバーから Help & Feedback を削除 + +### 🐛 バグ修正 + +- shuakami-jsonl の解析エラーを修正(fix #50) + +## v0.7.0 (2026-01-23) + +> AI チャット体験と更新処理を改善し、チャート基盤を chart.js から ECharts へ移行しました。 + +### ✨ 新機能 + +- 更新ロジックを改善 +- AI チャットのエラーログを改善 +- チャット下部からモデルを素早く切り替え可能に +- 既定プロンプトを見直し、少しユーモアを加味 +- chart.js を ECharts に置き換え +- 登録規約ロジックを削除 + +## v0.6.0 (2026-01-21) + +> AI SDK を導入して AI チャットの安定性を高め、思考内容ブロックを追加。あわせて一部スタイルも調整しました。 + +### ✨ 新機能 + +- ログ位置を特定しやすくする機能を追加 +- AI SDK を導入 +- 思考内容ブロックを追加 +- ホーム上部のドラッグ領域にグローバルモーダルが隠れる問題を解消 +- Windows 右上の閉じるボタンの見た目を改善 + +## v0.5.2 (2026-01-20) + +> 結合インポートに対応し、いくつかの問題を修正しました。 + +### ✨ 新機能 + +- 結合インポートに対応 +- メインパネルにチャット履歴の開始日時と終了日時を表示 +- ドラッグ&ドロップ領域を改善 + +### 🐛 バグ修正 + +- macOS x64 ビルド問題を解消するためビルド設定を改善 +- Windows のメッセージビューアで閉じるボタンの見た目が崩れる問題を修正 +- macOS パッケージング時は対象アーキテクチャ上でビルドが必要なよう修正(fixes #36) + +## v0.5.1 (2026-01-16) + +> いくつかの問題を修正しました。 + +### ✨ 新機能 + +- 文言を改善 + +### 🐛 バグ修正 + +- Windows でアプリを閉じてもプロセスが残る問題を修正(#33) +- 数値入力欄の不具合を修正(resolve #34) + +## v0.5.0 (2026-01-14) + +> Instagram 履歴の取り込みに対応し、ホームでは一括インポート、チャット画面では増分インポートが使えるようになりました。 + +### ✨ 新機能 + +- Instagram チャット履歴のインポートに対応 +- 各種ロジックを改善 +- システムプロンプトのプリセット機能を改善 +- 増分インポートに対応 +- 一括インポートに対応 +- スタイルを改善 +- Windows でネイティブウィンドウ操作とテーマ同期に対応(#31) + +### 🔧 雑務 + +- componenst.d.ts を削除 + +## v0.4.1 (2026-01-13) + +> 見た目と操作まわりを中心に改善しました。 + +### ✨ 新機能 + +- プロンプトのプレビューに対応 +- AI チャットのステータスバーを改善 +- マイグレーションテーブルのロジックを改善 +- サイドバーでアバター表示に対応 +- スタイルを改善 +- ネイティブウィンドウコントロールバーを置き換え +- グローバル背景色を改善 +- アプリ終了時に Worker をクリーンアップ + +### 🐛 バグ修正 + +- テーマ設定の「システムに従う」が効かない問題を修正 +- アップデートダイアログのレイアウト崩れを修正 + +## v0.4.0 (2026-01-12) + +> shuakami-jsonl の取り込みに対応し、AI チャットをより省 Token 化。取り込み時のセッション索引生成とビューアからの快速ジャンプにも対応し、アップデート高速化ミラーも追加しました。 + +### ✨ 新機能 + +- shuakami-jsonl に対応 +- Loading 表示を改善 +- カスタムフィルターを追加 +- プリセット文言システムを再構成し、共通プリセットに対応 +- Token 節約のためシステムプロンプトを簡潔化 +- セッション関連の function calling を追加 +- メッセージから前後文脈へジャンプする処理を追加 +- チャット履歴ビューアでセッション索引表示と高速ジャンプに対応 +- 設定ダイアログを再構成し、セッション索引設定を追加 +- チャット履歴取り込み時にセッション索引を生成 +- 設定ダイアログを再構成 +- 基本コンポーネントの操作スタイルを改善 +- ホーム画面の見た目を改善 +- アップデート高速化ロジックを改善 +- 高速化ミラーを追加 + +## v0.3.1 (2026-01-09) + +> Discord インポートに対応し、各パーサーで返信メッセージを取り込めるよう改善。保存先もより標準的な場所へ移し、取り込み診断も詳しくなりました。 + +### ✨ 新機能 + +- テーブル更新処理をメインプロセス側へ移動 +- 自動更新確認時に beta 版を無視 +- データ保存先を userData 配下へ移動 +- 各パーサーで返信メッセージ取り込みを再対応 +- プラットフォームメッセージ ID と返信 ID に対応し、同時にテーブル移行も実施 +- Tyrrrz/DiscordChatExporter 形式の取り込みに対応 +- member テーブルでロールに対応 +- ChatLab 形式の検出挙動を強化 +- クリック取り込みとドラッグ取り込みの挙動を統一 +- より詳細な形式診断に対応 + +### 🐛 バグ修正 + +- 一部ユーザーで platformId が空になる問題を修正 + +## v0.3.0 (2026-01-08) + +> 国際化対応をひと通り整え、中国語と英語の切り替えに対応。あわせていくつかの機能も改善しました。 + +### ✨ 新機能 + +- SQL ラボのエクスポートに対応 +- AI チャットのエクスポートに対応 +- 国際化対応をひと通り完了 +- AI モデルエラー時に明示的なエラーを表示 +- SQL 結果からメッセージビューアへジャンプ可能に +- システム prompt を改善し、prompt マーケットに対応 + +## v0.2.0 (2025-12-29) + +> プロキシ設定に対応し、インポート時のエラーログ表示を追加。あわせて UI 操作と一部機能を改善しました。 + +### ✨ 新機能 + +- メッセージマネージャーでシステムメッセージ表示に対応 +- インポート処理を改善し、エラー時にログを表示 +- WhatsApp の英語形式メッセージ取り込みに対応 +- プロキシ設定に対応(resolve #7) +- AI モデル画面の操作性を改善 +- ユーザー設定 API のチュートリアルを追加 +- 無料の GLM モデル 2 種を追加し、Doubao プロバイダーと最新モデルを追加 +- AI 応答で think 内容を出力しないよう調整 + +## v0.1.3 (2025-12-25) + +> いくつかの問題を修正しました。 + +### 🐛 バグ修正 + +- Echotrace パーサーの不具合を修正 + +## v0.1.2 (2025-12-25) + +> ダークモードに対応し、AI チャットのシステムプロンプトでユーザー情報を渡せるようになりました。 + +### ✨ 新機能 + +- AI チャットのシステムプロンプトでユーザー情報を受け渡し可能に +- チャット履歴ビューア右側に Owner を表示 +- データベース更新に対応 +- メンバータブで Owner 視点の設定に対応 +- ダークモードに対応 + +### 🐛 バグ修正 + +- 個人チャットをグループチャットと誤判定する問題を修正 + +## v0.1.1 (2025-12-24) + +> WhatsApp チャット履歴の取り込みに対応し、旧版 QQ 討論組形式の分析も可能になりました。 + +### ✨ 新機能 + +- チャットセッション下部に Token 使用量を表示 +- WhatsApp ネイティブ形式メッセージに対応 +- 旧版 QQ txt 討論組形式に対応 + +### 🐛 バグ修正 + +- メッセージマネージャーの z-index が低すぎる問題を修正 + +## v0.1.0 (2025-12-23) + +> プロジェクトを公開し、初回リリースしました。 + +### ✨ 新機能 + +- init diff --git a/changelogs/tw.json b/changelogs/tw.json new file mode 100644 index 000000000..c46b718a9 --- /dev/null +++ b/changelogs/tw.json @@ -0,0 +1,1770 @@ +[ + { + "version": "0.29.0", + "date": "2026-07-01", + "summary": "新增聯絡人功能與關係星圖,改善聯絡人計算、頭像載入與側邊欄效能。", + "changes": [ + { + "type": "feat", + "items": [ + "新增人際關係模組,將聯絡人作為子頁面,支援跨會話聯絡人彙整、時間範圍篩選、分頁與虛擬捲動", + "聯絡人頁支援手動將群組成員標記為好友,並提供前往來源對話與檢視聊天記錄的入口", + "新增互動關係星圖,支援 3D 全景、節點篩選、搜尋、詳情面板,以及相關聯絡人/群組探索", + "關係星圖支援高關聯、僅好友等視圖篩選,並在隱私模式下遮蔽姓名", + "【桌面端】支援在背景靜默下載應用程式更新" + ] + }, + { + "type": "perf", + "items": [ + "聯絡人列表改用分頁與虛擬捲動,降低大量好友與群組成員造成的渲染壓力", + "頭像改為懶載入,並將側邊欄會話列表虛擬化,減少啟動與頁面切換卡頓" + ] + }, + { + "type": "style", + "items": ["統一暗色模式的主背景、關係頁頂部與側邊欄選取狀態"] + } + ] + }, + { + "version": "0.28.1", + "date": "2026-06-26", + "summary": "優化聊天記錄檢視,新增匯入 API,修正匯入、同步與本機模型代理下載問題。", + "changes": [ + { + "type": "feat", + "items": [ + "新增 Push 匯入 API,支援依會話追加寫入、去重與增量索引產生", + "匯入完成後自動產生會話索引,涵蓋 CLI、桌面端與 pull sync 匯入流程", + "優化聊天記錄檢視器的控制項與高亮顯示體驗", + "側邊欄預設隱藏捲軸,滑鼠移入時顯示" + ] + }, + { + "type": "fix", + "items": [ + "強化 Push/Pull 匯入校驗、去重與並發鎖定,避免異常輸入造成誤寫或重複匯入", + "GET /api/v1/sessions/:id 補齊 lastPlatformMessageId 與 importedAt 欄位,支援增量匯入邊界判斷", + "背景 pull sync 完成後自動刷新會話列表", + "修正本機語意索引模型下載未正確使用代理的問題,並補齊 Worker 日誌初始化", + "【桌面端】API 預設連接埠與 CLI 對齊為 3110,連接埠被占用時會自動嘗試後續連接埠", + "【桌面端】系統代理解析支援 HTTP、HTTPS 與 SOCKS 結果,避免本機模型下載靜默繞過代理" + ] + }, + { + "type": "refactor", + "items": [ + "【CLI】pull/sync 匯入流程改用共享 streaming importer,移除舊解析與寫入實作", + "【桌面端】移除增量匯入後重複產生會話索引的多餘呼叫" + ] + }, + { + "type": "docs", + "items": ["修正文檔中的 API 連接埠、範例與未實作能力說明"] + } + ] + }, + { + "version": "0.28.0", + "date": "2026-06-25", + "summary": "新增 Google Chat 匯入支援,引入統一應用程式日誌模組,優化語意索引使用體驗。", + "changes": [ + { + "type": "feat", + "items": [ + "新增 Google Chat 匯入支援,涵蓋 CLI 與桌面端完整流程", + "新增統一應用程式日誌模組,支援依檔案大小自動輪替與全域未捕獲錯誤記錄", + "語意索引管理改為「移除」操作取代停用,修正 legacy hash,限制清單高度", + "語意索引模型設定儲存時自動預熱,並遷移至 AI 目錄統一管理", + "簡化語意索引設定,移除搜尋結果數量設定項,改用內建預設值" + ] + }, + { + "type": "fix", + "items": ["補齊 stream-json/stream-chain 相依套件聲明,修正多聊天掃描穿透問題"] + }, + { + "type": "docs", + "items": ["文件新增 Google Chat 支援平台說明"] + } + ] + }, + { + "version": "0.27.2", + "date": "2026-06-24", + "summary": "修正 AI auth profile 生命週期管理的多項問題,提升分析服務穩定性,補齊本機嵌入執行環境相依套件。", + "changes": [ + { + "type": "feat", + "items": ["【CLI Web】透過共享 AnalyticsService 統一回報日活用戶資料"] + }, + { + "type": "fix", + "items": ["刪除 AI 服務設定時同步清除對應的 auth profile"] + }, + { + "type": "refactor", + "items": ["移除已過期的分析設定遷移邏輯"] + } + ] + }, + { + "version": "0.27.1", + "date": "2026-06-23", + "summary": "支援 API 批次向量化加速語意索引建立,改為惰性載入本機模型;修正斷點續跑、同步游標回退與 AI 對話匯出等問題。", + "changes": [ + { + "type": "feat", + "items": [ + "語意索引支援 API 嵌入模型批次向量化,大幅提升索引建立速度", + "本機嵌入模型改為惰性 Worker 載入,降低啟動資源消耗" + ] + }, + { + "type": "fix", + "items": [ + "修正 AI 對話匯出:支援匯出目前可見的 AI 對話內容", + "修正語意索引批次寫入中途崩潰後,續跑觸發唯一約束錯誤、會話狀態卡住的問題", + "修正語意 Worker 多項問題:設定變更未同步至執行中的 Worker、可用性探針異常影響一般 AI 對話、活躍建立狀態追蹤不準確導致 Worker 提前關閉", + "修正同步拉取游標:空回應重試前未儲存初始頁伺服器水位,導致重複拉取尾端視窗", + "修正同步拉取分頁游標錯誤回退,避免重複匯入" + ] + }, + { + "type": "refactor", + "items": ["清理多處無效程式碼與過度設計的抽象,簡化工具目錄結構與深層合併邏輯"] + } + ] + }, + { + "version": "0.27.0", + "date": "2026-06-22", + "summary": "新增向量索引與證據檢索功能,支援透過語意搜尋定位並呈現聊天證據,並提供索引管理介面。", + "changes": [ + { + "type": "feat", + "items": [ + "新增語意索引管理介面,可選擇指定會話建立向量索引,提供多語言支援", + "新增 retrieve_chat_evidence 工具:AI 可透過語意搜尋檢索帶時間定位的聊天證據", + "規劃器自動辨識證據類問題,路由至語意檢索工具處理", + "證據內容區塊支援展開檢視、折疊收起與點擊跳轉原始訊息", + "語意檢索支援時間範圍篩選", + "重新設計語意索引模型選擇介面", + "AI 處理過程片段支援折疊收起", + "簡化證據來源列與 Agent 工具呼叫結果呈現" + ] + }, + { + "type": "fix", + "items": [ + "修正多關鍵字訊息搜尋功能", + "修正詞雲詞典載入 UX 回歸問題", + "修正排行榜標籤與 emoji 清理", + "修正 v8 資料庫遷移缺少國際化文字的問題", + "【CLI】修正工具轉接器中會話上下文傳遞與前處理缺失的問題" + ] + } + ] + }, + { + "version": "0.26.3", + "date": "2026-06-15", + "summary": "優化分析模組效能:新增磁碟快取與過期請求取消機制,切換會話更順暢;修正詞雲、高頻詞統計及時間軸等多處問題。", + "changes": [ + { + "type": "feat", + "items": ["【CLI Web】新增網站圖示(favicon)"] + }, + { + "type": "perf", + "items": [ + "分析結果依資料庫檔案版本落地快取,大幅提升二次載入速度", + "切換會話或篩選條件時自動取消過期分析請求", + "切換詞雲詞數時略過重新分詞,減少不必要的運算" + ] + }, + { + "type": "fix", + "items": [ + "修正高頻詞統計中含有平台回覆訊息占位符的問題", + "修正詞雲文字中包含媒體占位符的問題", + "修正 Jieba 自訂詞典變更後語言偏好快取與詞頻快取未失效的問題", + "修正時效性分析快取未按日失效的問題", + "修正時間軸面板預設捲動位置及會話跳轉功能失效的問題", + "修正片段訊息上下文缺失的問題", + "修正 AI 對話數量始終回傳 0 的問題", + "修正首頁教學連結未使用本地化路徑的問題", + "移除 AI 聊天頂部截圖按鈕", + "【桌面端】預先打包懶載路由依賴,避免開發模式 504 錯誤", + "【CLI】修正版本號不同步導致誤報更新提示的問題", + "【CLI】更新提示支援方向鍵選擇" + ] + }, + { + "type": "refactor", + "items": ["將時間軸面板標籤重新命名為「摘要」"] + } + ] + }, + { + "version": "0.26.2", + "date": "2026-06-13", + "summary": "優化會話「我是誰」身分識別功能,支援手動選擇 owner profile 並自動批次填充同平台會話;修正多處圖表渲染與 AI 快取問題。", + "changes": [ + { + "type": "feat", + "items": [ + "新增會話「我是誰」身分識別功能:手動確認後儲存平台 owner profile,並自動批次比對填充同平台其他未設定擁有者的會話", + "匯入和 pull sync 完成後自動嘗試套用已儲存的 owner profile,減少手動設定次數" + ] + }, + { + "type": "fix", + "items": [ + "修正 name-match 平台(WhatsApp / Line / Instagram)批次填充時各會話 ownerId 快取與資料庫寫入不一致的問題", + "修正多路由共用獨立 PreferencesManager 實例導致 owner profile 被覆寫為舊值的問題", + "【CLI】修正長期執行時 sync 模組 preferences 快取過期,pull 匯入新會話後 owner profile 未自動生效的問題", + "修正匯入後暫存檔案未完全刪除的問題", + "修正 AI 圖表在串流生成期間渲染抖動的問題", + "修正 AI 圖表渲染順序不穩定的問題", + "修正 AI 圖表存在僅占位空白列的問題", + "新增 AI 圖表生成進度提示", + "修正截圖前未調整圖表尺寸導致圖表顯示不完整的問題", + "修正 last_message_time 語義:表示資料涵蓋截止時間,而非群組最後活躍時間", + "修正增量匯入後概覽與分析快取未失效,導致顯示舊資料的問題" + ] + } + ] + }, + { + "version": "0.26.1", + "date": "2026-06-10", + "summary": "修正工具呼叫歷史重播多項問題,補齊工具呼叫持久化與重播能力;狀態列新增 Token 快取統計,訊息匯出支援多種格式。", + "changes": [ + { + "type": "feat", + "items": ["聊天狀態列新增 Token 快取命中/未命中用量顯示", "訊息匯出支援 TXT、JSON、Markdown 多種格式"] + }, + { + "type": "fix", + "items": [ + "支援持久化並重播對話中的工具呼叫記錄,保持多輪 AI 上下文完整性", + "修正工具呼叫無文字輸出的 assistant 輪次被歷史重播過濾,導致工具結果遺失的問題", + "修正連續匯出同一會話時,因檔名衝突導致靜默覆蓋的問題", + "【桌面版】修正大型會話匯出因 60 秒 IPC 逾時提前失敗的問題", + "修正 DeepSeek 工具呼叫輪次的 reasoning_content 未隨歷史重播", + "修正歷史壓縮時重播工具結果的 token 數未被正確計入", + "修正工具結果傳給模型的文字中包含不應暴露的原始訊息資料", + "修正工具面板在點擊外部區域時未自動關閉" + ] + }, + { + "type": "refactor", + "items": ["將會話頁面頂部列提取為共用元件"] + } + ] + }, + { + "version": "0.26.0", + "date": "2026-06-10", + "summary": "新增 AI 分析規劃器,支援流式結構化計畫產生與圖表規劃整合;改善圖表模式工具可用性,修正多項 AI 與匯入問題。", + "changes": [ + { + "type": "feat", + "items": [ + "新增 AI 分析規劃器,支援流式結構化分析計畫產生與區塊渲染", + "整合圖表規劃支援,AI 分析計畫可自動銜接圖表產生", + "規劃器引入啟動上下文與延伸資料快照,提升分析深度與品質", + "圖表渲染優先使用高層工具而非原始 SQL,降低權限需求", + "改善 AI 分析與圖表整體行為", + "新增圖表自動模式偏好設定", + "新增訊息 ID 複製操作", + "新增 shadow 路由 LLM 備援機制,並記錄路由決策日誌", + "【CLI】公開完整 Agent 串流事件" + ] + }, + { + "type": "fix", + "items": [ + "修正明確選擇圖表技能時 render_chart 工具遺失的問題", + "修正明確圖表模式下工具可用性缺口及錯誤後計畫狀態異常", + "遷移 ECharts 6 containLabel 設定,隱藏多餘的圓餅圖圖例", + "修正設定頁導覽順序、skillSettings 鍵名及 SubTabs 換行問題", + "修正 AI 助理訊息未填滿聊天區域寬度", + "改善分析計畫產生區塊的顯示樣式", + "修正思考深度選擇跨重新啟動後不持久的問題", + "修正 JSONL 時間戳記規範化導致增量匯入異常", + "【桌面版】修正 execute_sql 工具未註冊至桌面版工具清單" + ] + }, + { + "type": "test", + "items": ["新增 Agent 路由決策評估測試集"] + } + ] + }, + { + "version": "0.25.1", + "date": "2026-06-08", + "summary": "新增資料目錄相容性門禁,防止舊版執行環境誤寫已升級的資料;並新增 CLI ai chat 指令。", + "changes": [ + { + "type": "feat", + "items": [ + "新增資料目錄相容性門禁:寫入 v6 schema 資料後自動記錄最低可存取的執行環境版本,防止舊版本誤寫已升級的資料目錄", + "資料庫操作前校驗資料目錄相容版本,不符合要求時拒絕存取", + "【桌面版】啟動時校驗資料目錄相容版本,不符合時顯示提示視窗並結束", + "【CLI】啟動時校驗資料目錄相容版本,不符合時立即結束", + "【CLI】新增 ai chat 指令,支援在終端機與 AI 進行多輪對話" + ] + }, + { + "type": "fix", + "items": [ + "改用打包版本號(而非 Electron 開發模式回報的 0.0.0)寫入相容性門禁,並強制要求執行環境身分識別", + "資料庫遷移與匯入完成後正確寫入相容性門禁;啟動遷移失敗時立即回報錯誤,不再靜默忽略", + "【MCP】啟動時主動校驗資料目錄相容性,版本不符時提前結束,避免寫入不相容資料", + "【桌面版】HTTP API 將相容性門禁錯誤對應為 409 回應,方便前端顯示升級提示", + "【CLI】規範化 AI 工具名稱,修正 ai chat 指令的邊界處理問題" + ] + }, + { + "type": "refactor", + "items": ["統一 AI 對話與 segment 識別碼的命名規範"] + }, + { + "type": "test", + "items": ["補充解析器、設定遷移、資料庫遷移、HTTP 路由與 AI 工具的測試覆蓋"] + }, + { + "type": "docs", + "items": ["新增資料目錄相容性門禁公開文件,說明多版本共用資料目錄的限制與升級規則"] + } + ] + }, + { + "version": "0.25.0", + "date": "2026-06-07", + "summary": "AI 對話現在可透過技能產生圖表,並修正工具輪次、桌面標題列與開發服務生命週期問題。", + "changes": [ + { + "type": "feat", + "items": [ + "新增以斜線指令觸發的 AI 圖表執行環境,可在對話中產生並渲染 ECharts 圖表", + "改善 AI 圖表結果呈現,補上更清楚的渲染狀態與多種圖表類型支援" + ] + }, + { + "type": "fix", + "items": [ + "提高 AI Agent 預設工具呼叫輪次,減少複雜任務過早停止的情況", + "修正預設問題標籤與側邊欄元素的層疊順序異常", + "【CLI Web】修正開發後端生命週期清理不完整的問題", + "【桌面版】修正自動技能未註冊圖表工具,導致圖表技能無法完整執行的問題", + "【桌面版】修正 Windows 標題列覆蓋層在切換主題後快取未更新與顯示不順的問題" + ] + }, + { + "type": "refactor", + "items": [ + "集中管理 AI 圖表執行策略,統一 CLI 與桌面版的圖表工具啟用規則", + "精簡技能選單與 Skill Manager 的重複邏輯" + ] + }, + { + "type": "docs", + "items": ["更新 iMessage 聊天記錄匯出指南"] + }, + { + "type": "chore", + "items": ["移除公開儲存庫中的維護者專用技能目錄,改由私有維護上下文管理"] + } + ] + }, + { + "version": "0.24.1", + "date": "2026-06-04", + "summary": "新增應用內更新提醒與 AI 預處理預設規則,並修正更新標記與脫敏設定保存問題。", + "changes": [ + { + "type": "feat", + "items": [ + "新增應用內更新提醒入口,側邊欄可顯示最新版本說明", + "新增 AI 預處理預設設定,自動啟用資料清理、去噪與脫敏等基礎規則", + "支援以群組管理 AI 脫敏規則,方便維護內建與自訂規則" + ] + }, + { + "type": "fix", + "items": [ + "修正更新檢查失敗結果、舊版快取與 CLI Web 開發占位版本造成錯誤 New 標記的問題", + "修正 AI 預處理執行前未正確套用內建脫敏規則的問題", + "修正脫敏規則偏好以空覆蓋儲存時,無法清除舊內建規則開關的問題", + "修正舊版內建脫敏規則遷移後覆蓋設定遺失的問題" + ] + }, + { + "type": "docs", + "items": ["新增公開開發指南,補充本機開發、目錄職責與協作規範"] + }, + { + "type": "ci", + "items": ["發布流程新增 Markdown 版本更新記錄連結"] + } + ] + }, + { + "version": "0.24.0", + "date": "2026-06-03", + "summary": "新增 CLI Web 驗證與資料目錄遷移能力,統一多端 HTTP 路由,並修正資料遷移、AI 設定與匯入刷新問題。", + "changes": [ + { + "type": "feat", + "items": [ + "新增多語版本更新記錄的 Markdown 產生能力", + "支援在儲存空間管理中忽略舊資料遷移提示", + "【CLI Web】新增登入頁、Token 驗證與記住登入狀態", + "【CLI Web】支援資料目錄遷移流程,可在 Web 設定中切換並遷移資料目錄", + "【CLI】新增 --require-auth 參數,用於保護 /_web/* API 存取", + "【桌面端】新增內部 HTTP 服務,讓前端可透過統一服務轉接層使用共享介面" + ] + }, + { + "type": "fix", + "items": [ + "修正資料目錄遷移與資料庫遷移流程中的多個邊界情境,避免舊資料庫欄位缺失或路徑切換後讀取失敗", + "修正舊資料不存在或目錄變更後,資料遷移提示仍錯誤顯示的問題", + "修正增量匯入後側邊欄訊息數量未更新的問題", + "修正側邊欄收合狀態只存於 sessionStorage,導致重新整理後遺失的問題", + "修正編輯 AI 設定且已儲存金鑰時,拉取模型與驗證按鈕未正確啟用的問題", + "修正 AI 共享 SSE 串流回應的穩定性問題", + "修正新增自訂資料來源時未強制填寫 Token 的問題", + "【桌面端】將圖表外掛運算移至 Worker,避免阻塞主執行緒" + ] + }, + { + "type": "refactor", + "items": [ + "抽出 @openchatlab/http-routes 共享套件,統一 CLI Web 與桌面端的 HTTP 路由實作", + "將 AI 設定、助手、技能、對話、串流回應、快取與合併相關 API 遷移到共享 HTTP 路由", + "精簡桌面端 IPC 橋接層,移除舊版 AI、對話索引、LLM、Assistant、Skill、NLP 等相容處理程式", + "統一前端服務層,減少 Electron 與 Web 模式的分支實作" + ] + }, + { + "type": "perf", + "items": ["壓縮主程序建置產物,並延遲載入 tiktoken rank table,降低啟動與打包體積壓力"] + }, + { + "type": "ci", + "items": ["修正 Windows 發布流程中的 zstd 快取問題,並補充 CLI 更新發布說明"] + }, + { + "type": "chore", + "items": ["調整 Node 型別檢查專案設定,涵蓋桌面端與共享 Node 程式碼"] + } + ] + }, + { + "version": "0.23.1", + "date": "2026-06-01", + "summary": "新增 clb 短別名與連接埠預檢提示,修正背景程式靜默退出及深色模式標題列等多處問題。", + "changes": [ + { + "type": "feat", + "items": [ + "最佳化頁面頂部標題列與工具列排版", + "【CLI】啟動前主動偵測連接埠佔用,被佔用時提示切換埠號或執行 lsof,避免延遲報錯", + "【CLI】新增 clb 作為 chatlab 指令的短別名" + ] + }, + { + "type": "fix", + "items": [ + "修正側邊欄 Tooltip 位置異常及 Nuxt UI v4 API 相容問題", + "修正標題列在深色模式下出現紅色背景與層疊順序錯誤", + "規範 AI 訊息角色參數型別,強化對話測試斷言", + "【CLI】修正背景程式啟動入口錯誤導致服務啟動後靜默退出的問題", + "【CLI】改善連接埠偵測的錯誤處理與提示訊息", + "【CLI】修正 Web 模式下缺少 chatlab.fun 反向代理路由的問題" + ] + } + ] + }, + { + "version": "0.23.0", + "date": "2026-05-31", + "summary": "重構訊息編輯互動,新增推理等級按模型獨立設定,統一 CLI 啟動入口,修正多處推理識別與計算問題。", + "changes": [ + { + "type": "feat", + "items": [ + "新增訊息分叉(Fork)功能,可從任意 AI 回覆處建立獨立分支對話", + "狀態列新增推理強度選擇器,支援為每個推理模型個別記憶並切換思維等級", + "推理等級控制新增 default/auto 選項,擴展對 Kimi、Doubao、Gemini 等更多模型系列的支援", + "訊息分析檢視拆分為「類型分析」與「時間分析」兩個分頁,提供豐富統計洞察卡片", + "批次重建對話索引前新增確認彈窗,防止誤操作清空現有摘要", + "示範資料擴充為 4 個檔案,涵蓋群組及多個私訊場景", + "【CLI】新增統一啟動指令 chatlab start,支援 --headless 和 --no-open 參數", + "【CLI】新增背景服務模式,支援 chatlab start --daemon 安裝為系統服務,以及 stop/status 指令" + ] + }, + { + "type": "fix", + "items": [ + "修正訊息編輯可能導致資料遺失與狀態錯誤的多個並發安全問題", + "修正思維等級與上下文視窗計算未使用當前啟用模型 ID 的問題", + "修正自訂模型僅含 chat 能力時推理識別失敗,補全啟發式回退邏輯", + "修正 Kimi、Doubao 等模型在選擇 auto 思維等級時被靜默停用的問題", + "【CLI】修正 start 指令未啟動 Web 開發後端的問題", + "【CLI】修正 Linux 上服務路徑含空格時背景服務啟動失敗的問題" + ] + }, + { + "type": "refactor", + "items": [ + "重構訊息分支系統為「編輯並重新產生」模型,支援僅更新當前輪或覆蓋後續訊息兩種模式", + "移除模型設定中的「推理模型」與「停用思維模式」開關,改為依能力自動推斷", + "簡化模型切換按鈕介面,優化對話索引載入效能" + ] + } + ] + }, + { + "version": "0.22.1", + "date": "2026-05-29", + "summary": "新增對話摘要詳細程度設定,修正 CLI Web 批次摘要凍結、AI 設定編輯誤判及多項憑證偵測問題。", + "changes": [ + { + "type": "feat", + "items": ["新增對話摘要詳細程度設定,支援「簡潔」與「標準」兩種策略,可在 AI 設定中切換"] + }, + { + "type": "fix", + "items": [ + "修正 OpenAI 相容模式下變更 Base URL 後未重新驗證憑證的問題", + "修正 API 金鑰已設偵測邏輯錯誤,避免舊金鑰被誤用", + "修正批次產生摘要時點擊停止需等待當前請求完成才回應的問題", + "【CLI Web】修正批次產生摘要導致頁面凍結的問題", + "【CLI Web】修正批次產生摘要時未遵循已選取工作階段範圍的問題", + "【CLI Web】修正編輯 AI 設定時第三方服務被誤判為本機服務的問題" + ] + }, + { + "type": "refactor", + "items": ["將工作階段索引的國際化 key 從 storage 命名空間遷移至 ai 命名空間"] + }, + { + "type": "style", + "items": ["最佳化聊天記錄清單密度與訊息氣泡樣式"] + }, + { + "type": "docs", + "items": ["重整文件站導覽結構,將快速開始移至使用指南目錄"] + } + ] + }, + { + "version": "0.22.0", + "date": "2026-05-26", + "summary": "本次優化軟體預設狀態的介面樣式,並新增 CLI Web 更新與儲存管理能力,同時改善首頁匯入、文件站與多端穩定性。", + "changes": [ + { + "type": "feat", + "items": [ + "首頁匯入區改為分入口設計,新增 API 匯入與自動同步入口", + "版本紀錄改為隨應用程式本地打包,減少執行時對遠端資源的依賴", + "【CLI Web】新增儲存管理功能,可在 Web 設定中檢視並管理資料快取", + "【CLI】新增更新檢查與自動更新流程,並接入 CLI Web 的更新檢查", + "【文件站】新增獨立文件站 docs.chatlab.fun" + ] + }, + { + "type": "fix", + "items": [ + "修正首頁快捷開始按鈕缺失的問題", + "修正國際化 key 路徑錯誤與 ECharts 廢棄 api.style() 用法", + "強化自動更新與遷移重試流程中的安全保護", + "【桌面端】修正統一遷移可能回退資料目錄改動的問題", + "【CLI Web】修正發現新版本時顯示必定失敗的「立即更新」操作", + "【CLI Web】停用不可用的 Web 自動更新執行流程", + "【CLI Web】修正檔案管理操作依賴 shell 開啟造成的相容性與安全性問題", + "【CLI Web】修正資料目錄提示後 Web 服務無法繼續啟動的問題", + "【CLI Web】補齊合併相關 API 相容層", + "【CLI】優化更新檢查的非同步快取、按鍵互動與開發模式略過邏輯" + ] + }, + { + "type": "refactor", + "items": ["將文件連結遷移到 docs.chatlab.fun", "【設定】將會話索引移入 AI 設定,並調整設定頁排序"] + }, + { + "type": "style", + "items": ["優化側邊欄密度,並提升深色模式下 Tab 選擇器的對比度"] + }, + { + "type": "docs", + "items": ["重整公開文件站結構與匯出說明"] + }, + { + "type": "chore", + "items": ["將工作區套件遷移為 ESM", "隔離文件站工作區依賴", "將發布版本紀錄移出 docs 目錄"] + } + ] + }, + { + "version": "0.21.1", + "date": "2026-05-23", + "summary": "優化 Pull 同步可靠性與資料安全性,新增移除訂閱時清除已匯入聊天記錄的選項,並修正 UI 動畫與彈窗互動問題。", + "changes": [ + { + "type": "feat", + "items": [ + "Pull 同步完成後自動產生會話索引", + "新增移除訂閱時清除已匯入聊天記錄的選項", + "【CLI Web】版本紀錄彈窗支援依版本截圖,Markdown 列表修正改為可選", + "【MCP】新增 ci 變更類型的圖示與多語系支援" + ] + }, + { + "type": "fix", + "items": [ + "修正 Pull 同步在小頁面情境下可能遺失資料的問題,並驗證重試匯入結果", + "修正拉取同步後會話索引未自動產生的問題", + "修正 Pull 完成或資料刪除後側邊欄會話列表未重新整理的問題", + "修正遠端服務不支援分頁時無法取得全部會話的問題", + "優化拉取同步的重試機制與分頁策略,提升穩定性", + "修正會話存在性檢查缺少 schema 驗證導致資料表不存在報錯的問題", + "修正強制產生會話索引彈窗在失敗時意外關閉的問題", + "修正空會話狀態下會話索引彈窗阻塞頁面的問題", + "【桌面端】修正應用程式版本號顯示為 0.0.0 時自動回退讀取 package.json 版本", + "【CLI Web】同步圖示改為原地旋轉動畫,不再出現獨立的載入指示器" + ] + }, + { + "type": "docs", + "items": ["更新 Pull 協議文件為 since+nextSince 分頁模式"] + } + ] + }, + { + "version": "0.21.0", + "date": "2026-05-22", + "summary": "本次更新新增 MCP 支援,統一多端匯入與服務層,並改善 Web、AI、同步與發布穩定性。", + "changes": [ + { + "type": "feat", + "items": [ + "Electron 與 Web 模式支援一致的資料夾匯入流程,可處理多檔聊天記錄格式", + "【MCP】新增獨立命令入口,並在設定頁接入 MCP 設定", + "【MCP】Server 擴充至 19 個工具,並支援精簡文字與 JSON 兩種輸出格式" + ] + }, + { + "type": "fix", + "items": [ + "強化資料夾匯入路徑處理,降低匯入失敗風險", + "修復新增訂閱時可能發生的同步競態問題", + "修復 MiniMax 串流回應中的 內容未正確識別為思考事件的問題", + "【CLI Web】修復增量匯入無法使用的問題", + "【CLI Web】修復會話不存在與成員歷史等行為和桌面版不一致的問題", + "【CLI Web】修復開發服務的 Node 執行環境設定問題", + "【MCP】修復啟動時原生模組 ABI 綁定不一致的問題" + ] + }, + { + "type": "refactor", + "items": [ + "統一 CLI Web 與 Electron 的共用服務層,減少路由與 IPC 中的重複業務邏輯", + "【MCP】精簡對外暴露的工具註冊表,降低外部 AI Agent 的工具 schema 成本", + "【MCP】將核心能力抽取為獨立共用套件,簡化 CLI 與桌面版整合", + "【MCP】精簡設定頁整合方式,降低桌面端輔助程式碼複雜度" + ] + }, + { + "type": "ci", + "items": ["【CLI】發布流程支援同步發布 npm 套件"] + }, + { + "type": "docs", + "items": ["補充 changelog 端特有變更的前綴與排序規則"] + }, + { + "type": "chore", + "items": ["【CLI】【MCP】補齊 npm 發布所需設定與發布說明"] + } + ] + }, + { + "version": "0.20.0", + "date": "2026-05-19", + "summary": "本次更新統一多端核心架構,並改善 AI、匯入、同步與桌面版建置穩定性。同時為下一版的獨立 Web、CLI 與 MCP 能力預做準備;使用 CLI 前請先升級到此版本,以完成資料預遷移。", + "changes": [ + { + "type": "feat", + "items": [ + "新增獨立 CLI、HTTP API 服務與 MCP Server,為命令列、Web 與 AI Agent 整合提供基礎能力", + "新增獨立 Web 建置與一鍵啟動流程,支援透過 CLI 啟動 Web UI 並自動開啟瀏覽器", + "Web 模式支援聊天記錄匯入、Demo 匯入、會話查詢、成員查詢、搜尋與分析等核心流程", + "Web 模式接入 AI 對話、模型設定、自訂 Provider/Model、上下文壓縮與串流事件顯示", + "匯入能力升級為共用串流管線,支援多格式解析、增量匯入、匯入分析與自動產生會話索引", + "新增合併流程、Markdown 匯出與會話快取相關的服務端能力", + "新增同步共用套件與 CLI 自動化支援,作為後續多端同步能力的基礎", + "新增後端偏好設定持久化,並支援 CLI 首次執行時自動偵測與遷移桌面版資料" + ] + }, + { + "type": "fix", + "items": [ + "修復 Web 模式下會話索引、跨來源代理、Demo 保護與執行階段錯誤等問題", + "修復 Web 模式下應用程式版本顯示為空的問題", + "修復 AI Agent 證據檢索、Web 端事件串流與錯誤格式化等問題", + "修復同步顯示與匯入邏輯不一致的問題", + "修復 CLI 開發模式下的 ESM 模組解析問題", + "修復 CLI 安裝後可能找不到既有桌面版資料的問題", + "修復 Electron 套件拆分後資料遷移路徑不一致的問題", + "修復打包後 Worker 執行緒間接依賴 electron 模組導致崩潰的問題", + "修復 Electron 建置、依賴安裝與 better-sqlite3 原生模組重建相關問題" + ] + }, + { + "type": "refactor", + "items": [ + "將專案調整為多端工作區結構,拆分 apps/desktop、apps/cli 與多個共用 packages", + "將解析器、設定、資料庫適配、查詢、遷移、NLP、會話快取、匯入、合併、匯出與同步邏輯抽取為共用模組", + "將 AI Agent、工具系統、預處理、上下文壓縮、RAG、LLM 設定、助手與技能管理抽取為共用執行階段能力", + "統一 Electron、CLI Web 與 MCP 的 AI 工具命名、工具註冊與資料存取方式", + "統一資料目錄、訊息查詢、成員查詢、會話索引、SQL 執行與匯入去重等核心邏輯", + "將 CLI 從 packages/server 遷移到 apps/cli,並補齊 npm 發布建置流程", + "將純前端圖表模組遷移到 src/features,讓 packages 目錄只保留可重用的共用套件", + "移除多處 Electron 側純轉發檔案與廢棄程式碼,減少重複實作" + ] + }, + { + "type": "build", + "items": [ + "升級 Vite 等建置工具鏈", + "新增 CLI tsup 建置、Web 資源打包與 npm 發布所需設定", + "調整 Electron 桌面版建置設定,移除 Linux 桌面版建置目標" + ] + }, + { + "type": "docs", + "items": ["補充版本策略、提交 scope 約定與相關開發文件"] + } + ] + }, + { + "version": "0.19.0", + "date": "2026-05-06", + "summary": "本次更新支援 AI 上下文自動壓縮,新增 Demo 範例,並優化模型設定與除錯體驗。", + "changes": [ + { + "type": "feat", + "items": [ + "支援模型上下文視窗預設與自訂設定", + "支援 AI 對話上下文自動壓縮與相關設定", + "優化上下文壓縮流程與狀態顯示", + "除錯模式新增原始資料檢視,並支援記錄完整 LLM 上下文", + "優化 AI 模型設定文案、表單與設定頁顯示", + "移除向量模型設定,簡化相關設定項目", + "新使用者可在空狀態直接查看 Demo 範例", + "遷移至 pnpm workspace 專案結構" + ] + }, + { + "type": "fix", + "items": [ + "修正快速模型跟隨預設助手時,部分情況可能使用錯誤模型的問題", + "修正空資料狀態下不顯示 Demo 按鈕的問題", + "修正主行程直接依賴未宣告 axios,可能造成啟動失敗的風險" + ] + } + ] + }, + { + "version": "0.18.4", + "date": "2026-04-29", + "summary": "本次更新優化了模型的穩定性,同時支援遠端取得模型清單,優化 AI 錯誤詳細資訊的顯示與部分介面樣式等。", + "changes": [ + { + "type": "feat", + "items": [ + "支援遠端取得模型清單", + "OpenAI 相容 API 網址自動補齊 /v1 並支援即時預覽", + "優化 AI 對話錯誤詳細資訊的顯示", + "優化部分顯示樣式" + ] + }, + { + "type": "fix", + "items": ["修復部分邏輯漏洞"] + }, + { + "type": "chore", + "items": ["優化同步日誌技能的邏輯"] + } + ] + }, + { + "version": "0.18.3", + "date": "2026-04-28", + "summary": "本次更新支援設定快捷工具入口位置,優化預設時間篩選,並修正彈窗層級與資料目錄安全提示。", + "changes": [ + { + "type": "feat", + "items": ["支援設定快捷工具入口位置", "優化預設時間篩選體驗", "禁止將資料目錄設在應用程式安裝目錄內"] + }, + { + "type": "fix", + "items": ["修正彈窗樣式被覆蓋的問題", "修正設定頁內彈窗被遮擋的層級問題"] + } + ] + }, + { + "version": "0.18.2", + "date": "2026-04-26", + "summary": "本次更新新增訂閱類型選擇、遠端會話分頁發現,以及每次拉取訊息數量的設定。", + "changes": [ + { + "type": "feat", + "items": [ + "訂閱會話時支援依類型篩選與選取", + "支援遠端會話分頁發現與按需載入更多", + "支援為資料來源設定每次拉取的訊息數量" + ] + } + ] + }, + { + "version": "0.18.1", + "date": "2026-04-24", + "summary": "本次更新新增 DeepSeek V4 支援與開機自動啟動選項,優化設定與整體介面體驗,並修正 AI 對話中的連結開啟方式。
從這個版本開始,專案將正式遷移至 ChatLab 組織。", + "changes": [ + { + "type": "feat", + "items": [ + "遷移至 ChatLab 組織", + "優化全域樣式表現", + "優化快速提問的顯示邏輯", + "優化設定彈窗的開啟方式", + "支援開機自動啟動", + "支援 DeepSeek V4 模型" + ] + }, + { + "type": "fix", + "items": ["修正 AI 對話中的連結未在瀏覽器中開啟的問題"] + } + ] + }, + { + "version": "0.18.0", + "date": "2026-04-23", + "summary": "本次更新優化 AI 對話互動、整合多個分析入口,並提升資料來源同步與 Windows 更新的穩定性。", + "changes": [ + { + "type": "feat", + "items": [ + "優化 AI 助手互動流程", + "調整新增資料來源表單的提示文案", + "工具 Panel 新增 Mini 模式", + "將聊天記錄檢視器移至工具 Panel", + "統一關係分析相關分頁", + "將語錄模組整體移至洞察", + "將關鍵字分析移至實驗室" + ] + }, + { + "type": "fix", + "items": [ + "修正既有型別警告", + "Pull 增量同步加入 60 秒重疊視窗,避免漏掉訊息", + "Pull 拉取固定使用 `limit=1000`,避免遠端資料來源一次匯出過多資料造成卡頓", + "修正 Windows 更新時 NSIS 彈窗中斷靜默安裝的問題" + ] + } + ] + }, + { + "version": "0.17.5", + "date": "2026-04-21", + "summary": "本次更新聚焦大量錯誤修復,整體穩定性與可用性皆有所提升。", + "changes": [ + { + "type": "feat", + "items": [ + "優化關係卡片樣式。", + "以原生機器識別邏輯取代 `node-machine-id` 依賴,提升 Linux 下 API 金鑰修改穩定性。", + "合併聊天記錄時新增可選保留原始記錄。", + "在預設服務與第三方服務的 API 位址旁新增驗證按鈕。" + ] + }, + { + "type": "fix", + "items": [ + "優化 dataSource 遷移策略,提升遷移安全性。", + "修正話題在訊息過少時空狀態顯示異常。", + "修正本地模型驗證失效問題。", + "修正切換對話後已選分頁被重置的問題。", + "修正舊版 dataSources 升級後自動化頁面白屏問題。" + ] + } + ] + }, + { + "version": "0.17.4", + "date": "2026-04-19", + "summary": "完成 Import API v1 完整協定並新增分層資料來源管理,現已支援聊天記錄自動同步。", + "changes": [ + { + "type": "feat", + "items": ["完成 Import API v1 完整協定與分層資料來源管理"] + } + ] + }, + { + "version": "0.17.3", + "date": "2026-04-17", + "summary": "本次更新為私聊新增語言偏好分頁,側邊欄對話清單支援排序與篩選,完善 AI 服務商與模型設定能力,並修正時間篩選重置問題。", + "changes": [ + { + "type": "feat", + "items": [ + "對話清單新增排序與篩選功能", + "新增語言偏好分頁,可檢視偏好內容", + "優化介面樣式細節,提升視覺一致性", + "AI 服務商新增 Anthropic 支援", + "模型第三方服務支援自行選擇介面類型", + "AI 模型設定支援自訂名稱" + ] + }, + { + "type": "fix", + "items": ["修正從設定或 AI 對話頁返回後,時間篩選會被重置為「全部」的問題"] + }, + { + "type": "refactor", + "items": ["將語言偏好抽離為共用型別,減少重複定義"] + } + ] + }, + { + "version": "0.17.2", + "date": "2026-04-15", + "summary": "本次更新新增跨平台資料合併與成員訊息合併,強化詞庫與更新安全檢查,優化深色模式體驗與日誌能力,並修復多項問題。", + "changes": [ + { + "type": "feat", + "items": [ + "成員管理支援合併成員訊息", + "支援跨平台聊天資料合併", + "資料管理中部分表頭支援排序", + "將話題分析入口移至洞察模組", + "新增 AI 日誌檔案原始路徑紀錄", + "優化深色模式配色,提升視覺體驗" + ] + }, + { + "type": "fix", + "items": [ + "修正詞庫刷新與合併 ID 衝突問題", + "為 OpenAI 相容請求補上執行期 User-Agent 標頭", + "修正深色模式截圖匯出背景透明異常", + "詞庫下載新增 SHA256 完整性檢查", + "收斂遠端設定拉取策略並強化更新安裝確認" + ] + }, + { + "type": "chore", + "items": ["新增 ARM Linux 的 deb 安裝包建置支援", "優化版本日誌同步流程"] + }, + { + "type": "docs", + "items": ["新增繁體中文文件"] + } + ] + }, + { + "version": "0.17.1", + "date": "2026-04-13", + "summary": "重構話題模組並新增話題卡片展示,優化詞雲關鍵詞篩選與查詢快取邏輯,支援遠端下載分詞詞庫及繁體中文詞庫,並完善 WhatsApp 偵測邏輯。", + "changes": [ + { + "type": "feat", + "items": [ + "重構話題模組,新增話題卡片展示", + "詞雲支援關鍵詞篩選", + "支援遠端下載分詞詞庫,新增繁體中文詞庫支援", + "查詢快取邏輯優化", + "部分讀取中介面互動統一", + "完善 WhatsApp 偵測邏輯" + ] + }, + { + "type": "ci", + "items": ["新增官網文件站,支援自動化同步與部署"] + } + ] + }, + { + "version": "0.17.0", + "date": "2026-04-12", + "summary": "本次更新強化 WhatsApp 匯入解析與指定格式匯入,並重整總覽卡片體系,補上分享、截圖與除錯工具能力。", + "changes": [ + { + "type": "feat", + "items": [ + "新增 WhatsApp V2 時間戳彈性解析,可自動適配不同地區匯出格式", + "完善 WhatsApp 聊天紀錄偵測機制", + "新增指定格式匯入能力", + "在訊息分頁新增分享卡片", + "DEBUG 模式新增快速除錯工具", + "優化總覽身份卡並統一時間範圍查詢邏輯", + "重構總覽模組卡片並抽離主題色卡片,預留配色模式", + "統一卡片最大寬度,並將首頁工具升級為全域工具側邊欄", + "主題卡片支援截圖,並預設關閉截圖的行動端適配", + "移除診斷建議並新增提示" + ] + }, + { + "type": "fix", + "items": [ + "修正 WhatsApp 時間解析正則與行匹配正則寬嚴不一致的問題", + "修正 WhatsApp 12 小時制時間與 NNBSP 字元的解析相容性問題" + ] + }, + { + "type": "chore", + "items": ["快取 electron 與 electron-builder 二進位檔以加速 CI 打包"] + } + ] + }, + { + "version": "0.16.0", + "date": "2026-04-10", + "summary": "本次更新新增私聊主動性分析視圖,並修正模型編輯視窗中自訂模型遺失的問題。", + "changes": [ + { + "type": "feat", + "items": ["私聊場景新增主動性分析視圖", "優化頁尾區域的呈現與互動", "優化語錄模組下半部邏輯"] + }, + { + "type": "fix", + "items": ["修正第三方/本地服務編輯視窗會遺失多個自訂模型的問題"] + } + ] + }, + { + "version": "0.15.0", + "date": "2026-04-08", + "summary": "本次版本大幅優化搜尋與查詢效能,搜尋工具可自動帶入上下文訊息,並優化 AI 模型設定、新增部分服務商,同時支援 Linux 平台。", + "changes": [ + { + "type": "feat", + "items": [ + "新增查詢快取以加快存取速度", + "搜尋工具支援自動帶入上下文訊息", + "重構模型設定邏輯", + "新使用者首次啟動時優先顯示語言選擇視窗", + "實驗室新增基礎除錯工具", + "移除舊版提示詞" + ] + }, + { + "type": "fix", + "items": [ + "修正 Windows 淺色模式下標題列按鈕區背景色與應用不一致的問題", + "修正 CI 打包流程中 Node 24 與 pnpm 對齊相關問題", + "補齊工具呼叫顯示名稱的 i18n 翻譯" + ] + }, + { + "type": "refactor", + "items": ["優化 AI 設定彈窗的程式碼組織"] + }, + { + "type": "chore", + "items": ["升級至 Node 24", "支援 Linux 打包"] + }, + { + "type": "docs", + "items": ["更新文件"] + } + ] + }, + { + "version": "0.14.2", + "date": "2026-04-07", + "summary": "本次更新聚焦 AI 對話體驗升級,新增對話複製、優化介面,並支援 FTS5 全文搜尋工具,同步精簡部分搜尋參數並強化錯誤提示與測試能力。", + "changes": [ + { + "type": "feat", + "items": [ + "新增可在 7 天內記住助手選擇的功能", + "AI 對話支援一鍵複製訊息內容", + "優化 AI 對話樣式與整體互動體驗", + "支援 FTS5 全文搜尋,並新增快速搜尋工具", + "精簡部分工具的搜尋參數,降低 token 消耗", + "新增 Electron 應用 E2E 測試框架,支援連接埠管理與實例隔離" + ] + }, + { + "type": "fix", + "items": ["完善 AI 對話錯誤提示,提升問題定位效率"] + }, + { + "type": "refactor", + "items": ["整理 AI 對話模組程式結構", "抽出會話分析頁共用邏輯並統一頁首文案"] + }, + { + "type": "test", + "items": ["補充可重複使用的 E2E 啟動器冒煙測試能力"] + }, + { + "type": "docs", + "items": ["更新專案文件中的導覽圖片"] + }, + { + "type": "style", + "items": ["統一部分程式碼格式,提升可讀性"] + } + ] + }, + { + "version": "0.14.1", + "date": "2026-04-02", + "summary": "本次更新聚焦首頁資訊架構調整與介面優化,同步提升 SQL 對話體驗、統計讀取效能與 AI 工具品質。", + "changes": [ + { + "type": "feat", + "items": [ + "優化總覽頁樣式", + "優化 SQL 對話模組互動流程", + "將成員管理移至首頁,並調整相關分頁配置", + "新增部分 AI 工具,包含聊天概覽取得能力", + "新增對話資料快取管理模組,提升統計讀取效能", + "完善版本日誌彈窗的類型呈現" + ] + }, + { + "type": "fix", + "items": ["修復 SQL Lab 與摘要產生流程中 AI 錯誤被靜默吞沒的問題"] + }, + { + "type": "refactor", + "items": ["重構 AI 工具分類體系,提升工具組織的可維護性"] + }, + { + "type": "chore", + "items": ["淘汰部分低價值 AI 工具,精簡可用工具集"] + } + ] + }, + { + "version": "0.14.0", + "date": "2026-03-28", + "summary": "新增 API 匯入匯出與預設提問,優化總覽與設定體驗,並修復訊息去重、AI 對話流程與每日訊息趨勢顯示問題。", + "changes": [ + { + "type": "feat", + "items": [ + "新增 API 匯入", + "新增 API 匯出", + "點選預設問題後可直接送出提問", + "設定頁支援選擇會話預設進入的分頁", + "優化總覽頁樣式", + "優化整體介面樣式與 API 服務設定頁", + "優化身份卡片與助手選擇互動" + ] + }, + { + "type": "fix", + "items": [ + "修復訊息去重誤判,並統一空字串的去重行為", + "修復 AI 對話流程與前端 type-check 錯誤", + "補上預設 assistant 的兜底邏輯,修復例外情境", + "修復每日訊息趨勢未顯示的問題" + ] + }, + { + "type": "refactor", + "items": ["整理 parser、worker、RAG 與 merger 的歷史型別問題"] + }, + { + "type": "chore", + "items": ["新增 assistant 設定產生技能"] + } + ] + }, + { + "version": "0.13.0", + "date": "2026-03-16", + "summary": "AI 對話支援助手模式,對話可使用技能,輸入框支援快捷選擇,完善對話與設定介面,支援繁體中文與日文,並調整部分 UI,同時修復多項穩定性問題。", + "changes": [ + { + "type": "feat", + "items": [ + "完成助手模式初版並強化助手邏輯與分析工具能力", + "上線助手市集與技能市集,聊天對話支援使用技能", + "支援以 @ 選擇成員協作", + "支援繁體中文與日文在地化", + "設定頁面重構並優化部分 UI 細節", + "優化總覽模組樣式與對話介面體驗", + "調整匯出聊天記錄的入口位置", + "移除舊版提示詞系統與自訂篩選 AI 功能", + "切換頁面時模型呼叫不再中斷" + ] + }, + { + "type": "fix", + "items": ["修復 Gemini API 設定問題", "修復 NLP 停用詞呼叫順序導致的錯誤"] + }, + { + "type": "refactor", + "items": ["重構 AIChat 組織結構", "重構目錄位置與專案結構"] + }, + { + "type": "docs", + "items": ["更新使用者協議與專案文件"] + }, + { + "type": "chore", + "items": ["優化版本日誌建置流程"] + }, + { + "type": "style", + "items": ["統一程式碼格式與 lint 規則輸出"] + } + ] + }, + { + "version": "0.12.1", + "date": "2026-02-27", + "summary": "新增聊天紀錄前處理與除錯能力,重構 Agent/LLM 架構,並修復國際化與 Windows 主題顯示問題。", + "changes": [ + { + "type": "feat", + "items": [ + "新增聊天紀錄前處理流程", + "新增前處理設定介面與設定管理能力", + "Agent 支援依會話顯示上下文時間軸與執行狀態", + "新增 AI 除錯模式並提升日誌可觀測性" + ] + }, + { + "type": "fix", + "items": ["修復英文設定下部分介面未完成國際化的問題", "修復 Windows 動態更新 overlay 色彩時主題不一致的問題"] + }, + { + "type": "refactor", + "items": [ + "將 Agent 單體實作拆分為模組化架構", + "將工具系統重構為 AgentTool + TypeBox 結構並補齊 i18n", + "統一 LLM 存取層,收斂為 pi-ai 方案", + "重構資料流方向與 IPC 協議,並完成前端適配", + "引入共享型別並優化 ChatStatusBar 國際化", + "將部分圖表重構為外掛化架構" + ] + }, + { + "type": "chore", + "items": [ + "移除過度設計的 sessionLog 模組", + "移除 @ai-sdk 相關依賴與舊版 LLM 服務實作", + "暫時隱藏向量模型設定入口", + "更新專案描述文案" + ] + }, + { + "type": "style", + "items": ["執行 ESLint 自動修正,統一程式碼風格"] + } + ] + }, + { + "version": "0.11.2", + "date": "2026-02-15", + "summary": "優化聊天紀錄匯入機制與管理頁體驗,並提升多平台聊天紀錄的相容性。", + "changes": [ + { + "type": "feat", + "items": [ + "強化 LINE 與 WhatsApp 解析器的格式相容性", + "優化聊天紀錄嗅探層,支援輪詢偵測與回退機制", + "管理頁支援 Shift 多選", + "管理頁新增顯示聊天摘要數量與 AI 對話數量", + "優化首頁版面,提升可用空間", + "優化 Windows 右上角控制列樣式" + ] + }, + { + "type": "docs", + "items": ["更新專案文件"] + } + ] + }, + { + "version": "0.11.0", + "date": "2026-02-13", + "summary": "支援 Telegram 匯入,優化增量匯入體驗,補齊國際化設定,並修復索引失效與頁面閃爍等問題。", + "changes": [ + { + "type": "feat", + "items": [ + "補齊 AI 呼叫、日誌與主進程設定的國際化支援", + "支援 Telegram 聊天紀錄匯入", + "優化增量匯入互動與相關文案", + "優化開啟協議時的互動體驗" + ] + }, + { + "type": "fix", + "items": [ + "修復增量匯入後索引失效的問題(resolve #81)", + "修復 WhatsApp 使用 iPhone 匯出後無法辨識的問題(resolve #82)", + "修復切換對話頁面時出現雙重閃爍的問題" + ] + }, + { + "type": "chore", + "items": ["優化 TypeScript 設定", "調整 i18n 建置設定", "優化技能相關工程設定"] + } + ] + }, + { + "version": "0.10.0", + "date": "2026-02-11", + "summary": "新增互動頻率分析能力,優化會話查詢流程,並修復增量索引與資料庫掃描相關問題。", + "changes": [ + { + "type": "feat", + "items": ["新增互動頻率分析視圖,更直觀地觀察成員互動趨勢", "優化會話查詢相關邏輯與處理流程"] + }, + { + "type": "fix", + "items": [ + "修復增量更新後會話索引產生範圍不準確的問題(fix #79)", + "修復遷移與會話掃描時誤處理非聊天 SQLite 檔案的問題" + ] + }, + { + "type": "refactor", + "items": ["重構會話查詢模組,提升查詢結構可維護性"] + }, + { + "type": "chore", + "items": ["移除 transformers 相關依賴並更新工程設定"] + } + ] + }, + { + "version": "0.9.4", + "date": "2026-02-08", + "summary": "優化時間篩選與 AI 設定體驗,新增 API Key 本機加密,並修復 LINE 聊天紀錄解析問題。", + "changes": [ + { + "type": "feat", + "items": [ + "時間篩選支援更多彈性選項", + "API Key 支援本機加密儲存", + "新用戶首次進入時不再顯示版本日誌", + "優化 AI 對話底部的設定狀態顯示", + "資料目錄遷移後支援立即重新啟動軟體" + ] + }, + { + "type": "fix", + "items": ["修復 LINE 聊天紀錄解析問題"] + }, + { + "type": "docs", + "items": ["更新專案文件"] + } + ] + }, + { + "version": "0.9.3", + "date": "2026-02-03", + "summary": "支援自訂資料目錄,並修復多項已知問題。", + "changes": [ + { + "type": "feat", + "items": [ + "設定中新增資料目錄位置選項", + "優化資料儲存目錄遷移邏輯", + "切換目錄時新增確認彈窗", + "優化解析邏輯(WeFlow / Echotrace)" + ] + }, + { + "type": "fix", + "items": [ + "修復 Windows 自訂篩選時訊息量過大導致崩潰的問題", + "修復第三方中轉 API 呼叫 tool_call 導致對話異常結束的問題", + "修復部分 WhatsApp 聊天紀錄無法正確辨識的問題", + "修復管理頁表頭層級顯示問題" + ] + }, + { + "type": "refactor", + "items": ["重構 session 查詢模組", "補強遷移日誌輸出"] + } + ] + }, + { + "version": "0.9.2", + "date": "2026-02-02", + "summary": "排行榜改為圖表呈現,優化詞雲與本機 AI 推理模型,改進聊天紀錄篩選與日期選擇器,並在啟動後預先載入關鍵路由。", + "changes": [ + { + "type": "feat", + "items": [ + "排行榜改為圖表展示", + "優化詞雲效果", + "優化推理模型", + "優化訊息會話搜尋與篩選的聯動體驗", + "優化日期選擇器互動", + "啟動後預先載入關鍵路由" + ] + }, + { + "type": "chore", + "items": ["將 preload 模組化拆分", "優化 analytics 邏輯", "升級 ESLint 並整理程式碼格式"] + } + ] + }, + { + "version": "0.9.1", + "date": "2026-01-30", + "summary": "支援 LINE 聊天紀錄匯入,新增批次管理與聊天搜尋,並修復一些已知問題。", + "changes": [ + { + "type": "feat", + "items": [ + "新增批次管理,支援批次刪除與合併", + "支援聊天搜尋", + "支援 LINE 聊天紀錄匯入", + "相容 WeFlow 匯出的 JSON 格式", + "成員列表改為後端分頁載入", + "優化部分文案" + ] + }, + { + "type": "fix", + "items": ["修復 Windows 更新時因 Worker 占用導致軟體無法關閉的問題"] + } + ] + }, + { + "version": "0.9.0", + "date": "2026-01-28", + "summary": "支援 NLP 分詞能力,語錄頁新增詞雲;新增視圖功能可展示更多圖表;支援系統代理跟隨,並優化部分頁面與樣式。", + "changes": [ + { + "type": "feat", + "items": [ + "優化使用者選擇器效能,支援虛擬載入", + "將排行榜移至視圖分頁", + "引入分詞能力,並新增詞雲子分頁", + "優化群聊頁分頁文案", + "網路代理支援跟隨系統代理", + "優化版本日誌顯示判斷邏輯" + ] + }, + { + "type": "style", + "items": ["優化 Markdown 渲染樣式"] + } + ] + }, + { + "version": "0.8.0", + "date": "2026-01-26", + "summary": "新增會話摘要與向量檢索能力,版本更新後會顯示更新內容,優化部分介面互動,並修復一些已知問題。", + "changes": [ + { + "type": "feat", + "items": [ + "聊天會話支援摘要功能", + "新增批次產生會話摘要邏輯", + "支援向量模型設定與相關檢索", + "匯入聊天紀錄報錯時記錄更詳細的日誌", + "每次更新到新版本後自動開啟版本日誌", + "首頁新增 Footer,顯示常用連結", + "側邊欄移除幫助與回饋" + ] + }, + { + "type": "fix", + "items": ["修復 shuakami-jsonl 解析錯誤(fix #50)"] + } + ] + }, + { + "version": "0.7.0", + "date": "2026-01-23", + "summary": "優化 AI 對話體驗,改進更新流程,並以 ECharts 取代 chart.js。", + "changes": [ + { + "type": "feat", + "items": [ + "優化更新流程", + "補強 AI 對話錯誤日誌", + "聊天底部支援快速切換對話模型", + "優化預設提示詞,增加一點幽默感", + "以 ECharts 取代 chart.js", + "移除註冊協議邏輯" + ] + } + ] + }, + { + "version": "0.6.0", + "date": "2026-01-21", + "summary": "接入 AI SDK 提升 AI 對話穩定性,新增思考內容區塊,並優化部分樣式。", + "changes": [ + { + "type": "feat", + "items": [ + "新增定位日誌功能", + "接入 AI SDK", + "新增思考內容區塊", + "解決全域彈窗被首頁上方拖曳區域遮擋的問題", + "優化 Windows 右上角關閉按鈕樣式" + ] + } + ] + }, + { + "version": "0.5.2", + "date": "2026-01-20", + "summary": "支援合併匯入,並修復一些問題。", + "changes": [ + { + "type": "feat", + "items": ["支援合併匯入", "主面板顯示聊天紀錄起訖時間", "優化拖曳區域"] + }, + { + "type": "fix", + "items": [ + "優化建置設定以修復 macOS x64 編譯問題", + "修復訊息紀錄檢視器在 Windows 下的關閉按鈕樣式問題", + "macOS 打包時需在對應架構上編譯(fixes #36)" + ] + } + ] + }, + { + "version": "0.5.1", + "date": "2026-01-16", + "summary": "修復一些問題。", + "changes": [ + { + "type": "feat", + "items": ["優化文案"] + }, + { + "type": "fix", + "items": ["修復 Windows 下關閉軟體後程序未退出的問題(#33)", "修復數字輸入框 BUG(resolve #34)"] + } + ] + }, + { + "version": "0.5.0", + "date": "2026-01-14", + "summary": "支援 Instagram 聊天紀錄匯入;首頁支援批次匯入;聊天頁支援增量匯入。", + "changes": [ + { + "type": "feat", + "items": [ + "支援 Instagram 聊天紀錄匯入", + "優化整體邏輯", + "優化系統提示詞預設功能", + "支援增量匯入", + "支援批次匯入", + "優化樣式", + "Windows 端支援原生視窗控制並實作主題同步(#31)" + ] + }, + { + "type": "chore", + "items": ["移除 componenst.d.ts"] + } + ] + }, + { + "version": "0.4.1", + "date": "2026-01-13", + "summary": "優化部分樣式與互動體驗。", + "changes": [ + { + "type": "feat", + "items": [ + "提示詞支援預覽", + "優化 AI 對話狀態列", + "優化遷移表邏輯", + "側邊欄支援顯示頭像", + "優化樣式", + "替換原生視窗控制列", + "優化全域背景色", + "關閉軟體時清理 Worker" + ] + }, + { + "type": "fix", + "items": ["修復主題模式設定跟隨系統不生效的問題", "修復更新彈窗提示內容排版問題"] + } + ] + }, + { + "version": "0.4.0", + "date": "2026-01-12", + "summary": "匯入支援 shuakami-jsonl 格式;AI 對話更省 Token;匯入聊天紀錄時可建立會話索引,查看器也支援依索引快速跳轉;軟體更新支援加速鏡像。", + "changes": [ + { + "type": "feat", + "items": [ + "相容 shuakami-jsonl", + "優化 Loading 體驗", + "新增自訂篩選", + "重構預設詞系統,支援通用預設詞", + "精簡系統提示詞以節省 Token", + "新增會話相關 function calling 呼叫", + "處理訊息跳轉到上下文的邏輯", + "聊天紀錄檢視器支援查看會話索引與快速跳轉", + "重構設定彈窗,新增會話索引設定", + "匯入聊天紀錄時支援產生會話索引", + "重構設定彈窗", + "優化基礎元件互動樣式", + "優化首頁樣式", + "優化更新加速邏輯", + "新增加速鏡像" + ] + } + ] + }, + { + "version": "0.3.1", + "date": "2026-01-09", + "summary": "已適配 Discord 匯入;各解析器支援回覆類型匯入;軟體儲存目錄遷移至更標準的位置;匯入支援角色;匯入報錯提供更詳細的診斷與提示,並帶來一些細節優化。", + "changes": [ + { + "type": "feat", + "items": [ + "資料表升級改由主進程執行", + "自動檢查更新時忽略 beta 版本", + "將資料儲存目錄遷移到 userData 下", + "各解析器重新支援回覆訊息匯入", + "支援平台訊息 ID 與回覆 ID,並同步進行資料表遷移", + "支援 Tyrrrz/DiscordChatExporter 訊息格式匯入", + "member 表支援角色", + "強化 ChatLab 格式偵測行為", + "確保點擊匯入與拖曳匯入的邏輯一致", + "支援更詳細的格式診斷" + ] + }, + { + "type": "fix", + "items": ["修復部分使用者 platformId 為空的情況"] + } + ] + }, + { + "version": "0.3.0", + "date": "2026-01-08", + "summary": "完成完整國際化支援,支援中英文切換,並進一步優化部分功能。", + "changes": [ + { + "type": "feat", + "items": [ + "SQL 實驗室支援匯出", + "AI 對話支援匯出", + "完成最終國際化", + "AI 模型出錯時顯示明確錯誤", + "SQL 結果支援跳轉到訊息檢視器", + "優化系統 prompt,支援 prompt 市集" + ] + } + ] + }, + { + "version": "0.2.0", + "date": "2025-12-29", + "summary": "支援代理設定;匯入時支援顯示錯誤日誌;優化部分介面互動,並帶來一些功能更新。", + "changes": [ + { + "type": "feat", + "items": [ + "訊息管理器支援顯示系統訊息", + "優化匯入流程,錯誤時會顯示匯入日誌", + "WhatsApp 支援英文格式訊息匯入", + "支援設定代理(resolve #7)", + "優化 AI 模型介面互動", + "新增使用者設定 API 教學", + "新增 2 個免費 GLM 模型,並加入豆包服務商與最新模型", + "AI 回覆不再輸出 think 內容" + ] + } + ] + }, + { + "version": "0.1.3", + "date": "2025-12-25", + "summary": "修復一些問題。", + "changes": [ + { + "type": "fix", + "items": ["修復 Echotrace 解析器錯誤"] + } + ] + }, + { + "version": "0.1.2", + "date": "2025-12-25", + "summary": "支援深色模式;AI 對話中的系統提示詞可帶入使用者身分。", + "changes": [ + { + "type": "feat", + "items": [ + "AI 對話中的系統提示詞可帶入使用者身分", + "聊天紀錄檢視器中,Owner 顯示在右側", + "支援資料庫升級", + "成員分頁支援設定 Owner 視角", + "支援深色模式" + ] + }, + { + "type": "fix", + "items": ["修復將私聊誤判為群聊的問題"] + } + ] + }, + { + "version": "0.1.1", + "date": "2025-12-24", + "summary": "已適配 WhatsApp 聊天紀錄匯入;支援舊版 QQ 討論組格式分析。", + "changes": [ + { + "type": "feat", + "items": ["聊天會話底部顯示 Token 消耗", "支援 WhatsApp 原生格式訊息", "支援舊版 QQ txt 討論組格式"] + }, + { + "type": "fix", + "items": ["修復訊息管理器層級過低的問題"] + } + ] + }, + { + "version": "0.1.0", + "date": "2025-12-23", + "summary": "專案正式開源發布。", + "changes": [ + { + "type": "feat", + "items": ["init"] + } + ] + } +] diff --git a/changelogs/tw.md b/changelogs/tw.md new file mode 100644 index 000000000..e878b1719 --- /dev/null +++ b/changelogs/tw.md @@ -0,0 +1,1427 @@ +# 更新日誌 + +## v0.29.0 (2026-07-01) + +> 新增聯絡人功能與關係星圖,改善聯絡人計算、頭像載入與側邊欄效能。 + +### ✨ 新功能 + +- 新增人際關係模組,將聯絡人作為子頁面,支援跨會話聯絡人彙整、時間範圍篩選、分頁與虛擬捲動 +- 聯絡人頁支援手動將群組成員標記為好友,並提供前往來源對話與檢視聊天記錄的入口 +- 新增互動關係星圖,支援 3D 全景、節點篩選、搜尋、詳情面板,以及相關聯絡人/群組探索 +- 關係星圖支援高關聯、僅好友等視圖篩選,並在隱私模式下遮蔽姓名 +- 【桌面端】支援在背景靜默下載應用程式更新 + +### ⚡ 效能 + +- 聯絡人列表改用分頁與虛擬捲動,降低大量好友與群組成員造成的渲染壓力 +- 頭像改為懶載入,並將側邊欄會話列表虛擬化,減少啟動與頁面切換卡頓 + +### 💄 樣式 + +- 統一暗色模式的主背景、關係頁頂部與側邊欄選取狀態 + +## v0.28.1 (2026-06-26) + +> 優化聊天記錄檢視,新增匯入 API,修正匯入、同步與本機模型代理下載問題。 + +### ✨ 新功能 + +- 新增 Push 匯入 API,支援依會話追加寫入、去重與增量索引產生 +- 匯入完成後自動產生會話索引,涵蓋 CLI、桌面端與 pull sync 匯入流程 +- 優化聊天記錄檢視器的控制項與高亮顯示體驗 +- 側邊欄預設隱藏捲軸,滑鼠移入時顯示 + +### 🐛 修復 + +- 強化 Push/Pull 匯入校驗、去重與並發鎖定,避免異常輸入造成誤寫或重複匯入 +- GET /api/v1/sessions/:id 補齊 lastPlatformMessageId 與 importedAt 欄位,支援增量匯入邊界判斷 +- 背景 pull sync 完成後自動刷新會話列表 +- 修正本機語意索引模型下載未正確使用代理的問題,並補齊 Worker 日誌初始化 +- 【桌面端】API 預設連接埠與 CLI 對齊為 3110,連接埠被占用時會自動嘗試後續連接埠 +- 【桌面端】系統代理解析支援 HTTP、HTTPS 與 SOCKS 結果,避免本機模型下載靜默繞過代理 + +### ♻️ 重構 + +- 【CLI】pull/sync 匯入流程改用共享 streaming importer,移除舊解析與寫入實作 +- 【桌面端】移除增量匯入後重複產生會話索引的多餘呼叫 + +### 📝 文件 + +- 修正文檔中的 API 連接埠、範例與未實作能力說明 + +## v0.28.0 (2026-06-25) + +> 新增 Google Chat 匯入支援,引入統一應用程式日誌模組,優化語意索引使用體驗。 + +### ✨ 新功能 + +- 新增 Google Chat 匯入支援,涵蓋 CLI 與桌面端完整流程 +- 新增統一應用程式日誌模組,支援依檔案大小自動輪替與全域未捕獲錯誤記錄 +- 語意索引管理改為「移除」操作取代停用,修正 legacy hash,限制清單高度 +- 語意索引模型設定儲存時自動預熱,並遷移至 AI 目錄統一管理 +- 簡化語意索引設定,移除搜尋結果數量設定項,改用內建預設值 + +### 🐛 修復 + +- 補齊 stream-json/stream-chain 相依套件聲明,修正多聊天掃描穿透問題 + +### 📝 文件 + +- 文件新增 Google Chat 支援平台說明 + +## v0.27.2 (2026-06-24) + +> 修正 AI auth profile 生命週期管理的多項問題,提升分析服務穩定性,補齊本機嵌入執行環境相依套件。 + +### ✨ 新功能 + +- 【CLI Web】透過共享 AnalyticsService 統一回報日活用戶資料 + +### 🐛 修復 + +- 刪除 AI 服務設定時同步清除對應的 auth profile + +### ♻️ 重構 + +- 移除已過期的分析設定遷移邏輯 + +## v0.27.1 (2026-06-23) + +> 支援 API 批次向量化加速語意索引建立,改為惰性載入本機模型;修正斷點續跑、同步游標回退與 AI 對話匯出等問題。 + +### ✨ 新功能 + +- 語意索引支援 API 嵌入模型批次向量化,大幅提升索引建立速度 +- 本機嵌入模型改為惰性 Worker 載入,降低啟動資源消耗 + +### 🐛 修復 + +- 修正 AI 對話匯出:支援匯出目前可見的 AI 對話內容 +- 修正語意索引批次寫入中途崩潰後,續跑觸發唯一約束錯誤、會話狀態卡住的問題 +- 修正語意 Worker 多項問題:設定變更未同步至執行中的 Worker、可用性探針異常影響一般 AI 對話、活躍建立狀態追蹤不準確導致 Worker 提前關閉 +- 修正同步拉取游標:空回應重試前未儲存初始頁伺服器水位,導致重複拉取尾端視窗 +- 修正同步拉取分頁游標錯誤回退,避免重複匯入 + +### ♻️ 重構 + +- 清理多處無效程式碼與過度設計的抽象,簡化工具目錄結構與深層合併邏輯 + +## v0.27.0 (2026-06-22) + +> 新增向量索引與證據檢索功能,支援透過語意搜尋定位並呈現聊天證據,並提供索引管理介面。 + +### ✨ 新功能 + +- 新增語意索引管理介面,可選擇指定會話建立向量索引,提供多語言支援 +- 新增 retrieve_chat_evidence 工具:AI 可透過語意搜尋檢索帶時間定位的聊天證據 +- 規劃器自動辨識證據類問題,路由至語意檢索工具處理 +- 證據內容區塊支援展開檢視、折疊收起與點擊跳轉原始訊息 +- 語意檢索支援時間範圍篩選 +- 重新設計語意索引模型選擇介面 +- AI 處理過程片段支援折疊收起 +- 簡化證據來源列與 Agent 工具呼叫結果呈現 + +### 🐛 修復 + +- 修正多關鍵字訊息搜尋功能 +- 修正詞雲詞典載入 UX 回歸問題 +- 修正排行榜標籤與 emoji 清理 +- 修正 v8 資料庫遷移缺少國際化文字的問題 +- 【CLI】修正工具轉接器中會話上下文傳遞與前處理缺失的問題 + +## v0.26.3 (2026-06-15) + +> 優化分析模組效能:新增磁碟快取與過期請求取消機制,切換會話更順暢;修正詞雲、高頻詞統計及時間軸等多處問題。 + +### ✨ 新功能 + +- 【CLI Web】新增網站圖示(favicon) + +### ⚡ 效能 + +- 分析結果依資料庫檔案版本落地快取,大幅提升二次載入速度 +- 切換會話或篩選條件時自動取消過期分析請求 +- 切換詞雲詞數時略過重新分詞,減少不必要的運算 + +### 🐛 修復 + +- 修正高頻詞統計中含有平台回覆訊息占位符的問題 +- 修正詞雲文字中包含媒體占位符的問題 +- 修正 Jieba 自訂詞典變更後語言偏好快取與詞頻快取未失效的問題 +- 修正時效性分析快取未按日失效的問題 +- 修正時間軸面板預設捲動位置及會話跳轉功能失效的問題 +- 修正片段訊息上下文缺失的問題 +- 修正 AI 對話數量始終回傳 0 的問題 +- 修正首頁教學連結未使用本地化路徑的問題 +- 移除 AI 聊天頂部截圖按鈕 +- 【桌面端】預先打包懶載路由依賴,避免開發模式 504 錯誤 +- 【CLI】修正版本號不同步導致誤報更新提示的問題 +- 【CLI】更新提示支援方向鍵選擇 + +### ♻️ 重構 + +- 將時間軸面板標籤重新命名為「摘要」 + +## v0.26.2 (2026-06-13) + +> 優化會話「我是誰」身分識別功能,支援手動選擇 owner profile 並自動批次填充同平台會話;修正多處圖表渲染與 AI 快取問題。 + +### ✨ 新功能 + +- 新增會話「我是誰」身分識別功能:手動確認後儲存平台 owner profile,並自動批次比對填充同平台其他未設定擁有者的會話 +- 匯入和 pull sync 完成後自動嘗試套用已儲存的 owner profile,減少手動設定次數 + +### 🐛 修復 + +- 修正 name-match 平台(WhatsApp / Line / Instagram)批次填充時各會話 ownerId 快取與資料庫寫入不一致的問題 +- 修正多路由共用獨立 PreferencesManager 實例導致 owner profile 被覆寫為舊值的問題 +- 【CLI】修正長期執行時 sync 模組 preferences 快取過期,pull 匯入新會話後 owner profile 未自動生效的問題 +- 修正匯入後暫存檔案未完全刪除的問題 +- 修正 AI 圖表在串流生成期間渲染抖動的問題 +- 修正 AI 圖表渲染順序不穩定的問題 +- 修正 AI 圖表存在僅占位空白列的問題 +- 新增 AI 圖表生成進度提示 +- 修正截圖前未調整圖表尺寸導致圖表顯示不完整的問題 +- 修正 last_message_time 語義:表示資料涵蓋截止時間,而非群組最後活躍時間 +- 修正增量匯入後概覽與分析快取未失效,導致顯示舊資料的問題 + +## v0.26.1 (2026-06-10) + +> 修正工具呼叫歷史重播多項問題,補齊工具呼叫持久化與重播能力;狀態列新增 Token 快取統計,訊息匯出支援多種格式。 + +### ✨ 新功能 + +- 聊天狀態列新增 Token 快取命中/未命中用量顯示 +- 訊息匯出支援 TXT、JSON、Markdown 多種格式 + +### 🐛 修復 + +- 支援持久化並重播對話中的工具呼叫記錄,保持多輪 AI 上下文完整性 +- 修正工具呼叫無文字輸出的 assistant 輪次被歷史重播過濾,導致工具結果遺失的問題 +- 修正連續匯出同一會話時,因檔名衝突導致靜默覆蓋的問題 +- 【桌面版】修正大型會話匯出因 60 秒 IPC 逾時提前失敗的問題 +- 修正 DeepSeek 工具呼叫輪次的 reasoning_content 未隨歷史重播 +- 修正歷史壓縮時重播工具結果的 token 數未被正確計入 +- 修正工具結果傳給模型的文字中包含不應暴露的原始訊息資料 +- 修正工具面板在點擊外部區域時未自動關閉 + +### ♻️ 重構 + +- 將會話頁面頂部列提取為共用元件 + +## v0.26.0 (2026-06-10) + +> 新增 AI 分析規劃器,支援流式結構化計畫產生與圖表規劃整合;改善圖表模式工具可用性,修正多項 AI 與匯入問題。 + +### ✨ 新功能 + +- 新增 AI 分析規劃器,支援流式結構化分析計畫產生與區塊渲染 +- 整合圖表規劃支援,AI 分析計畫可自動銜接圖表產生 +- 規劃器引入啟動上下文與延伸資料快照,提升分析深度與品質 +- 圖表渲染優先使用高層工具而非原始 SQL,降低權限需求 +- 改善 AI 分析與圖表整體行為 +- 新增圖表自動模式偏好設定 +- 新增訊息 ID 複製操作 +- 新增 shadow 路由 LLM 備援機制,並記錄路由決策日誌 +- 【CLI】公開完整 Agent 串流事件 + +### 🐛 修復 + +- 修正明確選擇圖表技能時 render_chart 工具遺失的問題 +- 修正明確圖表模式下工具可用性缺口及錯誤後計畫狀態異常 +- 遷移 ECharts 6 containLabel 設定,隱藏多餘的圓餅圖圖例 +- 修正設定頁導覽順序、skillSettings 鍵名及 SubTabs 換行問題 +- 修正 AI 助理訊息未填滿聊天區域寬度 +- 改善分析計畫產生區塊的顯示樣式 +- 修正思考深度選擇跨重新啟動後不持久的問題 +- 修正 JSONL 時間戳記規範化導致增量匯入異常 +- 【桌面版】修正 execute_sql 工具未註冊至桌面版工具清單 + +### test + +- 新增 Agent 路由決策評估測試集 + +## v0.25.1 (2026-06-08) + +> 新增資料目錄相容性門禁,防止舊版執行環境誤寫已升級的資料;並新增 CLI ai chat 指令。 + +### ✨ 新功能 + +- 新增資料目錄相容性門禁:寫入 v6 schema 資料後自動記錄最低可存取的執行環境版本,防止舊版本誤寫已升級的資料目錄 +- 資料庫操作前校驗資料目錄相容版本,不符合要求時拒絕存取 +- 【桌面版】啟動時校驗資料目錄相容版本,不符合時顯示提示視窗並結束 +- 【CLI】啟動時校驗資料目錄相容版本,不符合時立即結束 +- 【CLI】新增 ai chat 指令,支援在終端機與 AI 進行多輪對話 + +### 🐛 修復 + +- 改用打包版本號(而非 Electron 開發模式回報的 0.0.0)寫入相容性門禁,並強制要求執行環境身分識別 +- 資料庫遷移與匯入完成後正確寫入相容性門禁;啟動遷移失敗時立即回報錯誤,不再靜默忽略 +- 【MCP】啟動時主動校驗資料目錄相容性,版本不符時提前結束,避免寫入不相容資料 +- 【桌面版】HTTP API 將相容性門禁錯誤對應為 409 回應,方便前端顯示升級提示 +- 【CLI】規範化 AI 工具名稱,修正 ai chat 指令的邊界處理問題 + +### ♻️ 重構 + +- 統一 AI 對話與 segment 識別碼的命名規範 + +### test + +- 補充解析器、設定遷移、資料庫遷移、HTTP 路由與 AI 工具的測試覆蓋 + +### 📝 文件 + +- 新增資料目錄相容性門禁公開文件,說明多版本共用資料目錄的限制與升級規則 + +## v0.25.0 (2026-06-07) + +> AI 對話現在可透過技能產生圖表,並修正工具輪次、桌面標題列與開發服務生命週期問題。 + +### ✨ 新功能 + +- 新增以斜線指令觸發的 AI 圖表執行環境,可在對話中產生並渲染 ECharts 圖表 +- 改善 AI 圖表結果呈現,補上更清楚的渲染狀態與多種圖表類型支援 + +### 🐛 修復 + +- 提高 AI Agent 預設工具呼叫輪次,減少複雜任務過早停止的情況 +- 修正預設問題標籤與側邊欄元素的層疊順序異常 +- 【CLI Web】修正開發後端生命週期清理不完整的問題 +- 【桌面版】修正自動技能未註冊圖表工具,導致圖表技能無法完整執行的問題 +- 【桌面版】修正 Windows 標題列覆蓋層在切換主題後快取未更新與顯示不順的問題 + +### ♻️ 重構 + +- 集中管理 AI 圖表執行策略,統一 CLI 與桌面版的圖表工具啟用規則 +- 精簡技能選單與 Skill Manager 的重複邏輯 + +### 📝 文件 + +- 更新 iMessage 聊天記錄匯出指南 + +### 🔧 雜項 + +- 移除公開儲存庫中的維護者專用技能目錄,改由私有維護上下文管理 + +## v0.24.1 (2026-06-04) + +> 新增應用內更新提醒與 AI 預處理預設規則,並修正更新標記與脫敏設定保存問題。 + +### ✨ 新功能 + +- 新增應用內更新提醒入口,側邊欄可顯示最新版本說明 +- 新增 AI 預處理預設設定,自動啟用資料清理、去噪與脫敏等基礎規則 +- 支援以群組管理 AI 脫敏規則,方便維護內建與自訂規則 + +### 🐛 修復 + +- 修正更新檢查失敗結果、舊版快取與 CLI Web 開發占位版本造成錯誤 New 標記的問題 +- 修正 AI 預處理執行前未正確套用內建脫敏規則的問題 +- 修正脫敏規則偏好以空覆蓋儲存時,無法清除舊內建規則開關的問題 +- 修正舊版內建脫敏規則遷移後覆蓋設定遺失的問題 + +### 📝 文件 + +- 新增公開開發指南,補充本機開發、目錄職責與協作規範 + +### 👷 CI + +- 發布流程新增 Markdown 版本更新記錄連結 + +## v0.24.0 (2026-06-03) + +> 新增 CLI Web 驗證與資料目錄遷移能力,統一多端 HTTP 路由,並修正資料遷移、AI 設定與匯入刷新問題。 + +### ✨ 新功能 + +- 新增多語版本更新記錄的 Markdown 產生能力 +- 支援在儲存空間管理中忽略舊資料遷移提示 +- 【CLI Web】新增登入頁、Token 驗證與記住登入狀態 +- 【CLI Web】支援資料目錄遷移流程,可在 Web 設定中切換並遷移資料目錄 +- 【CLI】新增 --require-auth 參數,用於保護 /\_web/\* API 存取 +- 【桌面端】新增內部 HTTP 服務,讓前端可透過統一服務轉接層使用共享介面 + +### 🐛 修復 + +- 修正資料目錄遷移與資料庫遷移流程中的多個邊界情境,避免舊資料庫欄位缺失或路徑切換後讀取失敗 +- 修正舊資料不存在或目錄變更後,資料遷移提示仍錯誤顯示的問題 +- 修正增量匯入後側邊欄訊息數量未更新的問題 +- 修正側邊欄收合狀態只存於 sessionStorage,導致重新整理後遺失的問題 +- 修正編輯 AI 設定且已儲存金鑰時,拉取模型與驗證按鈕未正確啟用的問題 +- 修正 AI 共享 SSE 串流回應的穩定性問題 +- 修正新增自訂資料來源時未強制填寫 Token 的問題 +- 【桌面端】將圖表外掛運算移至 Worker,避免阻塞主執行緒 + +### ♻️ 重構 + +- 抽出 @openchatlab/http-routes 共享套件,統一 CLI Web 與桌面端的 HTTP 路由實作 +- 將 AI 設定、助手、技能、對話、串流回應、快取與合併相關 API 遷移到共享 HTTP 路由 +- 精簡桌面端 IPC 橋接層,移除舊版 AI、對話索引、LLM、Assistant、Skill、NLP 等相容處理程式 +- 統一前端服務層,減少 Electron 與 Web 模式的分支實作 + +### ⚡ 效能 + +- 壓縮主程序建置產物,並延遲載入 tiktoken rank table,降低啟動與打包體積壓力 + +### 👷 CI + +- 修正 Windows 發布流程中的 zstd 快取問題,並補充 CLI 更新發布說明 + +### 🔧 雜項 + +- 調整 Node 型別檢查專案設定,涵蓋桌面端與共享 Node 程式碼 + +## v0.23.1 (2026-06-01) + +> 新增 clb 短別名與連接埠預檢提示,修正背景程式靜默退出及深色模式標題列等多處問題。 + +### ✨ 新功能 + +- 最佳化頁面頂部標題列與工具列排版 +- 【CLI】啟動前主動偵測連接埠佔用,被佔用時提示切換埠號或執行 lsof,避免延遲報錯 +- 【CLI】新增 clb 作為 chatlab 指令的短別名 + +### 🐛 修復 + +- 修正側邊欄 Tooltip 位置異常及 Nuxt UI v4 API 相容問題 +- 修正標題列在深色模式下出現紅色背景與層疊順序錯誤 +- 規範 AI 訊息角色參數型別,強化對話測試斷言 +- 【CLI】修正背景程式啟動入口錯誤導致服務啟動後靜默退出的問題 +- 【CLI】改善連接埠偵測的錯誤處理與提示訊息 +- 【CLI】修正 Web 模式下缺少 chatlab.fun 反向代理路由的問題 + +## v0.23.0 (2026-05-31) + +> 重構訊息編輯互動,新增推理等級按模型獨立設定,統一 CLI 啟動入口,修正多處推理識別與計算問題。 + +### ✨ 新功能 + +- 新增訊息分叉(Fork)功能,可從任意 AI 回覆處建立獨立分支對話 +- 狀態列新增推理強度選擇器,支援為每個推理模型個別記憶並切換思維等級 +- 推理等級控制新增 default/auto 選項,擴展對 Kimi、Doubao、Gemini 等更多模型系列的支援 +- 訊息分析檢視拆分為「類型分析」與「時間分析」兩個分頁,提供豐富統計洞察卡片 +- 批次重建對話索引前新增確認彈窗,防止誤操作清空現有摘要 +- 示範資料擴充為 4 個檔案,涵蓋群組及多個私訊場景 +- 【CLI】新增統一啟動指令 chatlab start,支援 --headless 和 --no-open 參數 +- 【CLI】新增背景服務模式,支援 chatlab start --daemon 安裝為系統服務,以及 stop/status 指令 + +### 🐛 修復 + +- 修正訊息編輯可能導致資料遺失與狀態錯誤的多個並發安全問題 +- 修正思維等級與上下文視窗計算未使用當前啟用模型 ID 的問題 +- 修正自訂模型僅含 chat 能力時推理識別失敗,補全啟發式回退邏輯 +- 修正 Kimi、Doubao 等模型在選擇 auto 思維等級時被靜默停用的問題 +- 【CLI】修正 start 指令未啟動 Web 開發後端的問題 +- 【CLI】修正 Linux 上服務路徑含空格時背景服務啟動失敗的問題 + +### ♻️ 重構 + +- 重構訊息分支系統為「編輯並重新產生」模型,支援僅更新當前輪或覆蓋後續訊息兩種模式 +- 移除模型設定中的「推理模型」與「停用思維模式」開關,改為依能力自動推斷 +- 簡化模型切換按鈕介面,優化對話索引載入效能 + +## v0.22.1 (2026-05-29) + +> 新增對話摘要詳細程度設定,修正 CLI Web 批次摘要凍結、AI 設定編輯誤判及多項憑證偵測問題。 + +### ✨ 新功能 + +- 新增對話摘要詳細程度設定,支援「簡潔」與「標準」兩種策略,可在 AI 設定中切換 + +### 🐛 修復 + +- 修正 OpenAI 相容模式下變更 Base URL 後未重新驗證憑證的問題 +- 修正 API 金鑰已設偵測邏輯錯誤,避免舊金鑰被誤用 +- 修正批次產生摘要時點擊停止需等待當前請求完成才回應的問題 +- 【CLI Web】修正批次產生摘要導致頁面凍結的問題 +- 【CLI Web】修正批次產生摘要時未遵循已選取工作階段範圍的問題 +- 【CLI Web】修正編輯 AI 設定時第三方服務被誤判為本機服務的問題 + +### ♻️ 重構 + +- 將工作階段索引的國際化 key 從 storage 命名空間遷移至 ai 命名空間 + +### 💄 樣式 + +- 最佳化聊天記錄清單密度與訊息氣泡樣式 + +### 📝 文件 + +- 重整文件站導覽結構,將快速開始移至使用指南目錄 + +## v0.22.0 (2026-05-26) + +> 本次優化軟體預設狀態的介面樣式,並新增 CLI Web 更新與儲存管理能力,同時改善首頁匯入、文件站與多端穩定性。 + +### ✨ 新功能 + +- 首頁匯入區改為分入口設計,新增 API 匯入與自動同步入口 +- 版本紀錄改為隨應用程式本地打包,減少執行時對遠端資源的依賴 +- 【CLI Web】新增儲存管理功能,可在 Web 設定中檢視並管理資料快取 +- 【CLI】新增更新檢查與自動更新流程,並接入 CLI Web 的更新檢查 +- 【文件站】新增獨立文件站 docs.chatlab.fun + +### 🐛 修復 + +- 修正首頁快捷開始按鈕缺失的問題 +- 修正國際化 key 路徑錯誤與 ECharts 廢棄 api.style() 用法 +- 強化自動更新與遷移重試流程中的安全保護 +- 【桌面端】修正統一遷移可能回退資料目錄改動的問題 +- 【CLI Web】修正發現新版本時顯示必定失敗的「立即更新」操作 +- 【CLI Web】停用不可用的 Web 自動更新執行流程 +- 【CLI Web】修正檔案管理操作依賴 shell 開啟造成的相容性與安全性問題 +- 【CLI Web】修正資料目錄提示後 Web 服務無法繼續啟動的問題 +- 【CLI Web】補齊合併相關 API 相容層 +- 【CLI】優化更新檢查的非同步快取、按鍵互動與開發模式略過邏輯 + +### ♻️ 重構 + +- 將文件連結遷移到 docs.chatlab.fun +- 【設定】將會話索引移入 AI 設定,並調整設定頁排序 + +### 💄 樣式 + +- 優化側邊欄密度,並提升深色模式下 Tab 選擇器的對比度 + +### 📝 文件 + +- 重整公開文件站結構與匯出說明 + +### 🔧 雜項 + +- 將工作區套件遷移為 ESM +- 隔離文件站工作區依賴 +- 將發布版本紀錄移出 docs 目錄 + +## v0.21.1 (2026-05-23) + +> 優化 Pull 同步可靠性與資料安全性,新增移除訂閱時清除已匯入聊天記錄的選項,並修正 UI 動畫與彈窗互動問題。 + +### ✨ 新功能 + +- Pull 同步完成後自動產生會話索引 +- 新增移除訂閱時清除已匯入聊天記錄的選項 +- 【CLI Web】版本紀錄彈窗支援依版本截圖,Markdown 列表修正改為可選 +- 【MCP】新增 ci 變更類型的圖示與多語系支援 + +### 🐛 修復 + +- 修正 Pull 同步在小頁面情境下可能遺失資料的問題,並驗證重試匯入結果 +- 修正拉取同步後會話索引未自動產生的問題 +- 修正 Pull 完成或資料刪除後側邊欄會話列表未重新整理的問題 +- 修正遠端服務不支援分頁時無法取得全部會話的問題 +- 優化拉取同步的重試機制與分頁策略,提升穩定性 +- 修正會話存在性檢查缺少 schema 驗證導致資料表不存在報錯的問題 +- 修正強制產生會話索引彈窗在失敗時意外關閉的問題 +- 修正空會話狀態下會話索引彈窗阻塞頁面的問題 +- 【桌面端】修正應用程式版本號顯示為 0.0.0 時自動回退讀取 package.json 版本 +- 【CLI Web】同步圖示改為原地旋轉動畫,不再出現獨立的載入指示器 + +### 📝 文件 + +- 更新 Pull 協議文件為 since+nextSince 分頁模式 + +## v0.21.0 (2026-05-22) + +> 本次更新新增 MCP 支援,統一多端匯入與服務層,並改善 Web、AI、同步與發布穩定性。 + +### ✨ 新功能 + +- Electron 與 Web 模式支援一致的資料夾匯入流程,可處理多檔聊天記錄格式 +- 【MCP】新增獨立命令入口,並在設定頁接入 MCP 設定 +- 【MCP】Server 擴充至 19 個工具,並支援精簡文字與 JSON 兩種輸出格式 + +### 🐛 修復 + +- 強化資料夾匯入路徑處理,降低匯入失敗風險 +- 修復新增訂閱時可能發生的同步競態問題 +- 修復 MiniMax 串流回應中的 內容未正確識別為思考事件的問題 +- 【CLI Web】修復增量匯入無法使用的問題 +- 【CLI Web】修復會話不存在與成員歷史等行為和桌面版不一致的問題 +- 【CLI Web】修復開發服務的 Node 執行環境設定問題 +- 【MCP】修復啟動時原生模組 ABI 綁定不一致的問題 + +### ♻️ 重構 + +- 統一 CLI Web 與 Electron 的共用服務層,減少路由與 IPC 中的重複業務邏輯 +- 【MCP】精簡對外暴露的工具註冊表,降低外部 AI Agent 的工具 schema 成本 +- 【MCP】將核心能力抽取為獨立共用套件,簡化 CLI 與桌面版整合 +- 【MCP】精簡設定頁整合方式,降低桌面端輔助程式碼複雜度 + +### 👷 CI + +- 【CLI】發布流程支援同步發布 npm 套件 + +### 📝 文件 + +- 補充 changelog 端特有變更的前綴與排序規則 + +### 🔧 雜項 + +- 【CLI】【MCP】補齊 npm 發布所需設定與發布說明 + +## v0.20.0 (2026-05-19) + +> 本次更新統一多端核心架構,並改善 AI、匯入、同步與桌面版建置穩定性。同時為下一版的獨立 Web、CLI 與 MCP 能力預做準備;使用 CLI 前請先升級到此版本,以完成資料預遷移。 + +### ✨ 新功能 + +- 新增獨立 CLI、HTTP API 服務與 MCP Server,為命令列、Web 與 AI Agent 整合提供基礎能力 +- 新增獨立 Web 建置與一鍵啟動流程,支援透過 CLI 啟動 Web UI 並自動開啟瀏覽器 +- Web 模式支援聊天記錄匯入、Demo 匯入、會話查詢、成員查詢、搜尋與分析等核心流程 +- Web 模式接入 AI 對話、模型設定、自訂 Provider/Model、上下文壓縮與串流事件顯示 +- 匯入能力升級為共用串流管線,支援多格式解析、增量匯入、匯入分析與自動產生會話索引 +- 新增合併流程、Markdown 匯出與會話快取相關的服務端能力 +- 新增同步共用套件與 CLI 自動化支援,作為後續多端同步能力的基礎 +- 新增後端偏好設定持久化,並支援 CLI 首次執行時自動偵測與遷移桌面版資料 + +### 🐛 修復 + +- 修復 Web 模式下會話索引、跨來源代理、Demo 保護與執行階段錯誤等問題 +- 修復 Web 模式下應用程式版本顯示為空的問題 +- 修復 AI Agent 證據檢索、Web 端事件串流與錯誤格式化等問題 +- 修復同步顯示與匯入邏輯不一致的問題 +- 修復 CLI 開發模式下的 ESM 模組解析問題 +- 修復 CLI 安裝後可能找不到既有桌面版資料的問題 +- 修復 Electron 套件拆分後資料遷移路徑不一致的問題 +- 修復打包後 Worker 執行緒間接依賴 electron 模組導致崩潰的問題 +- 修復 Electron 建置、依賴安裝與 better-sqlite3 原生模組重建相關問題 + +### ♻️ 重構 + +- 將專案調整為多端工作區結構,拆分 apps/desktop、apps/cli 與多個共用 packages +- 將解析器、設定、資料庫適配、查詢、遷移、NLP、會話快取、匯入、合併、匯出與同步邏輯抽取為共用模組 +- 將 AI Agent、工具系統、預處理、上下文壓縮、RAG、LLM 設定、助手與技能管理抽取為共用執行階段能力 +- 統一 Electron、CLI Web 與 MCP 的 AI 工具命名、工具註冊與資料存取方式 +- 統一資料目錄、訊息查詢、成員查詢、會話索引、SQL 執行與匯入去重等核心邏輯 +- 將 CLI 從 packages/server 遷移到 apps/cli,並補齊 npm 發布建置流程 +- 將純前端圖表模組遷移到 src/features,讓 packages 目錄只保留可重用的共用套件 +- 移除多處 Electron 側純轉發檔案與廢棄程式碼,減少重複實作 + +### build + +- 升級 Vite 等建置工具鏈 +- 新增 CLI tsup 建置、Web 資源打包與 npm 發布所需設定 +- 調整 Electron 桌面版建置設定,移除 Linux 桌面版建置目標 + +### 📝 文件 + +- 補充版本策略、提交 scope 約定與相關開發文件 + +## v0.19.0 (2026-05-06) + +> 本次更新支援 AI 上下文自動壓縮,新增 Demo 範例,並優化模型設定與除錯體驗。 + +### ✨ 新功能 + +- 支援模型上下文視窗預設與自訂設定 +- 支援 AI 對話上下文自動壓縮與相關設定 +- 優化上下文壓縮流程與狀態顯示 +- 除錯模式新增原始資料檢視,並支援記錄完整 LLM 上下文 +- 優化 AI 模型設定文案、表單與設定頁顯示 +- 移除向量模型設定,簡化相關設定項目 +- 新使用者可在空狀態直接查看 Demo 範例 +- 遷移至 pnpm workspace 專案結構 + +### 🐛 修復 + +- 修正快速模型跟隨預設助手時,部分情況可能使用錯誤模型的問題 +- 修正空資料狀態下不顯示 Demo 按鈕的問題 +- 修正主行程直接依賴未宣告 axios,可能造成啟動失敗的風險 + +## v0.18.4 (2026-04-29) + +> 本次更新優化了模型的穩定性,同時支援遠端取得模型清單,優化 AI 錯誤詳細資訊的顯示與部分介面樣式等。 + +### ✨ 新功能 + +- 支援遠端取得模型清單 +- OpenAI 相容 API 網址自動補齊 /v1 並支援即時預覽 +- 優化 AI 對話錯誤詳細資訊的顯示 +- 優化部分顯示樣式 + +### 🐛 修復 + +- 修復部分邏輯漏洞 + +### 🔧 雜項 + +- 優化同步日誌技能的邏輯 + +## v0.18.3 (2026-04-28) + +> 本次更新支援設定快捷工具入口位置,優化預設時間篩選,並修正彈窗層級與資料目錄安全提示。 + +### ✨ 新功能 + +- 支援設定快捷工具入口位置 +- 優化預設時間篩選體驗 +- 禁止將資料目錄設在應用程式安裝目錄內 + +### 🐛 修復 + +- 修正彈窗樣式被覆蓋的問題 +- 修正設定頁內彈窗被遮擋的層級問題 + +## v0.18.2 (2026-04-26) + +> 本次更新新增訂閱類型選擇、遠端會話分頁發現,以及每次拉取訊息數量的設定。 + +### ✨ 新功能 + +- 訂閱會話時支援依類型篩選與選取 +- 支援遠端會話分頁發現與按需載入更多 +- 支援為資料來源設定每次拉取的訊息數量 + +## v0.18.1 (2026-04-24) + +> 本次更新新增 DeepSeek V4 支援與開機自動啟動選項,優化設定與整體介面體驗,並修正 AI 對話中的連結開啟方式。
從這個版本開始,專案將正式遷移至 ChatLab 組織。 + +### ✨ 新功能 + +- 遷移至 ChatLab 組織 +- 優化全域樣式表現 +- 優化快速提問的顯示邏輯 +- 優化設定彈窗的開啟方式 +- 支援開機自動啟動 +- 支援 DeepSeek V4 模型 + +### 🐛 修復 + +- 修正 AI 對話中的連結未在瀏覽器中開啟的問題 + +## v0.18.0 (2026-04-23) + +> 本次更新優化 AI 對話互動、整合多個分析入口,並提升資料來源同步與 Windows 更新的穩定性。 + +### ✨ 新功能 + +- 優化 AI 助手互動流程 +- 調整新增資料來源表單的提示文案 +- 工具 Panel 新增 Mini 模式 +- 將聊天記錄檢視器移至工具 Panel +- 統一關係分析相關分頁 +- 將語錄模組整體移至洞察 +- 將關鍵字分析移至實驗室 + +### 🐛 修復 + +- 修正既有型別警告 +- Pull 增量同步加入 60 秒重疊視窗,避免漏掉訊息 +- Pull 拉取固定使用 `limit=1000`,避免遠端資料來源一次匯出過多資料造成卡頓 +- 修正 Windows 更新時 NSIS 彈窗中斷靜默安裝的問題 + +## v0.17.5 (2026-04-21) + +> 本次更新聚焦大量錯誤修復,整體穩定性與可用性皆有所提升。 + +### ✨ 新功能 + +- 優化關係卡片樣式。 +- 以原生機器識別邏輯取代 `node-machine-id` 依賴,提升 Linux 下 API 金鑰修改穩定性。 +- 合併聊天記錄時新增可選保留原始記錄。 +- 在預設服務與第三方服務的 API 位址旁新增驗證按鈕。 + +### 🐛 修復 + +- 優化 dataSource 遷移策略,提升遷移安全性。 +- 修正話題在訊息過少時空狀態顯示異常。 +- 修正本地模型驗證失效問題。 +- 修正切換對話後已選分頁被重置的問題。 +- 修正舊版 dataSources 升級後自動化頁面白屏問題。 + +## v0.17.4 (2026-04-19) + +> 完成 Import API v1 完整協定並新增分層資料來源管理,現已支援聊天記錄自動同步。 + +### ✨ 新功能 + +- 完成 Import API v1 完整協定與分層資料來源管理 + +## v0.17.3 (2026-04-17) + +> 本次更新為私聊新增語言偏好分頁,側邊欄對話清單支援排序與篩選,完善 AI 服務商與模型設定能力,並修正時間篩選重置問題。 + +### ✨ 新功能 + +- 對話清單新增排序與篩選功能 +- 新增語言偏好分頁,可檢視偏好內容 +- 優化介面樣式細節,提升視覺一致性 +- AI 服務商新增 Anthropic 支援 +- 模型第三方服務支援自行選擇介面類型 +- AI 模型設定支援自訂名稱 + +### 🐛 修復 + +- 修正從設定或 AI 對話頁返回後,時間篩選會被重置為「全部」的問題 + +### ♻️ 重構 + +- 將語言偏好抽離為共用型別,減少重複定義 + +## v0.17.2 (2026-04-15) + +> 本次更新新增跨平台資料合併與成員訊息合併,強化詞庫與更新安全檢查,優化深色模式體驗與日誌能力,並修復多項問題。 + +### ✨ 新功能 + +- 成員管理支援合併成員訊息 +- 支援跨平台聊天資料合併 +- 資料管理中部分表頭支援排序 +- 將話題分析入口移至洞察模組 +- 新增 AI 日誌檔案原始路徑紀錄 +- 優化深色模式配色,提升視覺體驗 + +### 🐛 修復 + +- 修正詞庫刷新與合併 ID 衝突問題 +- 為 OpenAI 相容請求補上執行期 User-Agent 標頭 +- 修正深色模式截圖匯出背景透明異常 +- 詞庫下載新增 SHA256 完整性檢查 +- 收斂遠端設定拉取策略並強化更新安裝確認 + +### 🔧 雜項 + +- 新增 ARM Linux 的 deb 安裝包建置支援 +- 優化版本日誌同步流程 + +### 📝 文件 + +- 新增繁體中文文件 + +## v0.17.1 (2026-04-13) + +> 重構話題模組並新增話題卡片展示,優化詞雲關鍵詞篩選與查詢快取邏輯,支援遠端下載分詞詞庫及繁體中文詞庫,並完善 WhatsApp 偵測邏輯。 + +### ✨ 新功能 + +- 重構話題模組,新增話題卡片展示 +- 詞雲支援關鍵詞篩選 +- 支援遠端下載分詞詞庫,新增繁體中文詞庫支援 +- 查詢快取邏輯優化 +- 部分讀取中介面互動統一 +- 完善 WhatsApp 偵測邏輯 + +### 👷 CI + +- 新增官網文件站,支援自動化同步與部署 + +## v0.17.0 (2026-04-12) + +> 本次更新強化 WhatsApp 匯入解析與指定格式匯入,並重整總覽卡片體系,補上分享、截圖與除錯工具能力。 + +### ✨ 新功能 + +- 新增 WhatsApp V2 時間戳彈性解析,可自動適配不同地區匯出格式 +- 完善 WhatsApp 聊天紀錄偵測機制 +- 新增指定格式匯入能力 +- 在訊息分頁新增分享卡片 +- DEBUG 模式新增快速除錯工具 +- 優化總覽身份卡並統一時間範圍查詢邏輯 +- 重構總覽模組卡片並抽離主題色卡片,預留配色模式 +- 統一卡片最大寬度,並將首頁工具升級為全域工具側邊欄 +- 主題卡片支援截圖,並預設關閉截圖的行動端適配 +- 移除診斷建議並新增提示 + +### 🐛 修復 + +- 修正 WhatsApp 時間解析正則與行匹配正則寬嚴不一致的問題 +- 修正 WhatsApp 12 小時制時間與 NNBSP 字元的解析相容性問題 + +### 🔧 雜項 + +- 快取 electron 與 electron-builder 二進位檔以加速 CI 打包 + +## v0.16.0 (2026-04-10) + +> 本次更新新增私聊主動性分析視圖,並修正模型編輯視窗中自訂模型遺失的問題。 + +### ✨ 新功能 + +- 私聊場景新增主動性分析視圖 +- 優化頁尾區域的呈現與互動 +- 優化語錄模組下半部邏輯 + +### 🐛 修復 + +- 修正第三方/本地服務編輯視窗會遺失多個自訂模型的問題 + +## v0.15.0 (2026-04-08) + +> 本次版本大幅優化搜尋與查詢效能,搜尋工具可自動帶入上下文訊息,並優化 AI 模型設定、新增部分服務商,同時支援 Linux 平台。 + +### ✨ 新功能 + +- 新增查詢快取以加快存取速度 +- 搜尋工具支援自動帶入上下文訊息 +- 重構模型設定邏輯 +- 新使用者首次啟動時優先顯示語言選擇視窗 +- 實驗室新增基礎除錯工具 +- 移除舊版提示詞 + +### 🐛 修復 + +- 修正 Windows 淺色模式下標題列按鈕區背景色與應用不一致的問題 +- 修正 CI 打包流程中 Node 24 與 pnpm 對齊相關問題 +- 補齊工具呼叫顯示名稱的 i18n 翻譯 + +### ♻️ 重構 + +- 優化 AI 設定彈窗的程式碼組織 + +### 🔧 雜項 + +- 升級至 Node 24 +- 支援 Linux 打包 + +### 📝 文件 + +- 更新文件 + +## v0.14.2 (2026-04-07) + +> 本次更新聚焦 AI 對話體驗升級,新增對話複製、優化介面,並支援 FTS5 全文搜尋工具,同步精簡部分搜尋參數並強化錯誤提示與測試能力。 + +### ✨ 新功能 + +- 新增可在 7 天內記住助手選擇的功能 +- AI 對話支援一鍵複製訊息內容 +- 優化 AI 對話樣式與整體互動體驗 +- 支援 FTS5 全文搜尋,並新增快速搜尋工具 +- 精簡部分工具的搜尋參數,降低 token 消耗 +- 新增 Electron 應用 E2E 測試框架,支援連接埠管理與實例隔離 + +### 🐛 修復 + +- 完善 AI 對話錯誤提示,提升問題定位效率 + +### ♻️ 重構 + +- 整理 AI 對話模組程式結構 +- 抽出會話分析頁共用邏輯並統一頁首文案 + +### test + +- 補充可重複使用的 E2E 啟動器冒煙測試能力 + +### 📝 文件 + +- 更新專案文件中的導覽圖片 + +### 💄 樣式 + +- 統一部分程式碼格式,提升可讀性 + +## v0.14.1 (2026-04-02) + +> 本次更新聚焦首頁資訊架構調整與介面優化,同步提升 SQL 對話體驗、統計讀取效能與 AI 工具品質。 + +### ✨ 新功能 + +- 優化總覽頁樣式 +- 優化 SQL 對話模組互動流程 +- 將成員管理移至首頁,並調整相關分頁配置 +- 新增部分 AI 工具,包含聊天概覽取得能力 +- 新增對話資料快取管理模組,提升統計讀取效能 +- 完善版本日誌彈窗的類型呈現 + +### 🐛 修復 + +- 修復 SQL Lab 與摘要產生流程中 AI 錯誤被靜默吞沒的問題 + +### ♻️ 重構 + +- 重構 AI 工具分類體系,提升工具組織的可維護性 + +### 🔧 雜項 + +- 淘汰部分低價值 AI 工具,精簡可用工具集 + +## v0.14.0 (2026-03-28) + +> 新增 API 匯入匯出與預設提問,優化總覽與設定體驗,並修復訊息去重、AI 對話流程與每日訊息趨勢顯示問題。 + +### ✨ 新功能 + +- 新增 API 匯入 +- 新增 API 匯出 +- 點選預設問題後可直接送出提問 +- 設定頁支援選擇會話預設進入的分頁 +- 優化總覽頁樣式 +- 優化整體介面樣式與 API 服務設定頁 +- 優化身份卡片與助手選擇互動 + +### 🐛 修復 + +- 修復訊息去重誤判,並統一空字串的去重行為 +- 修復 AI 對話流程與前端 type-check 錯誤 +- 補上預設 assistant 的兜底邏輯,修復例外情境 +- 修復每日訊息趨勢未顯示的問題 + +### ♻️ 重構 + +- 整理 parser、worker、RAG 與 merger 的歷史型別問題 + +### 🔧 雜項 + +- 新增 assistant 設定產生技能 + +## v0.13.0 (2026-03-16) + +> AI 對話支援助手模式,對話可使用技能,輸入框支援快捷選擇,完善對話與設定介面,支援繁體中文與日文,並調整部分 UI,同時修復多項穩定性問題。 + +### ✨ 新功能 + +- 完成助手模式初版並強化助手邏輯與分析工具能力 +- 上線助手市集與技能市集,聊天對話支援使用技能 +- 支援以 @ 選擇成員協作 +- 支援繁體中文與日文在地化 +- 設定頁面重構並優化部分 UI 細節 +- 優化總覽模組樣式與對話介面體驗 +- 調整匯出聊天記錄的入口位置 +- 移除舊版提示詞系統與自訂篩選 AI 功能 +- 切換頁面時模型呼叫不再中斷 + +### 🐛 修復 + +- 修復 Gemini API 設定問題 +- 修復 NLP 停用詞呼叫順序導致的錯誤 + +### ♻️ 重構 + +- 重構 AIChat 組織結構 +- 重構目錄位置與專案結構 + +### 📝 文件 + +- 更新使用者協議與專案文件 + +### 🔧 雜項 + +- 優化版本日誌建置流程 + +### 💄 樣式 + +- 統一程式碼格式與 lint 規則輸出 + +## v0.12.1 (2026-02-27) + +> 新增聊天紀錄前處理與除錯能力,重構 Agent/LLM 架構,並修復國際化與 Windows 主題顯示問題。 + +### ✨ 新功能 + +- 新增聊天紀錄前處理流程 +- 新增前處理設定介面與設定管理能力 +- Agent 支援依會話顯示上下文時間軸與執行狀態 +- 新增 AI 除錯模式並提升日誌可觀測性 + +### 🐛 修復 + +- 修復英文設定下部分介面未完成國際化的問題 +- 修復 Windows 動態更新 overlay 色彩時主題不一致的問題 + +### ♻️ 重構 + +- 將 Agent 單體實作拆分為模組化架構 +- 將工具系統重構為 AgentTool + TypeBox 結構並補齊 i18n +- 統一 LLM 存取層,收斂為 pi-ai 方案 +- 重構資料流方向與 IPC 協議,並完成前端適配 +- 引入共享型別並優化 ChatStatusBar 國際化 +- 將部分圖表重構為外掛化架構 + +### 🔧 雜項 + +- 移除過度設計的 sessionLog 模組 +- 移除 @ai-sdk 相關依賴與舊版 LLM 服務實作 +- 暫時隱藏向量模型設定入口 +- 更新專案描述文案 + +### 💄 樣式 + +- 執行 ESLint 自動修正,統一程式碼風格 + +## v0.11.2 (2026-02-15) + +> 優化聊天紀錄匯入機制與管理頁體驗,並提升多平台聊天紀錄的相容性。 + +### ✨ 新功能 + +- 強化 LINE 與 WhatsApp 解析器的格式相容性 +- 優化聊天紀錄嗅探層,支援輪詢偵測與回退機制 +- 管理頁支援 Shift 多選 +- 管理頁新增顯示聊天摘要數量與 AI 對話數量 +- 優化首頁版面,提升可用空間 +- 優化 Windows 右上角控制列樣式 + +### 📝 文件 + +- 更新專案文件 + +## v0.11.0 (2026-02-13) + +> 支援 Telegram 匯入,優化增量匯入體驗,補齊國際化設定,並修復索引失效與頁面閃爍等問題。 + +### ✨ 新功能 + +- 補齊 AI 呼叫、日誌與主進程設定的國際化支援 +- 支援 Telegram 聊天紀錄匯入 +- 優化增量匯入互動與相關文案 +- 優化開啟協議時的互動體驗 + +### 🐛 修復 + +- 修復增量匯入後索引失效的問題(resolve #81) +- 修復 WhatsApp 使用 iPhone 匯出後無法辨識的問題(resolve #82) +- 修復切換對話頁面時出現雙重閃爍的問題 + +### 🔧 雜項 + +- 優化 TypeScript 設定 +- 調整 i18n 建置設定 +- 優化技能相關工程設定 + +## v0.10.0 (2026-02-11) + +> 新增互動頻率分析能力,優化會話查詢流程,並修復增量索引與資料庫掃描相關問題。 + +### ✨ 新功能 + +- 新增互動頻率分析視圖,更直觀地觀察成員互動趨勢 +- 優化會話查詢相關邏輯與處理流程 + +### 🐛 修復 + +- 修復增量更新後會話索引產生範圍不準確的問題(fix #79) +- 修復遷移與會話掃描時誤處理非聊天 SQLite 檔案的問題 + +### ♻️ 重構 + +- 重構會話查詢模組,提升查詢結構可維護性 + +### 🔧 雜項 + +- 移除 transformers 相關依賴並更新工程設定 + +## v0.9.4 (2026-02-08) + +> 優化時間篩選與 AI 設定體驗,新增 API Key 本機加密,並修復 LINE 聊天紀錄解析問題。 + +### ✨ 新功能 + +- 時間篩選支援更多彈性選項 +- API Key 支援本機加密儲存 +- 新用戶首次進入時不再顯示版本日誌 +- 優化 AI 對話底部的設定狀態顯示 +- 資料目錄遷移後支援立即重新啟動軟體 + +### 🐛 修復 + +- 修復 LINE 聊天紀錄解析問題 + +### 📝 文件 + +- 更新專案文件 + +## v0.9.3 (2026-02-03) + +> 支援自訂資料目錄,並修復多項已知問題。 + +### ✨ 新功能 + +- 設定中新增資料目錄位置選項 +- 優化資料儲存目錄遷移邏輯 +- 切換目錄時新增確認彈窗 +- 優化解析邏輯(WeFlow / Echotrace) + +### 🐛 修復 + +- 修復 Windows 自訂篩選時訊息量過大導致崩潰的問題 +- 修復第三方中轉 API 呼叫 tool_call 導致對話異常結束的問題 +- 修復部分 WhatsApp 聊天紀錄無法正確辨識的問題 +- 修復管理頁表頭層級顯示問題 + +### ♻️ 重構 + +- 重構 session 查詢模組 +- 補強遷移日誌輸出 + +## v0.9.2 (2026-02-02) + +> 排行榜改為圖表呈現,優化詞雲與本機 AI 推理模型,改進聊天紀錄篩選與日期選擇器,並在啟動後預先載入關鍵路由。 + +### ✨ 新功能 + +- 排行榜改為圖表展示 +- 優化詞雲效果 +- 優化推理模型 +- 優化訊息會話搜尋與篩選的聯動體驗 +- 優化日期選擇器互動 +- 啟動後預先載入關鍵路由 + +### 🔧 雜項 + +- 將 preload 模組化拆分 +- 優化 analytics 邏輯 +- 升級 ESLint 並整理程式碼格式 + +## v0.9.1 (2026-01-30) + +> 支援 LINE 聊天紀錄匯入,新增批次管理與聊天搜尋,並修復一些已知問題。 + +### ✨ 新功能 + +- 新增批次管理,支援批次刪除與合併 +- 支援聊天搜尋 +- 支援 LINE 聊天紀錄匯入 +- 相容 WeFlow 匯出的 JSON 格式 +- 成員列表改為後端分頁載入 +- 優化部分文案 + +### 🐛 修復 + +- 修復 Windows 更新時因 Worker 占用導致軟體無法關閉的問題 + +## v0.9.0 (2026-01-28) + +> 支援 NLP 分詞能力,語錄頁新增詞雲;新增視圖功能可展示更多圖表;支援系統代理跟隨,並優化部分頁面與樣式。 + +### ✨ 新功能 + +- 優化使用者選擇器效能,支援虛擬載入 +- 將排行榜移至視圖分頁 +- 引入分詞能力,並新增詞雲子分頁 +- 優化群聊頁分頁文案 +- 網路代理支援跟隨系統代理 +- 優化版本日誌顯示判斷邏輯 + +### 💄 樣式 + +- 優化 Markdown 渲染樣式 + +## v0.8.0 (2026-01-26) + +> 新增會話摘要與向量檢索能力,版本更新後會顯示更新內容,優化部分介面互動,並修復一些已知問題。 + +### ✨ 新功能 + +- 聊天會話支援摘要功能 +- 新增批次產生會話摘要邏輯 +- 支援向量模型設定與相關檢索 +- 匯入聊天紀錄報錯時記錄更詳細的日誌 +- 每次更新到新版本後自動開啟版本日誌 +- 首頁新增 Footer,顯示常用連結 +- 側邊欄移除幫助與回饋 + +### 🐛 修復 + +- 修復 shuakami-jsonl 解析錯誤(fix #50) + +## v0.7.0 (2026-01-23) + +> 優化 AI 對話體驗,改進更新流程,並以 ECharts 取代 chart.js。 + +### ✨ 新功能 + +- 優化更新流程 +- 補強 AI 對話錯誤日誌 +- 聊天底部支援快速切換對話模型 +- 優化預設提示詞,增加一點幽默感 +- 以 ECharts 取代 chart.js +- 移除註冊協議邏輯 + +## v0.6.0 (2026-01-21) + +> 接入 AI SDK 提升 AI 對話穩定性,新增思考內容區塊,並優化部分樣式。 + +### ✨ 新功能 + +- 新增定位日誌功能 +- 接入 AI SDK +- 新增思考內容區塊 +- 解決全域彈窗被首頁上方拖曳區域遮擋的問題 +- 優化 Windows 右上角關閉按鈕樣式 + +## v0.5.2 (2026-01-20) + +> 支援合併匯入,並修復一些問題。 + +### ✨ 新功能 + +- 支援合併匯入 +- 主面板顯示聊天紀錄起訖時間 +- 優化拖曳區域 + +### 🐛 修復 + +- 優化建置設定以修復 macOS x64 編譯問題 +- 修復訊息紀錄檢視器在 Windows 下的關閉按鈕樣式問題 +- macOS 打包時需在對應架構上編譯(fixes #36) + +## v0.5.1 (2026-01-16) + +> 修復一些問題。 + +### ✨ 新功能 + +- 優化文案 + +### 🐛 修復 + +- 修復 Windows 下關閉軟體後程序未退出的問題(#33) +- 修復數字輸入框 BUG(resolve #34) + +## v0.5.0 (2026-01-14) + +> 支援 Instagram 聊天紀錄匯入;首頁支援批次匯入;聊天頁支援增量匯入。 + +### ✨ 新功能 + +- 支援 Instagram 聊天紀錄匯入 +- 優化整體邏輯 +- 優化系統提示詞預設功能 +- 支援增量匯入 +- 支援批次匯入 +- 優化樣式 +- Windows 端支援原生視窗控制並實作主題同步(#31) + +### 🔧 雜項 + +- 移除 componenst.d.ts + +## v0.4.1 (2026-01-13) + +> 優化部分樣式與互動體驗。 + +### ✨ 新功能 + +- 提示詞支援預覽 +- 優化 AI 對話狀態列 +- 優化遷移表邏輯 +- 側邊欄支援顯示頭像 +- 優化樣式 +- 替換原生視窗控制列 +- 優化全域背景色 +- 關閉軟體時清理 Worker + +### 🐛 修復 + +- 修復主題模式設定跟隨系統不生效的問題 +- 修復更新彈窗提示內容排版問題 + +## v0.4.0 (2026-01-12) + +> 匯入支援 shuakami-jsonl 格式;AI 對話更省 Token;匯入聊天紀錄時可建立會話索引,查看器也支援依索引快速跳轉;軟體更新支援加速鏡像。 + +### ✨ 新功能 + +- 相容 shuakami-jsonl +- 優化 Loading 體驗 +- 新增自訂篩選 +- 重構預設詞系統,支援通用預設詞 +- 精簡系統提示詞以節省 Token +- 新增會話相關 function calling 呼叫 +- 處理訊息跳轉到上下文的邏輯 +- 聊天紀錄檢視器支援查看會話索引與快速跳轉 +- 重構設定彈窗,新增會話索引設定 +- 匯入聊天紀錄時支援產生會話索引 +- 重構設定彈窗 +- 優化基礎元件互動樣式 +- 優化首頁樣式 +- 優化更新加速邏輯 +- 新增加速鏡像 + +## v0.3.1 (2026-01-09) + +> 已適配 Discord 匯入;各解析器支援回覆類型匯入;軟體儲存目錄遷移至更標準的位置;匯入支援角色;匯入報錯提供更詳細的診斷與提示,並帶來一些細節優化。 + +### ✨ 新功能 + +- 資料表升級改由主進程執行 +- 自動檢查更新時忽略 beta 版本 +- 將資料儲存目錄遷移到 userData 下 +- 各解析器重新支援回覆訊息匯入 +- 支援平台訊息 ID 與回覆 ID,並同步進行資料表遷移 +- 支援 Tyrrrz/DiscordChatExporter 訊息格式匯入 +- member 表支援角色 +- 強化 ChatLab 格式偵測行為 +- 確保點擊匯入與拖曳匯入的邏輯一致 +- 支援更詳細的格式診斷 + +### 🐛 修復 + +- 修復部分使用者 platformId 為空的情況 + +## v0.3.0 (2026-01-08) + +> 完成完整國際化支援,支援中英文切換,並進一步優化部分功能。 + +### ✨ 新功能 + +- SQL 實驗室支援匯出 +- AI 對話支援匯出 +- 完成最終國際化 +- AI 模型出錯時顯示明確錯誤 +- SQL 結果支援跳轉到訊息檢視器 +- 優化系統 prompt,支援 prompt 市集 + +## v0.2.0 (2025-12-29) + +> 支援代理設定;匯入時支援顯示錯誤日誌;優化部分介面互動,並帶來一些功能更新。 + +### ✨ 新功能 + +- 訊息管理器支援顯示系統訊息 +- 優化匯入流程,錯誤時會顯示匯入日誌 +- WhatsApp 支援英文格式訊息匯入 +- 支援設定代理(resolve #7) +- 優化 AI 模型介面互動 +- 新增使用者設定 API 教學 +- 新增 2 個免費 GLM 模型,並加入豆包服務商與最新模型 +- AI 回覆不再輸出 think 內容 + +## v0.1.3 (2025-12-25) + +> 修復一些問題。 + +### 🐛 修復 + +- 修復 Echotrace 解析器錯誤 + +## v0.1.2 (2025-12-25) + +> 支援深色模式;AI 對話中的系統提示詞可帶入使用者身分。 + +### ✨ 新功能 + +- AI 對話中的系統提示詞可帶入使用者身分 +- 聊天紀錄檢視器中,Owner 顯示在右側 +- 支援資料庫升級 +- 成員分頁支援設定 Owner 視角 +- 支援深色模式 + +### 🐛 修復 + +- 修復將私聊誤判為群聊的問題 + +## v0.1.1 (2025-12-24) + +> 已適配 WhatsApp 聊天紀錄匯入;支援舊版 QQ 討論組格式分析。 + +### ✨ 新功能 + +- 聊天會話底部顯示 Token 消耗 +- 支援 WhatsApp 原生格式訊息 +- 支援舊版 QQ txt 討論組格式 + +### 🐛 修復 + +- 修復訊息管理器層級過低的問題 + +## v0.1.0 (2025-12-23) + +> 專案正式開源發布。 + +### ✨ 新功能 + +- init diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 000000000..87c2892c3 --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,244 @@ +import { defineConfig } from 'vitepress' +import type { DefaultTheme } from 'vitepress' + +const enSidebar: DefaultTheme.SidebarItem[] = [ + { text: 'What is ChatLab', link: '/intro' }, + { + text: 'Usage', + items: [ + { text: 'Quick Start', link: '/usage/quick-start' }, + { text: 'Export Chat Records', link: '/usage/how-to-export' }, + { text: 'Import Chat Records', link: '/usage/how-to-import' }, + { text: 'Configure AI', link: '/usage/how-to-config-ai' }, + { text: 'Troubleshooting', link: '/usage/troubleshooting' }, + ], + }, + { + text: 'Standards', + items: [ + { text: 'ChatLab Format', link: '/standard/chatlab-format' }, + { text: 'AI Conversion Guide', link: '/standard/ai-converter' }, + { text: 'ChatLab API', link: '/standard/chatlab-api' }, + ], + }, + { + text: 'Contributing', + items: [ + { text: 'Development Guide', link: '/contributing/development' }, + ], + }, + { text: 'Acknowledgments', link: '/contributing/acknowledgments' }, +] + +const cnSidebar: DefaultTheme.SidebarItem[] = [ + { text: 'ChatLab 介绍', link: '/cn/intro' }, + { + text: '使用指南', + items: [ + { text: '快速开始', link: '/cn/usage/quick-start' }, + { text: '导出聊天记录', link: '/cn/usage/how-to-export' }, + { text: '导入聊天记录', link: '/cn/usage/how-to-import' }, + { text: '配置 AI', link: '/cn/usage/how-to-config-ai' }, + { text: '故障排查', link: '/cn/usage/troubleshooting' }, + { text: '常见问题', link: '/cn/usage/faq' }, + ], + }, + { + text: '适配 ChatLab', + items: [ + { text: 'ChatLab Format', link: '/cn/standard/chatlab-format' }, + { text: 'AI 辅助转换', link: '/cn/standard/ai-converter' }, + { text: 'ChatLab API', link: '/cn/standard/chatlab-api' }, + { text: 'Push 导入协议', link: '/cn/standard/chatlab-import' }, + { text: 'Pull 远程数据源协议', link: '/cn/standard/chatlab-pull' }, + ], + }, + { + text: '贡献', + items: [ + { text: '开发指南', link: '/cn/contributing/development' }, + ], + }, + { text: '致谢', link: '/cn/contributing/acknowledgments' }, +] + +const twSidebar: DefaultTheme.SidebarItem[] = [ + { text: 'ChatLab 介紹', link: '/tw/intro' }, + { + text: '使用指南', + items: [ + { text: '快速開始', link: '/tw/usage/quick-start' }, + { text: '匯出聊天記錄', link: '/tw/usage/how-to-export' }, + { text: '匯入聊天記錄', link: '/tw/usage/how-to-import' }, + { text: '配置 AI', link: '/tw/usage/how-to-config-ai' }, + { text: '故障排除', link: '/tw/usage/troubleshooting' }, + { text: '常見問題', link: '/tw/usage/faq' }, + ], + }, + { + text: '適配 ChatLab', + items: [ + { text: 'ChatLab Format', link: '/tw/standard/chatlab-format' }, + { text: 'AI 輔助轉換', link: '/tw/standard/ai-converter' }, + { text: 'ChatLab API', link: '/tw/standard/chatlab-api' }, + ], + }, +] + +export default defineConfig({ + title: 'ChatLab', + description: 'A local-first chat analysis tool powered by SQL and AI Agents.', + cleanUrls: true, + appearance: true, + srcExclude: ['README.md', 'README.zh-CN.md'], + sitemap: { + hostname: 'https://docs.chatlab.fun', + }, + head: [ + [ + 'script', + {}, + ` + var _hmt = _hmt || []; + (function() { + var hm = document.createElement("script"); + hm.src = "https://hm.baidu.com/hm.js?adea56ed261a02133c38250af3a6f7b6"; + var s = document.getElementsByTagName("script")[0]; + s.parentNode.insertBefore(hm, s); + })(); + `, + ], + ], + rewrites: { + 'en/:rest*': ':rest*', + }, + themeConfig: { + logo: '/assets/logo.svg', + logoLink: 'https://chatlab.fun', + socialLinks: [{ icon: 'github', link: 'https://github.com/ChatLab/ChatLab' }], + }, + locales: { + root: { + label: 'English', + lang: 'en', + description: 'A local-first chat analysis tool powered by SQL and AI Agents.', + themeConfig: { + nav: [ + { + text: 'Home', + link: 'https://chatlab.fun', + target: '_self', + noIcon: true, + }, + { text: 'Docs', link: '/', activeMatch: '^/$' }, + { + text: 'Roadmap', + link: 'https://chatlab.fun/roadmap/tasks', + target: '_self', + noIcon: true, + }, + { + text: 'Community', + link: 'https://chatlab.fun/other/community', + target: '_self', + noIcon: true, + }, + ], + sidebar: { + '/': enSidebar, + '/usage/': enSidebar, + '/standard/': enSidebar, + '/contributing/': enSidebar, + }, + editLink: { + pattern: 'https://github.com/ChatLab/ChatLab/edit/main/docs/:path', + text: 'Edit this page on GitHub', + }, + returnToTopLabel: 'Back to top', + }, + }, + cn: { + label: '简体中文', + lang: 'zh-CN', + description: '本地化的聊天记录分析工具,通过 SQL 和 AI Agent 回顾你的社交记忆。', + themeConfig: { + nav: [ + { + text: '主页', + link: 'https://chatlab.fun/cn/', + target: '_self', + noIcon: true, + }, + { text: '文档', link: '/cn/', activeMatch: '^/cn/$' }, + { + text: '路线图', + link: 'https://chatlab.fun/cn/roadmap/tasks', + target: '_self', + noIcon: true, + }, + { + text: '加入社群', + link: 'https://chatlab.fun/cn/other/community', + target: '_self', + noIcon: true, + }, + ], + sidebar: { + '/cn/': cnSidebar, + '/cn/usage/': cnSidebar, + '/cn/standard/': cnSidebar, + '/cn/contributing/': cnSidebar, + }, + outline: { + label: '目录', + }, + editLink: { + pattern: 'https://github.com/ChatLab/ChatLab/edit/main/docs/:path', + text: '在 GitHub 上编辑此页', + }, + returnToTopLabel: '返回顶部', + }, + }, + tw: { + label: '繁體中文', + lang: 'zh-TW', + description: '本地化的聊天記錄分析工具,透過 SQL 與 AI Agent 回顧你的社交記憶。', + themeConfig: { + nav: [ + { + text: '主頁', + link: 'https://chatlab.fun/tw/', + target: '_self', + noIcon: true, + }, + { text: '文件', link: '/tw/', activeMatch: '^/tw/$' }, + { + text: '路線圖', + link: 'https://chatlab.fun/tw/roadmap/tasks', + target: '_self', + noIcon: true, + }, + { + text: '加入社群', + link: 'https://chatlab.fun/tw/other/community', + target: '_self', + noIcon: true, + }, + ], + sidebar: { + '/tw/': twSidebar, + '/tw/usage/': twSidebar, + '/tw/standard/': twSidebar, + }, + outline: { + label: '目錄', + }, + editLink: { + pattern: 'https://github.com/ChatLab/ChatLab/edit/main/docs/:path', + text: '在 GitHub 上編輯此頁', + }, + returnToTopLabel: '返回頂部', + }, + }, + }, +}) diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 000000000..ea7a407d2 --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,4 @@ +import DefaultTheme from 'vitepress/theme' +import './style.css' + +export default DefaultTheme diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css new file mode 100644 index 000000000..18519eecc --- /dev/null +++ b/docs/.vitepress/theme/style.css @@ -0,0 +1,56 @@ +:root { + --vp-c-brand-1: #ee4567; + --vp-c-brand-2: #e03d5f; + --vp-c-brand-3: #d53758; + --vp-c-brand-soft: rgba(238, 69, 103, 0.14); + + --vp-c-tip-1: var(--vp-c-brand-1); + --vp-c-tip-2: var(--vp-c-brand-2); + --vp-c-tip-3: var(--vp-c-brand-3); + --vp-c-tip-soft: var(--vp-c-brand-soft); + + --vp-button-brand-border: transparent; + --vp-button-brand-text: var(--vp-c-white); + --vp-button-brand-bg: var(--vp-c-brand-3); + --vp-button-brand-hover-border: transparent; + --vp-button-brand-hover-text: var(--vp-c-white); + --vp-button-brand-hover-bg: var(--vp-c-brand-2); + --vp-button-brand-active-border: transparent; + --vp-button-brand-active-text: var(--vp-c-white); + --vp-button-brand-active-bg: var(--vp-c-brand-1); + + --vp-custom-block-tip-border: transparent; + --vp-custom-block-tip-text: var(--vp-c-text-1); + --vp-custom-block-tip-bg: var(--vp-c-brand-soft); + --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft); +} + +.dark { + --vp-c-bg: #101010; + --vp-c-bg-soft: #303030; +} + +.VPNavBarMenuLink.vp-external-link-icon::after, +.VPNavScreenMenuLink.vp-external-link-icon::after { + display: none; +} + +.VPDoc a { + color: var(--vp-c-brand-1); +} + +.VPDoc a:hover { + color: var(--vp-c-brand-2); +} + +.VPNav .divider { + display: none !important; +} + +.VPNavBarTitle .title { + border-bottom-color: transparent !important; + color: #ee4567 !important; + font-size: 20px !important; + font-weight: 900 !important; + letter-spacing: -0.025em !important; +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..a0719e8bc --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# ChatLab Public Docs + +Documentation site: **https://docs.chatlab.fun** (English) · **https://docs.chatlab.fun/cn/** (简体中文) + +This directory contains the source for the public documentation site. + +- Production site: `https://docs.chatlab.fun` +- English docs source: `docs/en/` +- Simplified Chinese docs source: `docs/cn/` +- Traditional Chinese docs source: `docs/tw/` +- VitePress config: `docs/.vitepress/` +- Static assets used by documentation pages: `docs/public/` +- Package manifest: `docs/package.json` + +## Content Boundaries + +- Keep product documentation, user guides, integration specs, and public product philosophy in this directory. +- Keep internal engineering notes in `.docs/`. +- Keep website-only content such as homepage, roadmap, download marketing content, and community landing pages in `chatlab.fun`. +- Keep release changelog JSON files in root `changelogs/`, not in `docs/public/`. + +## Maintenance Notes + +- The VitePress public entry pages are `en/index.md`, `cn/index.md`, and `tw/index.md`. +- Documentation site dependencies live in `docs/package.json`; root `pnpm docs:*` scripts delegate to this workspace package. +- `README.md` and `README.zh-CN.md` are source-maintenance entry points and are excluded from the VitePress build. +- When adding or moving public docs, update the sidebar in `docs/.vitepress/config.mts`. +- When adding images or attachments referenced by docs pages, place only the required assets under `docs/public/`. + diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md new file mode 100644 index 000000000..7bb155346 --- /dev/null +++ b/docs/README.zh-CN.md @@ -0,0 +1,28 @@ +# ChatLab 公开文档 + +此目录是公开文档站的源码。 + +- 生产站点:`https://docs.chatlab.fun` +- 英文文档源码:`docs/en/` +- 简体中文文档源码:`docs/cn/` +- 繁体中文文档源码:`docs/tw/` +- VitePress 配置:`docs/.vitepress/` +- 文档页面使用的静态资源:`docs/public/` +- 包配置:`docs/package.json` + +## 内容边界 + +- 产品文档、用户指南、集成规格、公开产品理念放在此目录。 +- 内部工程说明放在 `.docs/`。 +- 官网首页、路线图、下载营销内容、社群落地页等官网专属内容放在 `chatlab.fun`。 +- 发布用 changelog JSON 放在根目录 `changelogs/`,不放在 `docs/public/`。 + +## 维护说明 + +- VitePress 公开入口页是 `en/index.md`、`cn/index.md`、`tw/index.md`。 +- 文档站依赖维护在 `docs/package.json` 中;根目录 `pnpm docs:*` 脚本只转发到这个 workspace package。 +- `README.md` 和 `README.zh-CN.md` 是源码维护入口,已从 VitePress 构建中排除。 +- 新增或移动公开文档时,需要同步更新 `docs/.vitepress/config.mts` 里的侧边栏。 +- 新增图片或附件时,只把文档页面实际引用的资源放入 `docs/public/`。 + +内部构建与部署说明见 `.docs/features/public-docs.md`。 diff --git a/docs/cn/contributing/acknowledgments.md b/docs/cn/contributing/acknowledgments.md new file mode 100644 index 000000000..8f479ca53 --- /dev/null +++ b/docs/cn/contributing/acknowledgments.md @@ -0,0 +1,42 @@ +--- +outline: deep +--- + +# 致谢 + +ChatLab 的成长离不开每一位参与者的付出。 + +## 贡献者 + +感谢所有通过代码、文档、测试和反馈为项目做出贡献的朋友: + + + + + +## 感谢 + +在 ChatLab 发展的过程中,也少不了以下开发者的协助,非常感谢。 + +[@shuakami](https://github.com/shuakami) · [@ycccccccy](https://github.com/ycccccccy) + +## 特别感谢 + +### Leo Oliveira([@13dev](https://github.com/13dev)) + +2026 年初,ChatLab 决定将项目从个人账户迁移到 GitHub 组织,希望借助组织名义更好地服务社区。然而"ChatLab"这个组织名早已被一位葡萄牙开发者 Leo Oliveira 注册,他的项目已沉寂五年。 + +抱着试一试的心态,我在他的仓库下提了一个 [Issue](https://github.com/onecord-io/client/issues/3),说明了 ChatLab 的现状,请求他考虑将组织转让。 + +等了近一个月,几乎已经不抱希望。结果 Leo 突然回复,邀我加 Discord 细聊。聊了没几句,他就直接将组织改名,发来一句: + +> done + +没有任何条件,没有任何要求。他甚至主动说了两次:"如果你在后续开发中需要帮助,尽管开口。" + +Leo 并不了解 ChatLab,也没有任何获得回报的可能,他只是觉得这个组织名对一个活跃的开源项目更有意义。 + +这件事让我想起了为什么喜欢开源:在 2026 年,互联网精神依然广泛存在。 + +非常感谢 Leo 的慷慨与善意。🙏 + diff --git a/docs/cn/contributing/development.md b/docs/cn/contributing/development.md new file mode 100644 index 000000000..e209dd7bc --- /dev/null +++ b/docs/cn/contributing/development.md @@ -0,0 +1,161 @@ +--- +layout: doc +title: 开发指南 +--- + +# 开发指南 + +这份指南面向想参与 ChatLab 开发的协作者,覆盖本地运行、仓库结构、常见改动入口和提交规范。更细的产品使用说明见使用指南;内部任务、草稿和个人维护上下文不作为公开贡献的前置条件。 + +## 开发前先读 + +- 先读本页,了解公开的协作基线。 +- 使用 AI 协作时,让 AI 先读根目录的 `AGENTS.md` 和本页。 +- 如果你的工作区存在 `.docs/`,可以继续阅读 `.docs/README.md` 和相关文档。`.docs/` 是可选的个人或团队私有开发上下文,可用于沉淀任务、决策、AI 协作记忆和临时规划;公开文档和公开 PR 不应依赖 `.docs/` 才能理解。 + +## 环境要求 + +- Node.js `>=24 <25` +- pnpm `>=9 <10` + +安装依赖: + +```bash +pnpm install +``` + +## 本地运行 + +| 命令 | 用途 | +| --- | --- | +| `pnpm dev` | 交互式选择开发目标 | +| `pnpm dev:desktop` | 启动 Electron 桌面端开发模式 | +| `pnpm dev:web` | 启动 CLI Web UI 开发模式 | +| `pnpm docs:dev` | 启动公开文档站开发模式 | +| `pnpm build:desktop` | 构建桌面端 | +| `pnpm build:web` | 构建 Web UI | +| `pnpm docs:build` | 构建公开文档站 | +| `pnpm run type-check:all` | 运行前端和 Node 侧类型检查 | +| `pnpm lint` | 运行 ESLint 并自动修复 | +| `pnpm format` | 运行 Prettier 格式化 | + +小范围改动优先对修改文件或相关子项目做定向检查;跨模块、发布或架构类改动再跑全量检查。 + +## 目录职责 + +| 路径 | 职责 | +| --- | --- | +| `src/` | 共享前端应用代码,包含页面、组件、服务封装、状态和 i18n | +| `src/services/` | 前端访问 Electron、CLI Web API 和平台能力的服务层 | +| `apps/desktop/` | Electron 主进程、preload 和桌面端构建配置 | +| `apps/cli/` | CLI、HTTP API、CLI Web 运行时和导入命令 | +| `packages/core/` | 平台无关的核心数据模型、查询、导入和成员操作 | +| `packages/node-runtime/` | Node.js 运行时服务、数据库、AI、导出、缓存和迁移 | +| `packages/tools/` | 统一 AI 工具定义和数据访问适配 | +| `docs/` | 公开文档站源码 | +| `changelogs/` | 应用内和发布使用的多语言更新日志 | +| `.docs/` | 可选的个人或团队私有开发上下文,不是公开贡献的必需依赖 | + +## 架构边界 + +ChatLab 同时维护 Electron 桌面端和 CLI Web 端。涉及共享业务逻辑时,优先把逻辑放到 `packages/node-runtime/src/services/` 或 `packages/core/`,入口层只做薄适配。 + +- 不要在 Electron IPC handler 或 CLI HTTP route 中重复实现复杂业务流程。 +- 不要在入口层绕过 `packages/core/` 直接写成员合并、删除、别名更新等核心 SQL 写操作。 +- 平台差异通过 adapter 或 service 参数隔离,保持前端获得的数据结构一致。 +- 新增会话、成员、索引、摘要、导出、导入相关能力时,先确认是否能复用或扩展共享 service。 + +## 数据目录兼容门禁 + +Electron 桌面端、CLI Web 和 MCP 会共享同一个 `userDataDir`。如果某个新版 runtime 修改了数据库 schema、AI 数据、认证配置或数据目录布局,旧 runtime 继续读写同一目录可能会读错数据或破坏用户数据。因此,凡是会让旧版本无法安全访问同一数据目录的变更,都必须使用数据目录兼容门禁。 + +兼容标记文件位于: + +```text +/.chatlab-meta.json +``` + +现有 `/.chatlab` 仍只作为目录 marker,不承载 JSON 配置。 + +### 什么时候需要提升门禁 + +以下改动通常需要提升 `minRuntimeVersion`: + +- 数据库 schema 迁移会删除、重命名或改变旧版本会访问的表/字段语义 +- AI 对话、助手、技能、工具 allowlist、认证 profile、配置文件格式发生旧版本无法安全解析的变化 +- `userDataDir` 内目录布局变化会导致旧版本读写错误位置 +- 跨端共享数据的 canonical 命名或结构发生不向后兼容变化 + +如果只是新增旧版本会忽略的可选字段,或修复不影响旧版本读写的数据派生逻辑,通常不需要提升门禁。 + +### 实现要求 + +- 使用 `packages/node-runtime/src/data-dir-compat.ts` 里的统一 helper,不要在入口层手写 JSON 读写和版本比较。 +- CLI、MCP、Desktop 启动时必须调用兼容检查;`DatabaseManager` 打开数据库前也会检查,用于捕获长驻旧服务运行期间目录被其他新版 runtime 提升的情况。 +- 执行会提升门禁的迁移时,必须只在迁移实际成功后写入 `.chatlab-meta.json`;如果写入失败,启动或打开数据库必须中断,不能继续对外服务。 +- `minRuntimeVersion` 只使用稳定 semver,例如 `0.25.1`;预发布版本不作为正式兼容判断。 +- 提升只能升高,不能降低;已有更高 `minRuntimeVersion` 时必须保留。 +- `reasons` 要合并去重,便于排查是哪类迁移提升了门禁。 +- HTTP route 遇到数据目录兼容错误时应返回 `DATA_DIR_INCOMPATIBLE` 和 409,而不是普通 500。 + +隐藏救援开关: + +```bash +CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR=1 +``` + +这个开关只允许绕过“当前版本低于最低版本”的阻断,不能绕过损坏 JSON、非法字段或非法版本。使用时必须打印明确警告,并说明可能有数据损坏风险。 + +### 测试要求 + +修改会影响数据目录兼容性的代码时,至少覆盖: + +- 从上一个稳定版本和更早已发布版本升级时数据不丢失 +- `.chatlab-meta.json` 缺失时允许旧数据目录启动 +- 当前 runtime 低于 `minRuntimeVersion` 时 CLI/Desktop/MCP 或 `DatabaseManager` 会阻断 +- 迁移成功后会写入或合并 `minRuntimeVersion`、`dataCompatibilityVersion` 和 `reasons` +- 已有更高 `minRuntimeVersion` 不会被降低 +- HTTP route 将兼容错误映射为 `DATA_DIR_INCOMPATIBLE` + +## 常见改动入口 + +| 想改什么 | 先看哪里 | +| --- | --- | +| 前端页面和组件 | `src/pages/`、`src/components/` | +| 图表分析 | `src/components/analysis/`、`src/components/charts/` | +| 数据、消息、会话 API 调用 | `src/services/` | +| Electron 主进程 | `apps/desktop/main/`、`apps/desktop/preload/` | +| CLI 和 Web API | `apps/cli/` | +| 共享业务逻辑 | `packages/node-runtime/src/services/`、`packages/core/` | +| AI 工具和 Agent | `packages/tools/`、`packages/node-runtime/src/ai/`、`src/services/ai*` | +| 导入解析 | `packages/core/`、`apps/cli/src/import/`、`src/services/import/` | +| 文档站 | `docs/`、`docs/.vitepress/config.mts` | +| 更新日志 | `changelogs/` | + +## 测试与检查 + +- 修改 TypeScript 或 Vue 代码后,至少运行相关类型检查。 +- 修改公开文档后,运行 `pnpm docs:build` 或对修改的 Markdown/配置文件运行格式化检查。 +- 修改跨平台共享逻辑后,确认 Electron 和 CLI Web 两端入口没有产生行为分歧。 +- 修复会影响用户数据、业务逻辑、异步任务、缓存状态、跨端共享 service、公开 API 契约、导入解析、去重逻辑、权限认证、AI 工具 allowlist、配置/API key 迁移或数据库 schema/迁移的行为 bug 时,必须优先补能失败的回归测试。 +- 只改 UI 文案、i18n key/翻译、样式、类型声明、日志、注释、无行为变化的小重构,或修复低风险展示细节时,可以不新增测试,但仍需运行相关类型检查、lint 和 format;不要为了低价值页面文案或源码字符串扫描新增脆弱测试。 +- 日常默认运行 `pnpm test`;需要优先验证相关文件时运行 `pnpm test -- path/to/file.test.ts`。 +- `pnpm test` 默认只包含单元/集成测试,不应依赖真实 LLM、真实 Electron、真实浏览器、真实网络或长时间 E2E。 +- 与单个业务模块强相关的单元测试就近放在被测文件同目录,命名为 `*.test.ts` 或 `*.test.js`。 +- 跨模块、集成、E2E、测试工具或归属不明显的测试放在根目录 `tests/`。 +- SQL 行为、数据库迁移、Fastify route 和跨包 service 优先使用轻量内存 SQLite 或临时文件 fixture 验证真实行为;适配层测试只验证参数传递、权限过滤、错误映射和返回契约,避免重复下层算法矩阵。 + +## i18n 与文案 + +涉及 UI 文案时,同步维护简体中文、英文、日语和繁体中文翻译。代码中的日志、注释、AI 工具描述、错误消息等非 UI 文本默认使用英文;如果运行时 locale 可用,应支持中英双语返回。 + +## 使用 AI 协作 + +AI 可以帮助阅读代码、生成补丁和补测试,但公开 PR 必须让 reviewer 能在公开上下文中理解。建议让 AI 先读 `AGENTS.md` 和本页;如果你维护了自己的 `.docs/`,可以把它作为额外上下文,但不要让变更说明、测试理由或设计依据只存在于私有 `.docs/` 中。 + +## PR 与提交规范 + +- 明显的 Bug 修复可以直接提交 PR。 +- **新功能请先提交 Issue 讨论;未经讨论直接提交的新功能 PR 可能会被关闭。** +- 提交信息使用 Conventional Commits,例如 `fix(import): handle empty source` 或 `docs: add contributor guide`。 +- 仅当改动是平台特有时使用平台 scope,例如 `electron`、`cli`、`web`;通用改动使用模块名作为 scope。 diff --git a/docs/cn/index.md b/docs/cn/index.md new file mode 100644 index 000000000..a44905344 --- /dev/null +++ b/docs/cn/index.md @@ -0,0 +1,32 @@ +--- +layout: doc +title: ChatLab 文档 | 开源聊天记录分析工具 +--- + +# ChatLab + +ChatLab 是一款免费、开源、本地优先的聊天记录分析工具,支持从 WhatsApp、QQ、LINE、Discord、Instagram、Telegram、iMessage 导入聊天记录,提供可视化统计分析与 AI 智能对话功能,所有数据本地存储、不上传云端。 + +## 如何使用 ChatLab + +- [ChatLab 介绍](/cn/intro) — 了解 ChatLab 是什么,以及它的核心功能。 +- [快速开始](/cn/usage/quick-start) — 安装 ChatLab 并完成第一次聊天记录导入的步骤指南。 +- [导出与导入](/cn/usage/how-to-export) — 从 WhatsApp、QQ、LINE 等平台导出聊天记录并导入 ChatLab 分析。 +- [配置 AI](/cn/usage/how-to-config-ai) — 接入 OpenAI、Claude、DeepSeek 等 AI 模型,用自然语言分析聊天内容。 +- [故障排查](/cn/usage/troubleshooting) — 解决导入失败、格式不支持、AI 配置报错等常见问题。 + +## 开发者:对接 ChatLab + +- [标准与 API](/cn/standard/chatlab-api) — ChatLab 本地 REST API 文档,支持外部工具查询、导入和分析聊天数据。 +- [ChatLab Format](/cn/standard/chatlab-format) — 聊天数据交换格式规范,用于跨平台数据互通。 +- [Push 导入协议](/cn/standard/chatlab-import) — 通过 HTTP 接口将外部聊天数据推送导入 ChatLab。 +- [Pull 远程数据源协议](/cn/standard/chatlab-pull) — 暴露标准 HTTP 端点,让 ChatLab 主动拉取远程聊天数据。 +- [AI 辅助转换指南](/cn/standard/ai-converter) — 使用 AI 将不支持的聊天记录格式转换为 ChatLab 标准格式。 + +## 参与贡献 + +- [开发指南](/cn/contributing/development) — ChatLab 本地开发环境搭建、仓库结构、架构边界和 PR 规范。 + +## 更多 + +- [官网与路线图](https://chatlab.fun/cn/) — 前往官网下载 ChatLab 桌面端或 CLI,查看产品路线图和社区入口。 diff --git a/docs/cn/intro.md b/docs/cn/intro.md new file mode 100644 index 000000000..21000520a --- /dev/null +++ b/docs/cn/intro.md @@ -0,0 +1,51 @@ +--- +outline: deep +--- + +# ChatLab 介绍 + +ChatLab 是一个免费、开源、本地化的聊天记录分析软件。 + +在数字时代,聊天记录早已不再是简单的文本文件,它是长达十年的社交关系脉络,是亲人珍贵的语音片段,更是我们外挂在数字世界的情感大脑。 + +ChatLab 的诞生,就是为了**让每个用户都能安全地分析、回顾属于自己的社交记忆。** + +## 你可以用 ChatLab 做什么 + +- **可视化分析**:词频、词云、发言排行、互动热力图、活跃时段分布……用图表看懂你的聊天历史。 +- **AI 对话**:接入 AI 模型后,可以用自然语言提问,让 AI Agent 帮你搜索、摘要、分析聊天内容。 +- **隐私优先**:所有数据本地存储,不上传、不联网,完全由你掌控。 +- **未来**:ChatLab 的目标是成为社交记忆层的标准基础设施——沉淀每一段关系的记忆档案,成为 AI Agent 理解你的社交世界的本地接口。详见 [项目路线图](https://chatlab.fun/cn/roadmap/intro)。 + +## 支持的平台 + +| 平台 | 支持状态 | +|------|--------| +| QQ | ✅ 支持 | +| WhatsApp | ✅ 支持 | +| LINE | ✅ 支持 | +| Discord | ✅ 支持 | +| Instagram | ✅ 支持 | +| Telegram | ✅ 支持 | +| iMessage | ✅ 支持 | +| Messenger / KakaoTalk | 🔜 即将支持 | + +## 两种运行方式 + +**桌面端**:下载安装包,双击即用。适合个人用户日常分析聊天记录。 + +**CLI**:通过 npm 安装,在终端运行。适合服务端部署、自动化脚本,或与 AI Agent(如 Claude Desktop)搭配使用。 + +两种方式底层共用同一套数据引擎,数据可以互通。 + +## 下一步 + +想要安装并导入第一份聊天记录,看 [快速开始](./usage/quick-start.md)。 + +遇到了导入、AI 对话错误等问题,参考 [故障排查](./usage/troubleshooting.md)。 + +如果你导出的聊天记录格式不在当前支持范围,参考 [AI 辅助转换指南](./standard/ai-converter.md)。 + +如果你是开发者,想对接数据格式或本地 API,参考 [标准与 API](./standard/chatlab-api.md)。 + +有其他问题,欢迎加入社区:[加入社群](https://chatlab.fun/cn/other/community) diff --git a/docs/cn/standard/ai-converter.md b/docs/cn/standard/ai-converter.md new file mode 100644 index 000000000..dbfd71d39 --- /dev/null +++ b/docs/cn/standard/ai-converter.md @@ -0,0 +1,82 @@ +--- +outline: deep +--- + +# AI 辅助转换指南 + +如果你的聊天记录格式(如 CSV, HTML, TXT 或其他数据库导出)目前不被 ChatLab 直接支持,你可以利用 AI(如 ChatGPT, Claude, DeepSeek 等)快速编写一个转换脚本,将你的数据转换为 ChatLab 标准格式。 + +## 准备工作 + +1. **查看标准规范**:[ChatLab 标准格式规范 v0.0.2](./chatlab-format.md) +2. **准备数据**:准备好你导出的原始聊天记录文件(如果是在线服务,建议仅提供几百条脱敏后的样本即可)。 + +## 选择目标格式 + +请根据你的数据量大小,选择合适的提示词。 + +### 场景一:中小规模数据 (推荐) + +- **目标格式**:JSON (`.json`) +- **适用场景**:记录数 < 100 万条,文件体积 < 100MB。 +- **特点**:结构清晰,兼容性最好。 + +#### 复制 JSON 转换提示词 + +```markdown +**角色设定**:你是一个精通数据处理和脚本编写的专家。 + +**任务目标**:请根据我提供的【ChatLab 标准格式规范】(chatlab-format.md),编写一个脚本,将我上传的【原始聊天记录】转换为符合该规范的 **JSON 格式**。 + +**执行要求**: + +1. **分析结构**:分析原始聊天记录的文本规律或数据结构。 +2. **字段映射**: + - 将原始字段映射到 ChatLab 标准字段(`timestamp`, `sender`, `content`, `type` 等)。 + - 如果原始数据缺少 `sender` (用户 ID),请根据 `accountName` (用户名) 自动生成一个唯一的哈希值或虚拟 ID。 + - `type` 默认为 0 (文本)。如果能从内容中识别出图片、语音等类型,请尝试映射。 +3. **脚本生成**: + - 请编写一个**完整的、可执行的脚本**(推荐 Python 或 Node.js)。 + - **输出结构**:脚本应构建一个包含 `chatlab`, `meta`, `members`, `messages` 的完整 JSON 对象,并一次性写入文件。 + - 脚本需包含必要的错误处理,并打印进度。 +4. **结果验证**: + - 请确保生成的 JSON 结构严格符合 `chatlab-format.md` 中的定义。 + +**输出**:请直接提供代码,并简要说明如何运行该脚本。 +``` + +### 场景二:超大规模数据 + +- **目标格式**:JSONL (`.jsonl`) +- **适用场景**:记录数 > 100 万条,或文件体积巨大。 +- **特点**:流式读写,内存占用极低,不会因为数据量大而崩溃。 + +#### 复制 JSONL 转换提示词 + +```markdown +**角色设定**:你是一个精通大数据处理和流式计算的专家。 + +**任务目标**:请根据我提供的【ChatLab 标准格式规范】(chatlab-format.md),编写一个脚本,将我上传的【原始聊天记录】转换为符合该规范的 **JSONL (JSON Lines) 格式**。 + +**执行要求**: + +1. **分析结构**:分析原始聊天记录的文本规律。 +2. **流式处理**: + - **必须采用流式读写**(Line-by-Line)的方式,不要一次性将所有数据加载到内存中。 + - 逐行读取原始文件,逐行写入目标文件。 +3. **JSONL 结构要求**: + - **第一行**:必须写入 `_type: "header"` 行(包含 `chatlab` 和 `meta` 信息)。 + - **成员信息**:如果可能,先扫描一遍或在处理过程中收集成员信息,写入 `_type: "member"` 行。 + - **消息记录**:每一条聊天记录写入一行 `_type: "message"`。 +4. **脚本生成**: + - 请编写一个**高效的 Python 脚本**。 + - 确保处理过程内存占用恒定,适合处理 GB 级别的大文件。 + +**输出**:请直接提供代码,并简要说明如何运行该脚本。 +``` + +## 后续步骤 + +1. **运行脚本**:在本地环境中运行 AI 生成的脚本。 +2. **检查结果**:打开生成的文件,确认格式是否正确。 +3. **导入 ChatLab**:将生成的文件导入 ChatLab 进行分析。 diff --git a/docs/cn/standard/chatlab-api.md b/docs/cn/standard/chatlab-api.md new file mode 100644 index 000000000..6a12361a3 --- /dev/null +++ b/docs/cn/standard/chatlab-api.md @@ -0,0 +1,457 @@ +--- +outline: deep +--- + +# ChatLab API + +> v1 + +ChatLab 提供本地 RESTful API 服务,允许外部工具、脚本和 MCP 等通过 HTTP 接口查询聊天记录、执行 SQL 查询、导出聊天数据。 + +::: tip 数据导入 + +如需通过 API 推送或同步聊天数据,请参阅: + +- **[Push 导入协议](./chatlab-import.md)** — 外部系统主动将数据推送到 ChatLab +- **[Pull 远程数据源协议](./chatlab-pull.md)** — 第三方暴露标准端点,ChatLab 主动拉取数据 + +::: + +## 快速开始 + +### 1. 启用服务 + +打开 ChatLab → 设置 → ChatLab API → 开启服务。 + +启用后会自动生成 API Token,默认监听端口 `3110`。 + +### 2. 验证服务状态 + +```bash +curl http://127.0.0.1:3110/api/v1/status \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +响应示例: + +```json +{ + "success": true, + "data": { + "name": "ChatLab API", + "version": "1.0.0", + "uptime": 3600, + "sessionCount": 5 + }, + "meta": { + "timestamp": 1711468800, + "version": "0.0.2" + } +} +``` + +## 基本信息 + +| 项目 | 说明 | +| -------- | ------------------------- | +| 基础 URL | `http://127.0.0.1:3110` | +| API 前缀 | `/api/v1` | +| 认证方式 | Bearer Token | +| 数据格式 | JSON | +| 绑定地址 | `127.0.0.1`(仅本机访问) | + +### 认证 + +所有请求必须携带 `Authorization` 请求头: + +``` +Authorization: Bearer clb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +Token 可在 设置 → ChatLab API 页面查看和重新生成。 + +### 统一响应格式 + +**成功响应:** + +```json +{ + "success": true, + "data": { ... }, + "meta": { + "timestamp": 1711468800, + "version": "0.0.2" + } +} +``` + +**错误响应:** + +```json +{ + "success": false, + "error": { + "code": "SESSION_NOT_FOUND", + "message": "Session not found: abc123" + } +} +``` + +--- + +## 端点列表 + +### 系统 + +| 方法 | 路径 | 说明 | +| ---- | ---------------- | -------------------------- | +| GET | `/api/v1/status` | 服务状态 | +| GET | `/api/v1/schema` | ChatLab Format JSON Schema | + +### 数据查询与导出 + +| 方法 | 路径 | 说明 | +| ---- | ------------------------------------- | ------------------------ | +| GET | `/api/v1/sessions` | 获取所有会话列表 | +| GET | `/api/v1/sessions/:id` | 获取单个会话详情 | +| GET | `/api/v1/sessions/:id/messages` | 查询消息(分页) | +| GET | `/api/v1/sessions/:id/members` | 获取成员列表 | +| GET | `/api/v1/sessions/:id/stats/overview` | 获取概览统计 | +| POST | `/api/v1/sessions/:id/sql` | 执行自定义 SQL(只读) | +| GET | `/api/v1/sessions/:id/export` | 导出 ChatLab Format JSON | + +### 数据导入 + +| 方法 | 路径 | 说明 | 文档 | +| ---- | ------------------------------------ | -------------------------------------------------------- | -------------------------------- | +| POST | `/api/v1/imports/:sessionId` | 导入消息到指定会话(首次自动创建,后续追加) | [Push 导入协议](./chatlab-import.md) | + +--- + +## 端点详细说明 + +### GET /api/v1/status + +获取 API 服务的运行状态。 + +**响应:** + +| 字段 | 类型 | 说明 | +| -------------- | ------ | ------------------------- | +| `name` | string | 服务名称(`ChatLab API`) | +| `version` | string | ChatLab 应用版本 | +| `uptime` | number | 服务运行时间(秒) | +| `sessionCount` | number | 当前会话总数 | + +--- + +### GET /api/v1/schema + +获取 ChatLab Format 的 JSON Schema 定义,便于构建符合规范的导入数据。 + +--- + +### GET /api/v1/sessions + +获取所有已导入的会话列表。 + +**响应示例:** + +```json +{ + "success": true, + "data": [ + { + "id": "session_abc123", + "name": "技术交流群", + "platform": "qq", + "type": "group", + "messageCount": 58000, + "memberCount": 120, + "lastTimestamp": 1711468800 + } + ] +} +``` + +--- + +### GET /api/v1/sessions/:id + +获取单个会话的详细信息。 + +**路径参数:** + +| 参数 | 类型 | 说明 | +| ---- | ------ | ------- | +| `id` | string | 会话 ID | + +**响应示例:** + +```json +{ + "success": true, + "data": { + "id": "group_abc123", + "name": "产品讨论群", + "platform": "whatsapp", + "type": "group", + "messageCount": 58000, + "memberCount": 86, + "firstTimestamp": 1609459200, + "lastTimestamp": 1711468800, + "lastPlatformMessageId": "msg_900000", + "groupId": "112233445566", + "importedAt": 1711469900 + } +} +``` + +| 字段 | 类型 | 说明 | +| ----------------------- | ------------ | ------------------------------------------------------ | +| `messageCount` | number | 会话内消息总数 | +| `memberCount` | number | 成员总数 | +| `firstTimestamp` | number\|null | 最早消息时间戳(秒级 Unix) | +| `lastTimestamp` | number\|null | 最新消息时间戳(秒级 Unix) | +| `lastPlatformMessageId` | string\|null | 最新一条有 platformMessageId 的消息 ID(用于增量边界) | +| `importedAt` | number | 最后一次导入时间 | + +--- + +### GET /api/v1/sessions/:id/messages + +分页查询指定会话的消息列表,支持多种过滤条件。 + +**查询参数:** + +| 参数 | 类型 | 默认值 | 说明 | +| ----------- | ------ | ------ | ------------------------ | +| `page` | number | 1 | 页码 | +| `limit` | number | 100 | 每页条数(最大 1000) | +| `startTime` | number | - | 起始时间戳(秒级 Unix) | +| `endTime` | number | - | 结束时间戳(秒级 Unix) | +| `keyword` | string | - | 关键词搜索 | +| `senderId` | string | - | 按发送者 ID 筛选 | + +**请求示例:** + +```bash +curl "http://127.0.0.1:3110/api/v1/sessions/abc123/messages?page=1&limit=50&keyword=你好" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**响应:** + +```json +{ + "success": true, + "data": { + "messages": [ + { + "senderPlatformId": "123456", + "senderName": "张三", + "timestamp": 1703001600, + "type": 0, + "content": "你好!" + } + ], + "total": 1500, + "page": 1, + "limit": 50, + "totalPages": 30 + } +} +``` + +--- + +### GET /api/v1/sessions/:id/members + +获取指定会话的所有成员列表。 + +--- + +### GET /api/v1/sessions/:id/stats/overview + +获取指定会话的概览统计信息。 + +**响应:** + +```json +{ + "success": true, + "data": { + "messageCount": 58000, + "memberCount": 120, + "timeRange": { + "start": 1609459200, + "end": 1703001600 + }, + "messageTypeDistribution": { + "0": 45000, + "1": 8000, + "5": 3000, + "80": 2000 + }, + "topMembers": [ + { + "platformId": "123456", + "name": "张三", + "messageCount": 5800, + "percentage": 10.0 + } + ] + } +} +``` + +| 字段 | 说明 | +| ------------------------- | -------------------------------------------------------------------------------- | +| `messageCount` | 总消息数 | +| `memberCount` | 成员数 | +| `timeRange` | 最早/最新消息时间戳(秒级 Unix) | +| `messageTypeDistribution` | 各消息类型的数量(key 为 [消息类型](./chatlab-format.md#消息类型对照表) 枚举值) | +| `topMembers` | 前 10 活跃成员(按消息数降序) | + +--- + +### POST /api/v1/sessions/:id/sql + +对指定会话的数据库执行只读 SQL 查询。仅允许 `SELECT` 语句。 + +**请求体:** + +```json +{ + "sql": "SELECT sender_id, COUNT(*) as count FROM message GROUP BY sender_id ORDER BY count DESC LIMIT 10" +} +``` + +**响应:** + +```json +{ + "success": true, + "data": { + "columns": ["sender_id", "count"], + "rows": [ + [1, 5800], + [2, 3200] + ] + } +} +``` + +::: tip 提示 +使用 `SELECT * FROM sqlite_master WHERE type='table'` 查询可用的数据库表结构。 +::: + +--- + +### GET /api/v1/sessions/:id/export + +导出完整会话数据,格式为 [ChatLab Format](./chatlab-format.md) JSON。 + +**限制:** 最多导出 **10 万条** 消息。如果会话消息数超过此限制,返回 `400 EXPORT_TOO_LARGE` 错误。超大会话建议使用 `/messages` 分页 API 逐页获取。 + +**响应:** + +```json +{ + "success": true, + "data": { + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800, + "generator": "ChatLab API" + }, + "meta": { + "name": "技术交流群", + "platform": "qq", + "type": "group" + }, + "members": [...], + "messages": [...] + } +} +``` + +--- + +## 并发与限制 + +| 限制项 | 值 | 说明 | +| ---------------- | ------- | ------------------------------- | +| JSON 请求体大小 | 50MB | `application/json` 模式 | +| JSONL 请求体大小 | 无限制 | `application/x-ndjson` 流式模式 | +| 导出消息上限 | 10 万条 | `/export` 端点 | +| 分页最大每页 | 1000 条 | `/messages` 端点 | +| 导入并发 | 1 | 同一时刻仅允许一个导入操作 | + +--- + +## 错误码 + +| 错误码 | HTTP 状态码 | 说明 | +| ------------------------ | ----------- | ----------------------------------- | +| `UNAUTHORIZED` | 401 | Token 无效或缺失 | +| `SESSION_NOT_FOUND` | 404 | 会话不存在 | +| `INVALID_FORMAT` | 400 | Content-Type 不支持或请求体格式错误 | +| `INVALID_PAYLOAD` | 400 | 必填字段缺失、类型错误或校验失败 | +| `SQL_READONLY_VIOLATION` | 400 | SQL 不是 SELECT 语句 | +| `SQL_EXECUTION_ERROR` | 400 | SQL 执行出错 | +| `EXPORT_TOO_LARGE` | 400 | 消息数超过导出上限(10 万条) | +| `BODY_TOO_LARGE` | 413 | 请求体超过 50MB(仅 JSON 模式) | +| `IMPORT_IN_PROGRESS` | 409 | 有其他导入正在进行 | +| `IDEMPOTENCY_CONFLICT` | 409 | 相同幂等键但请求体不一致 | +| `IMPORT_FAILED` | 500 | 导入失败 | +| `SERVER_ERROR` | 500 | 服务内部错误 | + +--- + +## 安全说明 + +- **仅本机访问**:API 绑定 `127.0.0.1`,不对外暴露 +- **Token 认证**:所有端点需携带有效 Bearer Token +- **SQL 只读限制**:`/sql` 端点仅允许 `SELECT` 查询 +- **默认关闭**:API 服务需手动开启 + +--- + +## 使用场景 + +### 1. MCP 集成 + +将 ChatLab API 接入 ClaudeCode 等 AI 工具,实现 AI 对聊天记录的直接查询和分析。 + +### 2. 自动化导入 + +编写脚本定期从其他平台导出聊天记录,转换为 ChatLab Format 后通过 [Push 导入协议](./chatlab-import.md) 自动导入。 + +### 3. 数据分析 + +通过 SQL 端点执行自定义查询,配合 Python/R 等工具进行高级数据分析。 + +### 4. 数据备份 + +通过 `/export` 端点定期导出重要会话数据作为 JSON 备份。 + +### 5. 远程数据源 + +在设置页配置外部数据源 URL,ChatLab 按 [Pull 远程数据源协议](./chatlab-pull.md) 自动拉取并导入新数据。 + +--- + +## 版本信息 + +| 版本 | 说明 | +| ---- | ------------------------------------------------------------------------------ | +| v1 | 支持会话查询、消息搜索、SQL、导出、Push 导入(JSON + JSONL)、Pull 远程数据源 | + +--- + +## 相关文档 + +- [ChatLab 标准化格式规范](./chatlab-format.md) — 数据交换格式定义 +- [Push 导入协议](./chatlab-import.md) — 外部系统主动推送数据到 ChatLab +- [Pull 远程数据源协议](./chatlab-pull.md) — 第三方暴露标准端点,ChatLab 主动拉取 diff --git a/docs/cn/standard/chatlab-format.md b/docs/cn/standard/chatlab-format.md new file mode 100644 index 000000000..3d7ea367d --- /dev/null +++ b/docs/cn/standard/chatlab-format.md @@ -0,0 +1,389 @@ +--- +outline: deep +--- + +# 聊天数据交换标准化格式 + +> v0.0.2 + +ChatLab 定义了一套标准的聊天记录数据交换格式,用于支持多平台数据的统一导入和分析。 + +只要你将聊天记录转为该格式,那么就可以被 ChatLab 解析并使用其分析能力。 + +::: warning 注意 +该格式规范目前仍处于早期制定阶段,部分字段和结构可能会在后续版本中调整。 +::: + +## 概述 + +### 支持的文件格式 + +| 格式 | 扩展名 | 适用场景 | +| --------- | -------- | ------------------------------------------------- | +| **JSON** | `.json` | 中小型记录(<100 万条),结构清晰,易于阅读 | +| **JSONL** | `.jsonl` | 超大规模记录(>100 万条),流式处理,内存占用恒定 | + +### 格式对比 + +| 特性 | JSON | JSONL | +| ------------ | ---------------------- | ----------------------- | +| 内存占用 | 需加载完整结构 | 逐行处理,恒定 (~100MB) | +| 文件大小限制 | ~1GB(取决于内存) | 无实际限制 | +| 追加写入 | - 需重写整个文件 | ✅ 直接追加行 | +| 错误恢复 | 单处错误整文件失效 | 可跳过错误行继续 | +| 可读性 | ⭐⭐⭐ 易于阅读 | ⭐⭐ 每行一条记录 | +| 推荐场景 | 小中型记录 (<100 万条) | 大型记录 (>100 万条) | + +## 快速说明 + +以下是一个**最小化**的 ChatLab 格式示例,只包含必要字段: + +```json +{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1703001600 + }, + "meta": { + "name": "我的群聊", + "platform": "qq", + "type": "group" + }, + "members": [ + { + "platformId": "123456", + "accountName": "张三" + } + ], + "messages": [ + { + "sender": "123456", + "accountName": "张三", + "timestamp": 1703001600, + "type": 0, + "content": "大家好!" + } + ] +} +``` + +--- + +## JSON 格式详细说明 + +### 文件头 (chatlab) + +| 字段 | 类型 | 必填 | 说明 | +| ------------- | ------ | ---- | ---------------------------- | +| `version` | string | ✅ | 格式版本号,当前为 `"0.0.2"` | +| `exportedAt` | number | ✅ | 导出时间(秒级 Unix 时间戳) | +| `generator` | string | - | 生成工具名称 | +| `description` | string | - | 描述信息 | + +### 元信息 (meta) + +| 字段 | 类型 | 必填 | 说明 | +| ------------- | ------ | ---- | -------------------------------------------------------- | +| `name` | string | ✅ | 群名或对话名 | +| `platform` | string | ✅ | 平台标识,如 `qq` / `discord` / `whatsapp` / `slack` 等 | +| `type` | string | ✅ | 聊天类型:`group`(群聊)/ `private`(私聊) | +| `groupId` | string | - | 群 ID(仅群聊) | +| `groupAvatar` | string | - | 群头像(Data URL 格式) | +| `ownerId` | string | - | 所有者/导出者的 platformId | + +### 成员 (members) + +| 字段 | 类型 | 必填 | 说明 | +| --------------- | ------------ | ---- | ------------------------- | +| `platformId` | string | ✅ | 用户唯一标识 | +| `accountName` | string | ✅ | 账号名称 | +| `groupNickname` | string | - | 群昵称(仅群聊) | +| `aliases` | string[] | - | 用户自定义别名 | +| `avatar` | string | - | 用户头像(Data URL 格式) | +| `roles` | MemberRole[] | - | 成员角色(可多个) | + +#### 角色 (roles) + +成员可以拥有一个或多个角色,用于标识群主、管理员等身份: + +| 字段 | 类型 | 必填 | 说明 | +| ------ | ------ | ---- | --------------------------------------- | +| `id` | string | ✅ | 角色标识:`owner` / `admin` / 自定义 ID | +| `name` | string | - | 角色显示名称(自定义角色需要) | + +**标准角色 ID:** + +| ID | 说明 | +| ------- | ----------- | +| `owner` | 群主/创建者 | +| `admin` | 管理员 | + +**角色示例:** + +```json +// 群主 +"roles": [{ "id": "owner" }] + +// 管理员 +"roles": [{ "id": "admin" }] + +// 多角色 +"roles": [ + { "id": "owner" }, + { "id": "tech-team", "name": "技术组" }, + { "id": "vip", "name": "VIP会员" } +] +``` + +### 消息 (messages) + +| 字段 | 类型 | 必填 | 说明 | +| ------------------- | -------------- | ---- | --------------------------------- | +| `sender` | string | ✅ | 发送者的 `platformId` | +| `accountName` | string | ✅ | 发送时的账号名称 | +| `groupNickname` | string | - | 发送时的群昵称 | +| `timestamp` | number | ✅ | 秒级 Unix 时间戳 | +| `type` | number | ✅ | 消息类型(见下方对照表) | +| `content` | string \| null | ✅ | 消息内容(非文本消息可为 `null`) | +| `platformMessageId` | string | - | 消息的平台原始 ID | +| `replyToMessageId` | string | - | 回复的目标消息 ID | + +#### 消息 ID 与回复关系说明 + +**`platformMessageId`**(消息的平台原始 ID): + +- 存储消息在原始平台上的唯一标识(如 Discord 的 snowflake ID、QQ 的消息 ID) +- 用于在查询时关联 `replyToMessageId`,以显示被回复消息的内容 +- 如果平台不提供消息 ID,可省略此字段 + +**`replyToMessageId`**(回复的目标消息 ID): + +- 存储被回复消息的**平台原始 ID** +- 通过与其他消息的 `platformMessageId` 关联,可查询被回复消息的内容和发送者 +- 仅当消息是回复类型时才有意义 +- 如果平台不支持或数据不包含回复关系,可省略此字段 + +--- + +## 消息类型对照表 + +::: tip 提示 +若您的聊天记录中有其他特殊类型需要支持,请提交 issue 说明情况,我们会评估是否加入标准消息类型中。 +::: + +### 基础消息类型 (0-19) + +| 值 | 名称 | 说明 | +| --- | -------- | ----------- | +| 0 | TEXT | 文本消息 | +| 1 | IMAGE | 图片 | +| 2 | VOICE | 语音 | +| 3 | VIDEO | 视频 | +| 4 | FILE | 文件 | +| 5 | EMOJI | 表情包/贴纸 | +| 7 | LINK | 链接/卡片 | +| 8 | LOCATION | 位置 | + +### 交互消息类型 (20-39) + +| 值 | 名称 | 说明 | +| --- | ---------- | ---------------------- | +| 20 | RED_PACKET | 红包 | +| 21 | TRANSFER | 转账 | +| 22 | POKE | 拍一拍/戳一戳 | +| 23 | CALL | 语音/视频通话 | +| 24 | SHARE | 分享(音乐、小程序等) | +| 25 | REPLY | 引用回复 | +| 26 | FORWARD | 转发消息 | +| 27 | CONTACT | 名片消息 | + +### 系统消息类型 (80+) + +| 值 | 名称 | 说明 | +| --- | ------ | ------------------------------ | +| 80 | SYSTEM | 系统消息(入群/退群/群公告等) | +| 81 | RECALL | 撤回消息 | +| 99 | OTHER | 其他/未知 | + +## 头像格式说明 + +头像字段 `avatar` 和 `groupAvatar` 支持两种格式: + +### 1. Data URL + +嵌入式格式,图片数据直接编码在文件中,离线可用: + +``` +data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD... +``` + +支持的图片 MIME 类型: + +- `image/jpeg` - JPEG 格式(推荐,体积较小) +- `image/png` - PNG 格式 +- `image/gif` - GIF 格式 +- `image/webp` - WebP 格式 + +### 2. 网络 URL + +外链格式,图片存储在网络服务器,体积更小但需网络访问: + +``` +https://example.com/avatars/user123.jpg +``` + +::: tip 建议 + +- 如果需要离线使用或长期存档,推荐使用 Data URL 格式 +- 导出 Data URL 时建议将头像压缩为 100×100 像素以内,以减小文件体积 +- 如果头像来自可靠的长期有效的 CDN,可使用网络 URL 以减小文件体积 +::: + +## 完整示例 + +### 群聊示例(含可选字段) + +```json +{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1703001600, + "generator": "My Converter Tool", + "description": "2024年技术交流群聊天记录备份" + }, + "meta": { + "name": "技术交流群", + "platform": "whatsapp", + "type": "group", + "groupId": "38988428513", + "groupAvatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg...", + "ownerId": "abc123" + }, + "members": [ + { + "platformId": "abc123", + "accountName": "张三", + "groupNickname": "群主-张三", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg...", + "roles": [{ "id": "owner" }] + }, + { + "platformId": "def456", + "accountName": "李四", + "groupNickname": "管理员", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg...", + "roles": [{ "id": "admin" }] + } + ], + "messages": [ + { + "platformMessageId": "msg_001", + "sender": "abc123", + "accountName": "张三", + "groupNickname": "群主-张三", + "timestamp": 1703001600, + "type": 0, + "content": "大家好!欢迎加入技术交流群~" + }, + { + "platformMessageId": "msg_002", + "sender": "def456", + "accountName": "李四", + "groupNickname": "管理员", + "timestamp": 1703001610, + "type": 25, + "content": "收到!", + "replyToMessageId": "msg_001" + } + ] +} +``` + +### 私聊示例 + +```json +{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1703001600 + }, + "meta": { + "name": "与小明的对话", + "platform": "qq", + "type": "private" + }, + "members": [ + { + "platformId": "123456789", + "accountName": "我", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + }, + { + "platformId": "987654321", + "accountName": "小明", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + } + ], + "messages": [ + { + "sender": "123456789", + "accountName": "我", + "timestamp": 1703001600, + "type": 0, + "content": "在吗?" + } + ] +} +``` + +## JSONL 流式格式 + +JSONL(JSON Lines)格式适用于**超大规模聊天记录**(>100 万条),可避免内存溢出问题。 + +### 格式特点 + +- 每行一个 JSON 对象 +- 通过 `_type` 字段区分行类型:`header` / `member` / `message` +- 内存占用恒定(约 100MB),支持 GB 级文件 +- 支持流式写入,可边导出边追加 + +### 行类型说明 + +| `_type` | 说明 | 是否必需 | +| --------- | -------------------------------- | --------------- | +| `header` | 文件头,包含 `chatlab` 和 `meta` | ✅ 必须在第一行 | +| `member` | 成员信息 | - 可选 | +| `message` | 消息记录 | ✅ 至少一条 | + +### 完整示例 + +```jsonl +{"_type":"header","chatlab":{"version":"0.0.2","exportedAt":1703001600},"meta":{"name":"技术交流群","platform":"qq","type":"group"}} +{"_type":"member","platformId":"123456","accountName":"张三","groupNickname":"群主","roles":[{"id":"owner"}]} +{"_type":"member","platformId":"789012","accountName":"李四"} +{"_type":"message","platformMessageId":"msg_001","sender":"123456","accountName":"张三","groupNickname":"群主","timestamp":1703001600,"type":0,"content":"大家好!"} +{"_type":"message","sender":"789012","accountName":"李四","timestamp":1703001610,"type":0,"content":"你好!"} +{"_type":"message","sender":"123456","accountName":"张三","groupNickname":"群主","timestamp":1703001620,"type":1,"content":"[图片]"} +``` + +### 解析规则 + +1. **第一行必须是 header**:包含 `chatlab` 版本和 `meta` 元信息 +2. **成员行在消息之前**:可选,如果省略,成员信息会从消息中自动收集 +3. **消息按时间顺序排列**:建议按 `timestamp` 升序排列 +4. **每行独立完整**:单行解析错误可跳过继续处理 +5. **支持注释行**:以 `#` 开头的行会被跳过(可用于添加备注) + +::: warning 注意 + +- 每行必须是**有效的 JSON**(不能跨行) +- 行之间用换行符 `\n` 分隔 + +::: + +## 版本历史 + +| 版本 | 日期 | 变更 | +| ----- | ---------- | ------------------------------------------------------------------------------ | +| 0.0.1 | 2025-12-22 | 初始版本 | +| 0.0.2 | 2026-01-09 | 新增 roles、ownerId、platformMessageId、replyToMessageId 字段;新增 JSONL 格式 | diff --git a/docs/cn/standard/chatlab-import.md b/docs/cn/standard/chatlab-import.md new file mode 100644 index 000000000..a168435f2 --- /dev/null +++ b/docs/cn/standard/chatlab-import.md @@ -0,0 +1,475 @@ +--- +outline: deep +--- + +# Push 导入协议 + +> v1 + +本文档定义外部数据源向 ChatLab 推送聊天数据的标准导入协议。覆盖首次全量导入、历史回填、周期性增量同步三类场景。 + +::: tip 两种导入方式 + +- **Push 模式**(本文档):外部系统主动将数据推送到 ChatLab 的导入接口。适用于脚本集成、一次性文件导入等场景。 +- **[Pull 模式](./chatlab-pull.md)**:第三方暴露标准 HTTP 端点,ChatLab 主动拉取数据。**推荐的第三方集成方式。** + +两种模式底层共用同一套导入逻辑(去重、meta/members 更新、FTS 索引),数据格式统一为 [ChatLab Format](./chatlab-format.md)。 + +::: + +## 设计原则 + +1. **统一入口**:首次导入和增量导入使用同一个端点,调用方无需区分。 +2. **接口最小化**:1 个导入接口 + 2 个查询接口覆盖全部 Push 场景。 +3. **双层幂等**:请求级幂等(Idempotency-Key)+ 记录级去重(platformMessageId / 内容哈希),承诺 **at-least-once + deterministic dedupe**。 +4. **同步优先**:小批量导入同步返回 `200 OK` 和写入结果。 +5. **默认自动更新**:meta 和 members 默认随导入请求自动更新,可通过 `options` 控制。 + +--- + +## 基础约定 + +### 服务地址 + +``` +Base URL:http://: (桌面端默认 127.0.0.1:3110) +Prefix: /api/v1 +``` + +### 认证 + +所有请求必须携带 Bearer Token: + +``` +Authorization: Bearer +``` + +Token 在 ChatLab 设置页面生成,格式为 `clb_` + 64 字符 hex。 + +### Content-Type + +``` +application/json # 标准 JSON body(≤50MB) +``` + +--- + +## 接口总表 + +| 方法 | 路径 | 说明 | +| ------ | ---------------------------- | -------------------------------------------- | +| `POST` | `/api/v1/imports/:sessionId` | 导入消息到指定会话(首次自动创建,后续追加) | +| `GET` | `/api/v1/sessions/:id` | 查询会话状态(主要用于对账校验) | +| `GET` | `/api/v1/sessions` | 列出所有会话(发现目标 Session) | + +查询接口的详细说明请参阅 [ChatLab API 文档](./chatlab-api.md)。 + +--- + +## POST /api/v1/imports/:sessionId + +**唯一导入入口。** 会话不存在时自动创建,已存在时追加数据并自动更新 meta/members。 + +### Path 参数 + +| 参数 | 类型 | 说明 | +| ----------- | ------ | ------------------------------------------------------------------------------------ | +| `sessionId` | string | Session ID,由调用方生成并维护。同一聊天来源必须保持固定,跨批一致。生成策略见下方。 | + +**Session ID 生成策略:** + +| 优先级 | 场景 | 推荐格式 | 示例 | +| --- | --- | --- | --- | +| 1(首选) | 有平台原始 ID | `{platform}_{originalId}` | `whatsapp_112233445566`、`qq_123456789` | +| 2 | 文件导入(有结构化 ID) | `{platform}_{meta.groupId}` 或 `{platform}_{对方platformId}` | `whatsapp_112233445566` | +| 3 | 文件导入(无结构化标识) | `file_{SHA256(文件内容)[:16]}` | `file_a1b2c3d4e5f6g7h8` | +| 4(兜底) | 一次性导入 | `import_{UUID}` | `import_550e8400-e29b-41d4-a716-446655440000` | + +::: warning 注意 +- 不建议使用文件路径作为 sessionId 的输入——文件重命名会导致 sessionId 变化 +- 同一聊天来源的首次导入和增量导入**必须**使用相同的 sessionId +::: + +### 请求 Header + +| Header | 必填 | 说明 | +| --- | --- | --- | +| `Authorization` | 是 | `Bearer ` | +| `Content-Type` | 是 | `application/json` | +| `Idempotency-Key` | 建议 | 当前批次的唯一标识,用于重试安全。建议格式:`{sessionId}-{batchIndex}-{windowStart}` | + +### 快速测试 + +复制以下命令即可直接测试(将 `YOUR_TOKEN` 和端口替换为实际值): + +```bash +curl http://127.0.0.1:3110/api/v1/imports/group_abc123 \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "chatlab": { "version": "0.0.2", "exportedAt": 1711468800, "generator": "test" }, + "meta": { "name": "产品讨论群", "platform": "whatsapp", "type": "group", "groupId": "112233445566" }, + "members": [ + { "platformId": "user_a", "accountName": "张三", "roles": [{ "id": "owner" }] } + ], + "messages": [ + { "platformMessageId": "msg_1001", "sender": "user_a", "timestamp": 1711468800, "type": 0, "content": "Hello" } + ] +}' +``` + +成功时返回 `"success": true` 和写入统计;重复调用同一 `platformMessageId` 会被去重(`duplicateCount` 增加,`writtenCount` 不变)。 + +--- + +### 请求 Body(JSON 模式) + +```json +{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800, + "generator": "YourSystem/1.0" + }, + "meta": { + "name": "产品讨论群", + "platform": "whatsapp", + "type": "group", + "groupId": "112233445566", + "groupAvatar": "data:image/jpeg;base64,...", + "ownerId": "user_owner" + }, + "members": [ + { + "platformId": "user_a", + "accountName": "张三", + "groupNickname": "产品", + "avatar": "data:image/jpeg;base64,...", + "roles": [{ "id": "owner" }] + } + ], + "messages": [ + { + "platformMessageId": "msg_1001", + "sender": "user_a", + "accountName": "张三", + "groupNickname": "产品", + "timestamp": 1711468800, + "type": 0, + "content": "Hello", + "replyToMessageId": "msg_1000" + } + ], + "options": { + "metaUpdateMode": "patch", + "memberUpdateMode": "upsert" + } +} +``` + +### options 对象(可选) + +| 字段 | 类型 | 默认值 | 可选值 | 说明 | +| --- | --- | --- | --- | --- | +| `metaUpdateMode` | string | `patch` | `patch` / `none` | `patch`:非空字段覆盖更新;`none`:跳过更新 | +| `memberUpdateMode` | string | `upsert` | `upsert` / `none` | `upsert`:新增+更新;`none`:跳过更新 | + +::: tip 提示 +回填历史数据时建议传 `"metaUpdateMode": "none"` 防止旧群名覆盖当前值。 +::: + +### 各块携带规则 + +| 块 | 会话首次创建 | 后续增量导入 | 语义 | +| ---------- | ------------ | ------------ | -------------------------------------------------------------- | +| `chatlab` | 必填 | 可选 | 首次用于创建会话;后续用于版本兼容判断 | +| `meta` | 必填 | 可选 | 首次作为初始值;后续携带则非空字段自动覆盖更新 | +| `members` | 必填 | 可选 | 首次写入全量成员;后续按 `platformId` 做 upsert | +| `messages` | 必填 | 必填 | 每批必须包含至少一条消息 | + +**Meta 自动更新规则:** + +- 调用方传了且值不为空的字段 → 覆盖更新 +- 调用方未传或值为空的字段 → 保持原值不变 +- `platform` 和 `type` 不允许增量更新(会话创建后不变) + +**Members Upsert 规则:** + +- 按 `platformId` 匹配:新 `platformId` → 插入为新成员;已存在的 → 更新非空字段 +- 不携带 `members` 时:消息中出现的未知 `sender` 仍会被自动创建为成员(仅含最小信息) + +--- + +### 字段定义 + +#### chatlab 对象 + +| 字段 | 类型 | 必填 | 说明 | +| ------------ | ------ | ---- | --------------------------------- | +| `version` | string | 是 | 格式版本号,当前为 `"0.0.2"` | +| `exportedAt` | number | 是 | 导出/生成时间(秒级 Unix 时间戳) | +| `generator` | string | 否 | 生成工具/系统名称 | + +#### meta 对象 + +| 字段 | 类型 | 首次必填 | 说明 | +| ------------- | ------ | -------- | -------------------------------------------- | +| `name` | string | 是 | 群名/会话名 | +| `platform` | string | 是 | 平台标识(见下方枚举) | +| `type` | string | 是 | 会话类型:`group`(群聊)/ `private`(私聊) | +| `groupId` | string | 否 | 群的平台原始 ID | +| `groupAvatar` | string | 否 | 群头像,base64 Data URL 或网络 URL | +| `ownerId` | string | 否 | 导出者/所有者的 platformId | + +**平台标识枚举:** + +| 值 | 平台 | +| ---------- | --------- | +| `wechat` | 微信 | +| `qq` | QQ | +| `telegram` | Telegram | +| `discord` | Discord | +| `whatsapp` | WhatsApp | +| `line` | LINE | +| `slack` | Slack | +| `unknown` | 未知/其他 | + +::: tip 提示 +如果你的平台不在上述列表中,可使用小写英文标识(如 `signal`、`matrix`),ChatLab 会按 `unknown` 的分析策略处理。 +::: + +#### members 数组元素 + +| 字段 | 类型 | 必填 | 说明 | +| --------------- | ------ | ---- | -------------------------------------- | +| `platformId` | string | 是 | 成员在平台的唯一标识(QQ号、用户ID等) | +| `accountName` | string | 建议 | 账号名称(不随群变化的原始昵称) | +| `groupNickname` | string | 否 | 群内专属昵称 | +| `avatar` | string | 否 | 头像,base64 Data URL 或网络 URL | +| `roles` | array | 否 | 角色列表,见 [角色定义](./chatlab-format.md#角色-roles) | + +#### messages 数组元素 + +| 字段 | 类型 | 必填 | 说明 | +| ------------------- | ------------ | -------- | ------------------------------------------------------------------- | +| `sender` | string | 是 | 发送者的 `platformId` 或保留标识(如 `SYSTEM`) | +| `timestamp` | number | 是 | 消息时间戳,秒级 Unix 时间戳 | +| `type` | number | 是 | 消息类型枚举(见 [消息类型](./chatlab-format.md#消息类型对照表)) | +| `accountName` | string | 建议 | 发送时的账号名称 | +| `groupNickname` | string | 否 | 发送时的群昵称 | +| `content` | string\|null | 否 | 纯文本内容,非文本消息可为 null | +| `platformMessageId` | string | 强烈建议 | 消息的平台原始 ID,去重的首选依据 | +| `replyToMessageId` | string | 否 | 回复目标消息的 `platformMessageId` | + +**sender 字段规则:** + +1. 必须是稳定的 `platformId` 或保留标识 `SYSTEM` +2. 若 `sender` 对应的成员不在 `members` 中,ChatLab 自动补创建成员(仅含最小信息) +3. `SYSTEM` 用于系统消息(入群/退群/公告等),不计入成员统计 + +--- + +### 成功响应 + +```json +{ + "success": true, + "data": { + "sessionId": "group_abc123", + "created": false, + "batch": { + "receivedCount": 5000, + "writtenCount": 4986, + "duplicateCount": 14 + }, + "session": { + "totalCount": 128640, + "memberCount": 86, + "firstTimestamp": 1609459200, + "lastTimestamp": 1711468800 + }, + "updates": { + "metaUpdated": true, + "membersAdded": 3, + "membersUpdated": 5 + } + } +} +``` + +| 字段 | 说明 | +| ------------------------ | ----------------------------------------------------- | +| `created` | `true` 表示本次请求触发了会话创建(首次导入) | +| `batch.receivedCount` | 本批收到的消息条数 | +| `batch.writtenCount` | 实际写入的条数 | +| `batch.duplicateCount` | 因去重跳过的条数 | +| `session.totalCount` | 写入后会话的累计消息总数 | +| `session.memberCount` | 会话的成员总数 | +| `session.firstTimestamp` | 会话内最早消息时间戳 | +| `session.lastTimestamp` | 会话内最新消息时间戳 | +| `updates.metaUpdated` | 本次请求是否触发了 meta 更新 | +| `updates.membersAdded` | 本次新增的成员数 | +| `updates.membersUpdated` | 本次更新的成员数 | + +--- + +## 去重语义 + +去重在单个 Session 内生效,不跨会话。 + +**优先级:** + +1. 若消息提供了 `platformMessageId`,以此作为唯一键去重(高精度,推荐)。 +2. 若未提供 `platformMessageId`,退化为内容哈希去重:`sha256(timestamp + '\0' + sender + '\0' + contentTag + '\0' + normalizedContent)` + +| 层次 | 机制 | 适用范围 | 精度 | +| ------------------ | ----------------------------------------------- | ----------------------------- | -------- | +| 请求级幂等 | `Idempotency-Key` | 同一 HTTP 请求的重试 | 精确 | +| 消息级去重(主键) | `platformMessageId` | 跨批次、跨窗口的同一条消息 | 精确 | +| 消息级去重(降级) | 内容哈希 `sha256(timestamp + sender + content)` | 无 platformMessageId 时的兜底 | 最大努力 | + +::: warning 注意 +- 同一 `platformMessageId` 的消息不会被重复写入,即使 content 不同(以首次写入为准) +- 内容哈希去重在"同一人、同一秒、完全相同内容"时判定为重复,存在极小概率误判 +- **强烈建议**外部数据源提供 `platformMessageId`,这是最可靠的去重依据 +::: + +--- + +## 分批策略 + +### 默认批次大小 + +每批建议 **5000 条消息**。 + +| 约束 | 值 | 说明 | +| ------------------ | ------------ | ------------------------------- | +| JSON body 大小上限 | 50MB | 超过返回 `BODY_TOO_LARGE` (413) | +| 建议每批消息数 | 5000 条 | 兼顾性能和内存占用 | + +### 分批原则 + +- 按时间顺序分批,从旧到新推送 +- 批次间允许时间重叠(建议重叠 5~10 分钟),依靠去重吸收 +- 每批独立请求,任意一批失败不影响其他批次 +- 第一批必须携带 `chatlab` + `meta` + `members`(触发会话创建) +- 后续批次可仅携带 `messages` + +### 游标维护(由调用方负责) + +ChatLab 不为调用方维护游标。推荐结构: + +```json +{ + "sessionId": "group_abc123", + "lastSyncedTimestamp": 1711468800, + "lastSyncedMessageId": "msg_900000" +} +``` + +每批成功后,将响应中的 `session.lastTimestamp` 更新到游标。失败时保持游标不变,用相同的 `Idempotency-Key` 重试。 + +### 并发约束 + +当前版本:**同一 session 同一时刻仅允许一个导入任务**。对同一 sessionId 的并发请求会收到 `IMPORT_IN_PROGRESS` (409) 错误;不同 session 的并发导入互不影响。 + +--- + +## 标准调用流程 + +### 首次全量导入(Bootstrap) + +``` +1. 准备全量聊天数据,按时间顺序切分为 N 批(每批 ≤5000 条) + +2. 第一批请求: + POST /api/v1/imports/group_abc123 + Body: { chatlab, meta, members, messages } + → 响应 created: true,会话创建成功 + +3. 第 2~N 批请求: + POST /api/v1/imports/group_abc123 + Body: { messages } + Idempotency-Key: {sessionId}-{batchIndex}-{windowStart} + +4. 每批成功后记录游标;失败用相同 Idempotency-Key 重试 + +5. 全部批次完成后对账: + GET /api/v1/sessions/group_abc123 + → 校验 totalCount、firstTimestamp、lastTimestamp +``` + +### 历史回填(Backfill) + +``` +1. 确定需要补齐的时间区间 [start, end] +2. 按时间窗口拆分为多批 +3. 依次调用 POST /api/v1/imports/:sessionId + (建议设置 options.metaUpdateMode: "none" 防止旧群名覆盖) +4. 每批成功后更新本地游标 +5. 可选:GET /api/v1/sessions/:id 对账 +``` + +### 每日定时增量(Scheduled Sync) + +``` +1. 固定 Session ID + 定时器(如每小时/每天) +2. 从上次游标开始,向前重叠 5~10 分钟作为窗口起点 +3. 拉取该时间窗口内的新消息 +4. 如有新成员或群名变更,携带 meta/members +5. 调用 POST /api/v1/imports/:sessionId +6. 依靠去重吸收重叠数据 +7. 更新本地游标 +``` + +--- + +## 媒体附件(协议预留) + +当前版本重点保证文本消息的导入稳定性。`attachments` 作为协议预留字段:调用方可在消息中携带该字段,ChatLab 当前接收但不承诺完整落库与渲染。 + +预留字段结构见 [ChatLab 格式规范](./chatlab-format.md)。 + +--- + +## 错误码与重试策略 + +| 错误码 | HTTP 状态 | 说明 | 可重试 | +| --- | --- | --- | --- | +| `UNAUTHORIZED` | 401 | Token 无效或缺失 | 否 | +| `INVALID_FORMAT` | 400 | Content-Type 不支持或请求体格式错误 | 否 | +| `INVALID_PAYLOAD` | 400 | 必填字段缺失、类型错误或校验失败 | 否 | +| `BODY_TOO_LARGE` | 413 | JSON body 超过 50MB | 否 | +| `IMPORT_IN_PROGRESS` | 409 | 当前有其他导入正在执行 | 是 | +| `IDEMPOTENCY_CONFLICT` | 409 | 相同幂等键但请求体不一致 | 否 | +| `IMPORT_FAILED` | 500 | 导入过程内部错误 | 是 | +| `SERVER_ERROR` | 500 | 服务内部错误 | 是 | + +**重试策略建议:** + +``` +最大重试次数:3 +退避策略:指数退避 + 随机抖动 + 第 1 次重试:5s + random(0, 1s) + 第 2 次重试:15s + random(0, 3s) + 第 3 次重试:45s + random(0, 5s) +重试时必须使用相同的 Idempotency-Key +``` + +--- + +## 版本与兼容性 + +- `chatlab.version`:`0.0.2` +- API 路径前缀:`/api/v1` +- **向后兼容**:新增字段均为可选,不破坏旧调用方 +- **字段废弃策略**:废弃字段先标记为 `deprecated`,保留两个版本后移除 +- **平台标识可扩展**:`platform` 字段不限于预定义枚举,可传入任意小写字母标识 + +--- + +## 相关文档 + +- [ChatLab API 文档](./chatlab-api.md) — 查询、导出和系统端点 +- [Pull 远程数据源协议](./chatlab-pull.md) — 第三方暴露标准端点,ChatLab 主动拉取 +- [ChatLab 标准化格式规范](./chatlab-format.md) — 数据交换格式定义 diff --git a/docs/cn/standard/chatlab-pull.md b/docs/cn/standard/chatlab-pull.md new file mode 100644 index 000000000..61b44f96a --- /dev/null +++ b/docs/cn/standard/chatlab-pull.md @@ -0,0 +1,351 @@ +--- +outline: deep +--- + +# Pull 远程数据源协议 + +> v1 + +本文档定义第三方数据源暴露标准 HTTP 端点供 ChatLab 主动拉取数据的协议规范。这是 ChatLab 生态**推荐的第三方集成方式**。 + +::: tip 两种导入方式 + +- **[Push 模式](./chatlab-import.md)**:外部系统主动将数据推送到 ChatLab 的导入接口。适用于脚本集成、一次性文件导入。 +- **Pull 模式**(本文档):第三方暴露标准 HTTP 端点,ChatLab 主动拉取数据。**推荐的第三方集成方式。** + +::: + +## 为什么 Pull 是推荐方案 + +- 第三方工具是数据生产者,天然适合暴露数据;ChatLab 是数据消费者/分析者,天然适合主动获取 +- 用户只需在 ChatLab UI 中输入数据源地址,即可浏览、选择、同步——操作完全在 ChatLab 端完成 +- Push 模式需要第三方实现 HTTP 客户端逻辑(批次管理、重试、游标维护),门槛更高 +- Pull 协议定义的是**通用数据暴露标准**,不只服务 ChatLab,任何兼容工具都可以接入 + +**适用场景:** + +- 外部采集端运行在远程设备上,只需暴露 HTTP 接口 +- 用户希望在 ChatLab UI 上浏览可用对话、选择导入、点击"立即同步" +- 需要定时自动增量同步的长期运行场景 + +--- + +## 概述 + +Pull 模式的工作流程分为三个阶段: + +``` +1. 发现:ChatLab 获取数据源上的所有可用对话列表 +2. 拉取:用户选择对话后,ChatLab 拉取历史消息 +3. 同步:定时增量拉取新消息(可选 SSE 实时通知加速) +``` + +第三方数据源只需按本协议实现标准 HTTP 端点,ChatLab(以及未来任何兼容工具)即可自动完成发现、全量拉取和增量同步。 + +--- + +## 阶段一:发现可用对话 + +ChatLab 连接到远程数据源后,首先获取所有可拉取的对话列表。 + +### GET /sessions + +``` +GET {baseUrl}/sessions +Authorization: Bearer {token} ← 仅配置了 token 时携带 +Accept: application/json +``` + +**可选参数:** + +| 参数 | 类型 | 说明 | +| --------- | ------ | -------------------------------------------------------------------------- | +| `keyword` | string | 按对话名称模糊搜索。搜索语义由服务端定义,推荐按 `name` 模糊匹配,可选扩展到 `id` | +| `limit` | number | 返回条数限制。未传时默认返回全部;若服务端实现分页,建议设置合理上限 | +| `cursor` | string | 分页游标。仅在服务端支持分页发现时使用;`keyword` 变化后必须重新从第一页开始 | + +**响应:** + +```json +{ + "sessions": [ + { + "id": "112233445566", + "name": "产品讨论群", + "platform": "whatsapp", + "type": "group", + "messageCount": 58000, + "memberCount": 86, + "lastMessageAt": 1711468800 + }, + { + "id": "user_a", + "name": "张三", + "platform": "whatsapp", + "type": "private", + "messageCount": 1200, + "memberCount": 2, + "lastMessageAt": 1711465200 + } + ], + "page": { + "hasMore": true, + "nextCursor": "eyJsYXN0TWVzc2FnZUF0IjoxNzExNDY1MjAwLCJpZCI6Ind4aWRfZnJpZW5kX2EifQ==" + } +} +``` + +| 字段 | 类型 | 必填 | 说明 | +| --------------- | ------ | ---- | ----------------------------------- | +| `id` | string | 是 | 对话在数据源中的唯一标识 | +| `name` | string | 是 | 对话名称(群名/联系人名) | +| `platform` | string | 是 | 平台标识(与 Push 模式相同) | +| `type` | string | 是 | `group` / `private` | +| `messageCount` | number | 否 | 消息总数(用于 ChatLab 展示预估量) | +| `memberCount` | number | 否 | 成员数 | +| `lastMessageAt` | number | 否 | 最新消息时间戳 | + +`page` 为**可选增强字段**: + +| 字段 | 类型 | 必填 | 说明 | +| ------------ | ------- | ---- | ------------------------------------------------------------------ | +| `hasMore` | boolean | 否 | 是否还有下一页。仅在服务端支持分页发现时返回 | +| `nextCursor` | string | 否 | 下一页游标。`hasMore=true` 时应返回;客户端原样透传给下次请求 | + +**兼容规则:** + +- 旧版服务端可以继续只返回 `{ "sessions": [...] }`,不带 `page` +- ChatLab 客户端在响应中**未发现** `page` 字段时,应按“单次全量结果”处理 +- 若响应中包含 `page`,客户端可根据产品交互选择手动“加载更多”或自动续拉 +- ChatLab 当前推荐在 UI 中使用手动“加载更多”,按 `hasMore / nextCursor` 拉取后续页面 + +**分页一致性建议:** + +- 服务端应保证分页顺序稳定,推荐使用固定排序(例如 `lastMessageAt desc, id asc`) +- `cursor` 必须与当前查询条件绑定;只要 `keyword` 变化,旧 `cursor` 就应视为失效 +- 不建议在 `/sessions` 发现接口中使用 `offset` 分页,避免在列表变化时出现重复或漏项 + +ChatLab 在 UI 中展示该列表,用户选择需要导入的对话。 + +--- + +## 阶段二:拉取对话数据 + +用户选定对话后,ChatLab 拉取指定对话的数据。 + +### GET /sessions/:id/messages + +``` +GET {baseUrl}/sessions/{sessionId}/messages?format=chatlab&since={timestamp} +Authorization: Bearer {token} +Accept: application/json +``` + +| 参数 | 必填 | 说明 | +| ----------- | ---- | ------------------------------------------------------------------- | +| `sessionId` | 是 | 来自阶段一返回的对话 `id` | +| `format` | 是 | 固定为 `chatlab`,要求数据源返回 ChatLab 标准格式 | +| `since` | 否 | Unix 时间戳(秒级)。省略或为 `0` 时为全量拉取,大于 0 时为增量拉取 | +| `limit` | 否 | 单次返回的最大消息数,用于分页 | + +::: tip 未来演进 +后续版本可能支持 `Accept: application/x-ndjson` 以启用 NDJSON 流式响应,当前版本仅使用 JSON。 +::: + +### 数据携带规则 + +- **首次全量**(`since` 为空或 0):**必须**包含 `chatlab` + `meta` + `members` + `messages` +- **增量同步**(`since > 0`):**必须**包含 `messages`。`meta` / `members` **仅在发生实际变更时携带**,未变更时不得携带,以避免历史快照覆盖当前状态 +- 无新数据时返回空 `messages` 数组 + +::: tip 数据准备 +数据源在首次收到某个会话的 `since=0` 请求时,如需时间准备数据(如从磁盘加载、索引构建等),可先返回空 `messages` + `hasMore: false`。ChatLab 会自动重试(最多 3 次,间隔递增),等待数据源就绪后正常返回数据。 +::: + +### 响应格式 + +响应为标准 [ChatLab Format](./chatlab-format.md)(JSON 或 JSONL),并附带 `sync` 同步元信息。 + +```json +{ + "chatlab": { "version": "0.0.2", "exportedAt": 1711468800 }, + "meta": { "name": "产品讨论群", "platform": "whatsapp", "type": "group" }, + "members": [ ... ], + "messages": [ ... ], + "sync": { + "hasMore": true, + "nextSince": 1711468800 + } +} +``` + +### sync 同步元信息 + +| 字段 | 类型 | 必填 | 说明 | +| ------------ | ------- | ------ | ------------------------------------------------------------------------------------------ | +| `hasMore` | boolean | **是** | 是否还有更多数据。为 `true` 时 ChatLab 自动续拉 | +| `nextSince` | number | **是** | 下一次请求建议使用的 `since` 值(通常为本批最后一条消息的时间戳) | + +ChatLab 的分页续拉完全基于 `hasMore` + `nextSince` 时间戳链。数据源返回一批消息后,将 `nextSince` 设为本批最后一条消息的时间戳,ChatLab 下次请求时传入该值即可获取后续数据。ChatLab 内置的去重机制会正确处理时间戳边界的消息重叠。 + +::: details 协议预留字段(当前版本不使用) +以下字段在协议中保留,ChatLab 当前版本不主动使用,未来版本可能启用: + +| 字段 | 类型 | 说明 | +| ------------ | ------- | -------------------------------------------------------------- | +| `nextOffset` | number | 分页偏移量,配合 `offset` 参数使用 | +| `watermark` | number | 快照上界时间戳,用于保证分页期间数据一致性 | + +数据源可以不实现这些字段。ChatLab 的去重机制(基于 `platformMessageId` 或内容哈希)已能保证数据完整性。 +::: + +**sync 块的必要性规则:** + +| 数据源返回方式 | sync 块要求 | 说明 | +| -------------------------- | ----------- | ---------------------------------------------------------------- | +| 单次返回全部数据(不分页) | 可选 | ChatLab 视 `messages` 为完整结果 | +| 支持 `limit` 分页 | **必须** | 至少包含 `hasMore` + `nextSince` | + +::: warning 注意 +若数据源支持分页但未返回 `sync` 块,ChatLab 不保证自动续拉——仅处理首次返回的数据。 +::: + +### 分批拉取策略 + +对于大量历史数据(如数万条消息),推荐的分批方式: + +**时间戳链分批**(推荐):通过 `since` + `limit` 分批拉取,数据源通过 `sync.nextSince` 返回下次请求的起始时间戳,ChatLab 自动续拉直到 `hasMore=false`。 + +``` +第 1 页:GET /sessions/:id/messages?format=chatlab&since=0&limit=1000 + → 返回 1000 条,sync: { hasMore: true, nextSince: 1711400000 } + +第 2 页:GET /sessions/:id/messages?format=chatlab&since=1711400000&limit=1000 + → 返回 1000 条,sync: { hasMore: true, nextSince: 1711440000 } + +第 N 页:... + → 返回 500 条,sync: { hasMore: false, nextSince: 1711468800 } +``` + +ChatLab 内置去重机制保证不重复写入,即使 `nextSince` 边界上有消息重叠也不会产生重复数据。 + +--- + +## 阶段三:定时增量同步 + +ChatLab 按用户配置的间隔,定期对已订阅的对话执行增量拉取: + +``` +GET {baseUrl}/sessions/{sessionId}/messages?format=chatlab&since={lastPullAt} +``` + +远程数据源返回 `since` 之后的增量消息。ChatLab 通过内部导入管道处理(去重、meta/members 更新、FTS 索引等全部复用 Push 模式逻辑)。 + +--- + +## 可选:SSE 实时通知 + +除定时轮询外,远程数据源可**可选**实现 SSE(Server-Sent Events)端点,用于**通知 ChatLab 有新数据可拉取**。 + +::: warning 重要 +SSE 仅作为通知通道,不是数据同步主通道。ChatLab 不假设 SSE 事件可靠送达(网络断连、进程重启均可能丢失事件)。最终数据一致性始终由定时 Pull 保证。SSE 的作用是将增量同步延迟从"分钟级"降到"秒级"。 +::: + +### GET /push/messages + +``` +GET {baseUrl}/push/messages +Authorization: Bearer {token} +Accept: text/event-stream +``` + +**事件格式:** + +``` +event: message.new +data: {"eventId":"evt_001","sessionId":"112233445566","timestamp":1711468800} +``` + +| 字段 | 类型 | 必填 | 说明 | +| ------------------- | ------ | ---- | ------------------------------------------ | +| `eventId` | string | 是 | 事件唯一 ID,用于 ChatLab 去重已处理的通知 | +| `sessionId` | string | 是 | 有新消息的对话 ID | +| `timestamp` | number | 是 | 新消息的时间戳 | +| `platformMessageId` | string | 否 | 新消息的平台 ID(如可获取) | + +ChatLab 接收到 SSE 事件后,**触发一次该 session 的增量拉取**(调用 `GET /sessions/:id/messages?format=chatlab&since={lastPullAt}`),而非直接将事件数据写入存储。 + +--- + +## 认证 + +远程数据源可选择是否要求认证。如果需要,使用 `Authorization: Bearer {token}` 机制。 + +::: tip SSE 认证 +部分数据源额外支持 `?access_token=TOKEN` 查询参数方式传递 Token(SSE 长连接场景推荐此方式,因为 EventSource API 不支持自定义 Header)。ChatLab 在连接 SSE 时也支持查询参数传 Token。 +::: + +--- + +## 实现指南 + +### 最小实现(2 个端点) + +只需实现以下两个端点即可接入 ChatLab: + +| 端点 | 说明 | +| --------------------------------------------------- | --------------------- | +| `GET /sessions` | 返回对话列表 | +| `GET /sessions/:id/messages?format=chatlab&since=X` | 返回 ChatLab 格式数据 | + +最小实现不需要分页、SSE 或复杂的 `sync` 块。ChatLab 会将响应中的 `messages` 视为完整数据。 + +### 增强实现 + +| 能力 | 说明 | +| ------------------------------- | ------------------------------------------------------------ | +| `GET /push/messages` | SSE 实时通知(仅唤醒拉取,不传输完整数据) | +| 支持 `limit` + `sync` 分页 | 大量历史数据的分批拉取,通过 `hasMore` + `nextSince` 续拉 | + +### 数据格式 + +所有数据响应必须符合 [ChatLab 标准化格式规范](./chatlab-format.md)(JSON 或 JSONL),包括 `chatlab`、`meta`、`members`、`messages` 四个标准块。 + +### 媒体文件 + +如果数据源的消息中包含媒体引用,`attachments` 中的 `filePath` 或 `dataUri` 可指向数据源的媒体服务端点。ChatLab 当前按"协议预留"处理,未来版本将支持从数据源拉取媒体文件。 + +--- + +## 示例场景 + +某采集端在手机上持续采集微信消息,暴露 `GET /sessions` 和 `GET /sessions/:id/messages` 两个端点。用户在 ChatLab 中操作: + +``` +1. 在 ChatLab 设置中添加远程数据源(输入采集端 URL + 可选 Token) + +2. ChatLab 调用 GET {baseUrl}/sessions + → 展示 86 个群和 200 个私聊 + +3. 用户选择其中 5 个群导入 + +4. ChatLab 立即执行全量拉取: + GET {baseUrl}/sessions/{id}/messages?format=chatlab&since=0 + → 如有 sync.hasMore=true,自动续拉直到全部完成 + +5. 之后每小时自动增量同步: + GET {baseUrl}/sessions/{id}/messages?format=chatlab&since={lastPullAt} + +6. 如果采集端实现了 SSE: + 收到 message.new 事件 → 立即触发增量拉取(不等定时器) + +7. 用户可随时在 ChatLab UI 点击"立即同步" +``` + +--- + +## 相关文档 + +- [ChatLab API 文档](./chatlab-api.md) — 查询、导出和系统端点 +- [Push 导入协议](./chatlab-import.md) — 外部系统主动推送数据到 ChatLab +- [ChatLab 标准化格式规范](./chatlab-format.md) — 数据交换格式定义 diff --git a/docs/cn/usage/faq.md b/docs/cn/usage/faq.md new file mode 100644 index 000000000..23f1ae6c0 --- /dev/null +++ b/docs/cn/usage/faq.md @@ -0,0 +1,23 @@ +--- +outline: deep +--- + +# 常见问题 + +这里汇总 ChatLab 使用中高频出现的问题与处理思路。 + +## 导出问题 + +待补充。 + +## 导入问题 + +待补充。 + +## AI相关问题 + +待补充。 + +## 软件异常报错问题 + +待补充。 diff --git a/docs/cn/usage/how-to-config-ai.md b/docs/cn/usage/how-to-config-ai.md new file mode 100644 index 000000000..88275ecce --- /dev/null +++ b/docs/cn/usage/how-to-config-ai.md @@ -0,0 +1,31 @@ +--- +outline: deep +--- + +# 如何配置 AI 模型 + +## 在线 AI 模型 + +这里以 deepseek 为例,其他的模型请自行搜索配置方法: + +1. 访问 [Deepseek 官网](https://www.deepseek.com/),选择 API 开放平台,注册并登录 + +![](/cn/img/ai-guide/1.png) + +2. 选择左侧充值,充值你所需要的金额(建议先充 10 块钱,足够用很久了) + +![](/cn/img/ai-guide/2.png) + +3. 选择左侧 API Keys,点击创建 API key,随便填写一个标题,然后复制 API Key + +![](/cn/img/ai-guide/3.png) + +4. 打开 ChatLab,右下角点击设置,然后「模型配置」>「添加新配置」 + +把刚才复制的 API Key 填写到 API Keys 中,点击验证确认没问题后,点击右下角添加后,即可开始使用 AI 相关功能 + +![](/cn/img/ai-guide/4.png) + +5. 在左侧的用量信息中,你可以查看你充值和消费的额度 + +![](/cn/img/ai-guide/5.png) diff --git a/docs/cn/usage/how-to-export.md b/docs/cn/usage/how-to-export.md new file mode 100644 index 000000000..b312765f6 --- /dev/null +++ b/docs/cn/usage/how-to-export.md @@ -0,0 +1,144 @@ +# 导出聊天记录 + +ChatLab 专注于对已导出数据的分析,我们不提供抓取数据的功能。 + +您需要先使用官方或开源社区的第三方工具,将聊天记录导出后,再导入 ChatLab 进行分析。 + +## QQ + +目前有两种方式: + +### QQ Chat Exporter + +目前已适配 **QQ Chat Exporter** 导出的 json / jsonl 格式。 + +- **项目地址**:[https://github.com/shuakami/qq-chat-exporter](https://github.com/shuakami/qq-chat-exporter) +- **支持平台**:Windows / Linux +- **使用教程**:参考项目 README。 +- **提示**:导出时,需要选择格式为 json,同时建议勾选 「嵌入头像为 Base64」选项后再导出。 +- **提示 2**:对于导出的 jsonl 格式,仅需导入 jsonl 格式目录中的 manifest.json 文件 + +### 旧版 QQ(消息管理器) + +ChatLab 支持**旧版 QQ 原生导出的 txt 格式**(通过 QQ 消息管理器导出),直接将 `.txt` 文件拖入即可。 + +## 抖音 + +目前 **douyin-chat-export** 已适配 ChatLab 的导出协议,属于**非官方导出工具**,使用前请自行甄别项目安全性。 + +- **项目地址**:[https://github.com/TeamBreakerr/douyin-chat-export](https://github.com/TeamBreakerr/douyin-chat-export) +- **支持平台**:Windows / macOS / Linux +- **使用教程**:参考项目 README。 + +## 飞书 + +目前 **xiaofeixia** 已适配 ChatLab 的导出协议,属于**非官方导出工具**,使用前请自行甄别项目安全性。 + +- **项目地址**:[https://github.com/JiQingzhe2004/xiaofeixia](https://github.com/JiQingzhe2004/xiaofeixia) +- **支持平台**:Windows / macOS +- **使用教程**:参考项目 README。 + +注意事项:使用该软件需向企业管理员申请权限,因此不适用于个人员工。 + +## 企微 + +企微官方接口提供了导出聊天记录的功能,但是需要通过第三方SCRM系统进行导出。 + +如果你是管理员,需要自行采购支持导出的第三方SCRM系统,然后才能导出并分析。 + +个人用户暂时没有任何方式可以导出聊天记录。 + +## WhatsApp + +对于 WhatsApp, 目前已适配官方提供的"导出聊天"功能。 + +目前已兼容中文语言和英文语言的导出,如有其他语言需求,请联系开发者。 + +- **导出方式**: + 1. 打开 WhatsApp,进入想要导出的对话。 + 2. 点击顶部联系人名称 -> 导出聊天 (Export Chat)。 + 3. 选择"不附加媒体"。 +- **格式**:将导出后的 `.zip` 包解压出其中的 `txt` 文件,将 `txt` 文件拖入 ChatLab 即可。 + +## Discord + +对于 Discord,目前已适配 **DiscordChatExporter** 导出的 json 格式。 + +- **项目地址**:[https://github.com/Tyrrrz/DiscordChatExporter](https://github.com/Tyrrrz/DiscordChatExporter) +- **支持平台**:Windows / macOS / Linux +- **使用教程**:参考项目 README。 +- **提示**:请务必选择导出格式为 **JSON**,以便 ChatLab 正确解析。 + +## Instagram + +对于 Instagram,目前已适配官方提供的导出功能。 + +- **导出方式**: + 1. 打开 Instagram 应用或网页版,进入「设置」。 + 2. 点击「账户中心」->「你的信息和权限」->「下载你的信息」。 + 3. 选择「部分信息」,然后勾选「消息」。 + 4. 选择格式为 **JSON**,日期范围选择「所有时间」。 + 5. 点击「提交请求」,等待 Instagram 处理完成后下载。 +- **格式**:将下载的压缩包解压后,找到 `your_instagram_activity/messages/inbox/` 目录下对应聊天的 `message_1.json` 文件,拖入 ChatLab 即可。 +- **提示**:如果对话内容较多,可能会有多个 `message_*.json` 文件,建议逐个导入。 + +## LINE + +对于 LINE,目前已适配官方提供的聊天记录导出功能。 + +- **导出方式**: + 1. 打开 LINE,进入想要导出的对话。 + 2. 移动端:点击聊天右上角菜单 -> 设置 -> 导出聊天记录。 + 3. 桌面端(Windows / macOS):进入 Chats,打开对应聊天后,点击右上角菜单 -> Save chat。 + 4. 保存或分享导出的文本文件。 +- **格式**:将导出的 `.txt` 文件直接拖入 ChatLab 即可。 +- **提示**:LINE 官方说明中提到,桌面端仅会保存当前已加载并显示在聊天窗口中的消息。 + + + +## iMessage + +对于 iMessage,目前 **imessage-chatlab** 已适配 ChatLab 标准 JSON 格式。 + +- **项目地址**:[https://github.com/gamesme/imessage-chatlab](https://github.com/gamesme/imessage-chatlab) +- **支持平台**:macOS +- **安装方式**:如果已安装 Rust 工具链,可以运行 `cargo install imessage-chatlab`;也可以按项目 README 从源码安装。 +- **导出方式**:参考项目 README。例如可运行 `imessage-chatlab -c clone -o ~/imessage_chatlab_export` 导出本机 iMessage 数据。 +- **格式**:工具会按会话导出 ChatLab 标准 JSON 文件,将导出的 `.json` 文件拖入 ChatLab 即可。 +- **提示**:该工具会读取 macOS 本机 Messages 数据库。使用前请仔细阅读项目文档,并确认您拥有读取与分析相关聊天记录的合法权限。 + +## Google Chat + +对于 Google Chat,目前已适配 Google 官方 Takeout 导出的 ZIP 格式。 + +- **导出方式**: + 1. 打开 [Google Takeout](https://takeout.google.com/),用您的 Google 账号登录。 + 2. 点击「取消全选」,然后仅勾选 **Google Chat**(按需减小导出体积)。 + 3. 选择文件类型为 **.zip**(暂不支持 .tgz 格式)。 + 4. 点击「创建导出」,等待 Google 处理完成后下载。 +- **格式**:将下载的 `.zip` 文件直接拖入 ChatLab,ChatLab 会扫描压缩包内的所有会话并显示选择列表,勾选后逐个导入即可。 +- **提示**: + - 仅支持 ZIP 格式,如果 Takeout 提供的是 .tgz,请重新导出时选择 .zip。 + - 首次导出请求可能需要等待数小时,Google 会通过邮件通知下载就绪。 + - 附件(图片、文件等)目前不会随聊天记录一并导入。 + +## Q&A:小红书/企微/钉钉等的聊天记录能分析吗? + +ChatLab 的功能是 **对已导出的固定文本格式的聊天记录进行分析**,但前提是**您已经通过合法合规的渠道导出了聊天记录**。 + +我们**不提供任何解密、抓包或导出的工具与脚本**,只支持对已导出的聊天记录格式进行兼容。只要您能提供脱敏后的聊天记录文本样本,那么就可以尝试支持分析。 + +如果您有一定的技术基础,可以尝试使用 **AI 辅助转换** 的方式,将您的数据转换为标准格式。详情请查看 [AI 辅助转换指南](/cn/standard/ai-converter)。 + +此外,如果您是开发者,并已支持了其他聊天应用的聊天记录导出,欢迎[兼容 ChatLab 格式](/cn/standard/chatlab-format),我会将您的 Github 链接加到这里。 + +## ⚠️ 使用说明 + +在尝试分析聊天数据或使用相关工具前,请务必仔细阅读并知晓以下条款: + +- **第三方工具**:**使用第三方导出工具时,请务必仔细阅读其官方文档和安全说明。** ChatLab 与文中所列的第三方项目无任何关联,相关链接仅作为技术信息参考提供,不代表本项目认可、担保或安全性背书。用户需自行评估并承担使用第三方工具的所有风险。 +- **合法授权原则**:您仅可处理您**本人参与**的聊天记录。若涉及他人隐私,请务必确保已获得相关人员的知情同意。 +- **禁止非法用途**:严禁将本软件用于窃取、监控或分析未经授权的他人隐私,或用于任何侵犯他人权益的行为。 +- **合规性自负**:从第三方平台获取数据的行为属于您的个人行为。若因分析行为违反了原始数据来源平台的服务条款而导致账号受限或其他后果,ChatLab 不承担任何责任。 +- **禁止商用**:严禁任何个人或机构将本软件或分析结果用于任何形式的商业盈利行为。 +- **结果准确性**:软件生成的分析结果可能存在错误或“幻觉”,仅供技术交流参考,不应作为法律证据或决策依据。 diff --git a/docs/cn/usage/how-to-import.md b/docs/cn/usage/how-to-import.md new file mode 100644 index 000000000..6b3fdbc88 --- /dev/null +++ b/docs/cn/usage/how-to-import.md @@ -0,0 +1,18 @@ +--- +outline: deep +--- + +# 导入聊天记录指南 + +完成导出后,您只需在 ChatLab 的首页: + +1. 将导出的**数据文件**直接拖入上传区域。 +2. 等待 ChatLab 解析完成即可。 + +## BUG 排查 + +如果导入失败,可以通过日志快速排查问题: + +软件左下角「设置」 > 「基础设置」 > 「日志文件」,打开该目录,该目录下有个「import」目录,就是所有导入的日志记录了。 + +如果您看不懂,可以通过 Github issue 提交问题。 diff --git a/docs/cn/usage/index.md b/docs/cn/usage/index.md new file mode 100644 index 000000000..6b7cb8ef9 --- /dev/null +++ b/docs/cn/usage/index.md @@ -0,0 +1,5 @@ +--- +outline: deep +--- + +# USAGE diff --git a/docs/cn/usage/qa.md b/docs/cn/usage/qa.md new file mode 100644 index 000000000..130e52e6f --- /dev/null +++ b/docs/cn/usage/qa.md @@ -0,0 +1,50 @@ +# Q&A + +## 未来会支持音频、图片导入吗? + +不确定,目前的文本分析功能仍然有非常多的 TODO 需要实现,计划文本分析的功能完善之后再考虑音频和图片的分析。 + +## 如何直接访问本地数据库 + +ChatLab 使用 SQLite 存储聊天记录,你可以用任何 SQLite 客户端工具直接查看数据。 + +### 数据库位置 + +你可直接通过软件的 设置 > 存储管理 > 聊天记录数据库 > 打开,打开数据库所在文件夹。 + +| 平台 | 路径 | +| ------- | ------------------------------------------------------- | +| macOS | `~/Library/Application Support/ChatLab/data/databases/` | +| Windows | `%APPDATA%/ChatLab/data/databases/` | +| Linux | `~/.config/ChatLab/data/databases/` | + +每个聊天记录是一个独立的 `.db` 文件。 + +### 推荐工具 + +- [DB Browser for SQLite](https://sqlitebrowser.org/) - 免费开源,新手友好 +- [TablePlus](https://tableplus.com/) - 界面美观 +- [DBeaver](https://dbeaver.io/) - 功能强大 + +### 命令行访问 + +```bash +# macOS/Linux +sqlite3 ~/Library/Application\ Support/ChatLab/data/databases/你的数据库.db + +# 常用命令 +.tables # 查看所有表 +.schema message # 查看 message 表结构 +SELECT * FROM message LIMIT 10; # 查询消息 +``` + +### 表结构 + +- `meta` - 聊天记录元信息 +- `member` - 成员信息 +- `message` - 消息内容 +- `member_name_history` - 成员改名历史 + +### 注意事项 + +⚠️ 建议在 ChatLab **关闭时**访问数据库,避免锁冲突。 diff --git a/docs/cn/usage/quick-start.md b/docs/cn/usage/quick-start.md new file mode 100644 index 000000000..2e685b6da --- /dev/null +++ b/docs/cn/usage/quick-start.md @@ -0,0 +1,74 @@ +--- +outline: deep +--- + +# 快速开始 + +## 第一步:安装 ChatLab + +ChatLab 提供两种安装方式: + +**方式一:官网下载安装包** + +前往 [ChatLab 官网](https://chatlab.fun) 或 [GitHub Releases](https://github.com/ChatLab/ChatLab/releases) 下载对应操作系统的安装包,双击安装即可。 + +**方式二:CLI 安装** + +```bash +npm i chatlab-cli -g +``` + +需要 Node.js ≥ 20。 + +安装后运行以下命令启动 ChatLab: + +```bash +chatlab start # 启动 API + Web UI,并在浏览器中打开 +chatlab start --no-open # 启动 API + Web UI,但不自动打开浏览器 +chatlab start --headless # 仅启动 API,不挂载 Web UI(供脚本 / AI Agent 调用) +``` + +常用选项:`--port <端口>`(默认 3110)、`--host <地址>`、`--token <令牌>`。 + +如果希望服务常驻后台(开机自启),加上 `--daemon` 参数: + +```bash +chatlab start --daemon # 注册为系统服务,开机自启(macOS / Linux) +chatlab status # 查看常驻状态 +chatlab stop # 停止并取消常驻 +``` + +::: tip +`clb` 是 `chatlab` 的简写,两者完全等价。 +::: + +## 第二步:导入聊天记录 + +ChatLab 提供三种导入方式,适用于不同场景: + +| 方式 | 适用场景 | +|------|----------| +| **文件导入** | 将导出的聊天记录文件直接拖入 ChatLab 首页,适合一次性导入 | +| **自动同步** | 配置外部平台的数据源,让聊天记录定期自动同步到 ChatLab | +| **API 推送** | 开启本地 API 服务,允许第三方工具/插件或脚本主动推送聊天记录至 ChatLab | + +### 普通用户 + +使用**文件导入**即可,你需要: + +1. 先使用第三方工具将聊天记录导出为文件,具体导出方式请查看 [导出聊天记录](/cn/usage/how-to-export)。 +2. 将导出的文件直接拖入 ChatLab 首页即可,如遇问题请查看 [导入聊天记录指南](/cn/usage/how-to-import)。 + +### 开发者 + +如果你是开发者,想要对接**自动同步**或 **API 推送**,请查看以下文档: + +- [Push 导入协议](/cn/standard/chatlab-import) — 对应「API 推送」 +- [Pull 远程数据源协议](/cn/standard/chatlab-pull) — 对应「自动同步」 +- [ChatLab Format](/cn/standard/chatlab-format) — 了解数据格式规范 + +## 第三步:配置 AI + +ChatLab 内置 AI Agent 功能,接入 AI 模型后即可通过自然语言探索你的聊天历史。 + +详细配置步骤请查看 [如何配置 AI 模型](/cn/usage/how-to-config-ai)。 diff --git a/docs/cn/usage/troubleshooting.md b/docs/cn/usage/troubleshooting.md new file mode 100644 index 000000000..875902814 --- /dev/null +++ b/docs/cn/usage/troubleshooting.md @@ -0,0 +1,81 @@ +--- +outline: deep +--- + +# 故障排查指南 + +本文档帮助用户和开发者排查 ChatLab 使用中遇到的问题。 + +## 日志文件 + +**获取日志文件**:软件左下角的 **「设置」 > 「存储管理」 > 「日志文件」 > 打开目录** + +日志存储在 `文档/ChatLab/logs/` 目录下: + +``` +ChatLab/logs/ +├── app.log # 主程序日志 +├── ai/ # AI 相关日志 +│ └── ai_YYYY-MM-DD_HH-mm.log +└── import/ # 导入日志 + └── import_{sessionId}_{timestamp}.log +``` + +### 日志文件说明 + +| 目录/文件 | 内容 | +| -------------- | ------------------------------------------------ | +| `app.log` | 主程序日志,包含文件解析、数据库操作、IPC 通信等 | +| `ai/*.log` | AI 日志,包含 LLM 调用、Agent 执行、工具调用等 | +| `import/*.log` | 导入性能日志,包含导入速度、内存使用、各阶段耗时 | + +## 常见问题 + +### 1. 导入失败 + +**症状**:拖入文件后提示解析失败 + +**排查步骤**: + +1. 确认文件格式是否支持(.json / .jsonl / .txt) +2. 检查文件是否损坏(用文本编辑器打开查看) +3. 查看日志文件中的 `[Parser]` 相关错误 + +### 2. AI 功能无响应 + +**症状**:AI 实验室发送消息后无回复 + +**排查步骤**: + +1. 检查是否已配置 API Key(设置 > AI 设置) +2. 点击「验证」,确认 API 连接正常 +3. 查看日志文件中的 `[LLM]` 或 `[Agent]` 相关错误 + +**常见原因**: + +- API Key 无效或余额不足 +- API 服务商限流 + +### 3. 数据库错误 + +**症状**:打开会话时提示错误 + +**排查步骤**: + +1. 查看日志文件中的 `[Database]` 相关错误 +2. 检查数据库文件是否存在 + +## 反馈问题 + +如果以上方法无法解决问题,请: + +1. 收集日志文件 +2. 描述问题复现步骤 +3. 提交 Issue 到 GitHub + +**提交 Issue 时请包含**: + +- 操作系统及版本 +- ChatLab 版本 +- 问题描述及复现步骤 +- 相关日志片段(注意脱敏) diff --git a/docs/en/contributing/acknowledgments.md b/docs/en/contributing/acknowledgments.md new file mode 100644 index 000000000..31bfd31dc --- /dev/null +++ b/docs/en/contributing/acknowledgments.md @@ -0,0 +1,43 @@ +--- +outline: deep +--- + +# Acknowledgments + +ChatLab wouldn't be where it is without the people who helped along the way. + +## Contributors + +Thank you to everyone who has contributed code, documentation, tests, and feedback: + + + + + +## Thanks + +The following developers have also been a part of ChatLab's journey in one way or another — thank you. + +[@shuakami](https://github.com/shuakami) · [@ycccccccy](https://github.com/ycccccccy) + +## Special Thanks + +### Leo Oliveira ([@13dev](https://github.com/13dev)) + +In early 2026, we decided to move ChatLab from a personal GitHub account to an organization — a natural step as the community grew. There was just one problem: the "ChatLab" organization name had been registered years earlier by a developer in Madeira, Portugal named Leo Oliveira. His project had been inactive for over five years. + +On a whim, I filed an [Issue](https://github.com/onecord-io/client/issues/3) in his repository, explained the situation, and asked if he'd consider transferring the organization. I didn't expect much. + +Nearly a month passed. I had more or less given up and started thinking about alternative names. Then Leo replied out of nowhere, invited me to chat on Discord, and within a few messages — before I'd even had a chance to prepare for any kind of negotiation — he had already renamed his organization. + +He sent a single message: + +> done + +No conditions. No payment. No strings attached. He even said, unprompted, twice: *"If you need help with the project down the road, just ask."* + +Leo had no idea what ChatLab was, and nothing to gain from the transfer. He just thought an active open-source project deserved the name more than an abandoned one. + +It was a good reminder of why open source is worth caring about — even in 2026, that spirit is still very much alive. + +Thank you, Leo. 🙏 diff --git a/docs/en/contributing/development.md b/docs/en/contributing/development.md new file mode 100644 index 000000000..e705bef20 --- /dev/null +++ b/docs/en/contributing/development.md @@ -0,0 +1,159 @@ +--- +layout: doc +title: Development Guide +--- + +# Development Guide + +This guide is for contributors who want to work on ChatLab code. It covers local setup, repository structure, common change entry points, and contribution rules. Product usage belongs in the usage docs; internal tasks, drafts, and personal maintenance context are not required for public contributions. + +## Read This First + +- Start with this page to understand the public collaboration baseline. +- When using AI while contributing, ask it to read the root `AGENTS.md` file and this page first. +- If your workspace contains `.docs/`, you can also read `.docs/README.md` and related files. `.docs/` is an optional private development context for an individual or team. It can store tasks, decisions, AI collaboration memory, and temporary plans; public docs and public PRs should not require `.docs/` to be understood. + +## Requirements + +- Node.js `>=24 <25` +- pnpm `>=9 <10` + +Install dependencies: + +```bash +pnpm install +``` + +## Local Commands + +| Command | Purpose | +| --- | --- | +| `pnpm dev` | Select a development target interactively | +| `pnpm dev:desktop` | Start the Electron desktop app in development mode | +| `pnpm dev:web` | Start the CLI Web UI in development mode | +| `pnpm docs:dev` | Start the public docs site locally | +| `pnpm build:desktop` | Build the desktop app | +| `pnpm build:web` | Build the Web UI | +| `pnpm docs:build` | Build the public docs site | +| `pnpm run type-check:all` | Run both web and Node type checks | +| `pnpm lint` | Run ESLint with auto-fix | +| `pnpm format` | Run Prettier formatting | + +For small changes, prefer targeted checks for the files or package you changed. For cross-module, release, or architecture changes, run the broader checks. + +## Repository Structure + +| Path | Responsibility | +| --- | --- | +| `src/` | Shared frontend app code, including pages, components, services, stores, and i18n | +| `src/services/` | Frontend service layer for Electron, CLI Web API, and platform capabilities | +| `apps/desktop/` | Electron main process, preload, and desktop build configuration | +| `apps/cli/` | CLI, HTTP API, CLI Web runtime, and import commands | +| `packages/core/` | Platform-independent data model, queries, imports, and member operations | +| `packages/node-runtime/` | Node.js runtime services, database, AI, exports, caches, and migrations | +| `packages/tools/` | Shared AI tool definitions and data access adapters | +| `docs/` | Public documentation site source | +| `changelogs/` | Multilingual changelogs used by the app and releases | +| `.docs/` | Optional private development context for an individual or team, not required for public contributions | + +## Architecture Boundaries + +ChatLab maintains both an Electron desktop app and a CLI Web app. When changing shared business behavior, put the logic in `packages/node-runtime/src/services/` or `packages/core/` first, and keep entry points thin. + +- Do not duplicate complex business flows inside Electron IPC handlers or CLI HTTP routes. +- Do not bypass `packages/core/` in entry points to write core SQL operations such as member merge, delete, or alias updates. +- Isolate platform differences through adapters or service options, and keep returned frontend data shapes consistent. +- For new session, member, index, summary, export, or import behavior, first check whether an existing shared service can be reused or extended. + +## Data Directory Compatibility Gate + +Electron desktop, CLI Web, and MCP can share the same `userDataDir`. If a newer runtime changes the database schema, AI data, auth config, or data directory layout, an older runtime may read incorrect data or corrupt user data. Any change that makes old runtimes unsafe for the same data directory must use the data directory compatibility gate. + +The compatibility metadata file is: + +```text +/.chatlab-meta.json +``` + +The existing `/.chatlab` file remains only a directory marker and should not be converted to JSON. + +### When To Raise The Gate + +Usually raise `minRuntimeVersion` when: + +- a database migration deletes, renames, or changes the meaning of tables/columns that older versions access +- AI chats, assistants, skills, tool allowlists, auth profiles, or config files change in a way older versions cannot safely parse +- the `userDataDir` layout changes so older versions would read or write the wrong location +- shared cross-runtime data changes canonical names or structure in a non-backward-compatible way + +Adding optional fields that old versions safely ignore, or changing only regenerable derived data, usually does not require raising the gate. + +### Implementation Rules + +- Use the helpers in `packages/node-runtime/src/data-dir-compat.ts`; do not hand-roll JSON reads, writes, or semver comparison in entry points. +- CLI, MCP, and Desktop startup must check compatibility. `DatabaseManager` also checks before opening a database so long-running stale services can notice when another newer runtime raises the gate. +- A migration that raises the gate should write `.chatlab-meta.json` only after the migration actually succeeds. If writing the meta file fails, startup or database open must abort instead of continuing to serve requests. +- `minRuntimeVersion` must be a stable semver such as `0.25.1`; prerelease versions are not accepted as the formal compatibility version. +- Raising can only increase the requirement. If an existing meta file already requires a higher version, keep it. +- Merge and de-duplicate `reasons` so future debugging can identify which migration raised the gate. +- HTTP routes that hit a data directory compatibility error should return `DATA_DIR_INCOMPATIBLE` with HTTP 409, not a generic 500. + +Hidden rescue override: + +```bash +CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR=1 +``` + +This only bypasses “current runtime is below the required version.” It must not bypass broken JSON, invalid fields, or invalid versions. When used, the runtime must print a clear warning about data corruption risk. + +### Test Requirements + +Compatibility-related changes should cover: + +- upgrades from the previous stable version and earlier released versions without data loss +- missing `.chatlab-meta.json` allowing old data directories to start +- CLI/Desktop/MCP or `DatabaseManager` blocking when the current runtime is below `minRuntimeVersion` +- successful migrations writing or merging `minRuntimeVersion`, `dataCompatibilityVersion`, and `reasons` +- existing higher `minRuntimeVersion` values not being lowered +- HTTP routes mapping compatibility failures to `DATA_DIR_INCOMPATIBLE` + +## Common Change Entry Points + +| If you want to change | Start here | +| --- | --- | +| Frontend pages and components | `src/pages/`, `src/components/` | +| Chart analysis | `src/components/analysis/`, `src/components/charts/` | +| Data, message, and session API calls | `src/services/` | +| Electron main process | `apps/desktop/main/`, `apps/desktop/preload/` | +| CLI and Web API | `apps/cli/` | +| Shared business logic | `packages/node-runtime/src/services/`, `packages/core/` | +| AI tools and agents | `packages/tools/`, `packages/node-runtime/src/ai/`, `src/services/ai*` | +| Import parsing | `packages/core/`, `apps/cli/src/import/`, `src/services/import/` | +| Documentation site | `docs/`, `docs/.vitepress/config.mts` | +| Changelog | `changelogs/` | + +## Tests And Checks + +- After changing TypeScript or Vue code, run at least the relevant type check. +- After changing public docs, run `pnpm docs:build` or targeted formatting checks for the changed Markdown/config files. +- After changing shared cross-platform logic, confirm Electron and CLI Web entry points do not diverge in behavior. +- Daily default test command is `pnpm test`; to prioritize related tests, run `pnpm test -- path/to/file.test.ts`. +- `pnpm test` should include only unit/integration tests and must not depend on real LLMs, real Electron, real browsers, real network, or long-running E2E. +- Unit tests tightly coupled to one business module should live next to the tested file and use `*.test.ts` or `*.test.js`. +- Cross-module, integration, E2E, test utility, or unclear-ownership tests should live in the root `tests/` directory. +- SQL behavior, database migrations, Fastify routes, and cross-package services should prefer lightweight in-memory SQLite or temporary file fixtures that exercise real behavior. Adapter-layer tests should focus on argument passing, permission filtering, error mapping, and response contracts instead of repeating lower-level algorithm matrices. + +## i18n And Copy + +When changing UI copy, update Simplified Chinese, English, Japanese, and Traditional Chinese translations together. Logs, code comments, AI tool descriptions, error messages, and other non-UI text should default to English. If runtime locale is available, support bilingual Chinese/English responses where appropriate. + +## Using AI While Contributing + +AI can help read code, draft patches, and add tests, but public PRs must remain understandable from public context. Ask AI to read `AGENTS.md` and this page first. If you maintain your own `.docs/`, you can use it as extra context, but do not leave change rationale, test reasoning, or design assumptions only in private `.docs/` files. + +## PR And Commit Rules + +- Obvious bug fixes can be submitted directly. +- For new features, open an Issue for discussion first. Feature PRs submitted without prior discussion may be closed. +- Use Conventional Commits, such as `fix(import): handle empty source` or `docs: add contributor guide`. +- Use platform scopes such as `electron`, `cli`, or `web` only for platform-specific changes. For general changes, use the module name as the scope. diff --git a/docs/en/index.md b/docs/en/index.md new file mode 100644 index 000000000..cc4b1f290 --- /dev/null +++ b/docs/en/index.md @@ -0,0 +1,32 @@ +--- +layout: doc +title: ChatLab Docs | Open-Source Chat History Analysis Tool +--- + +# ChatLab + +ChatLab is a free, open-source, local-first chat history analysis tool. It supports importing records from WhatsApp, QQ, LINE, Discord, Instagram, Telegram, and iMessage, and offers visual analytics and AI-powered conversation features — with all data stored locally, never uploaded to the cloud. + +## How to Use ChatLab + +- [Introduction](/intro) — Learn what ChatLab is and what it can do. +- [Quick Start](/usage/quick-start) — Step-by-step guide to installing ChatLab and importing your first chat records. +- [Export & Import](/usage/how-to-export) — Export chat records from WhatsApp, QQ, LINE, and other platforms and import them into ChatLab. +- [Configure AI](/usage/how-to-config-ai) — Connect OpenAI, Claude, DeepSeek, or other AI models to analyze your chat history in natural language. +- [Troubleshooting](/usage/troubleshooting) — Fix common issues with failed imports, unsupported formats, and AI configuration errors. + +## For Developers: Integrating with ChatLab + +- [API & Standards](/standard/chatlab-api) — ChatLab's local REST API for querying, importing, and analyzing chat data from external tools. +- [ChatLab Format](/standard/chatlab-format) — The open chat data interchange format for cross-platform data compatibility. +- [Push Import Protocol](/standard/chatlab-import) — Push external chat data into ChatLab via HTTP. +- [Pull Data Source Protocol](/standard/chatlab-pull) — Expose standard HTTP endpoints so ChatLab can pull remote chat data on demand. +- [AI Conversion Guide](/standard/ai-converter) — Use AI to convert unsupported chat export formats into ChatLab Format. + +## Contributing + +- [Development Guide](/contributing/development) — Local development setup, repo structure, architecture boundaries, and PR guidelines for ChatLab. + +## More + +- [Website & Roadmap](https://chatlab.fun) — Download ChatLab desktop app or CLI, view the product roadmap, and find community links. diff --git a/docs/en/intro.md b/docs/en/intro.md new file mode 100644 index 000000000..e6748151c --- /dev/null +++ b/docs/en/intro.md @@ -0,0 +1,51 @@ +--- +outline: deep +--- + +# Introduction + +ChatLab is a free, open-source, local-first chat analysis app. + +In the digital era, chat histories are no longer just plain text. They carry a decade of social connections, precious voice notes from loved ones, and serve as the emotional memory we keep in the digital world. + +ChatLab was built to **give everyone a private, secure way to analyze and revisit their own conversations.** + +## What you can do with ChatLab + +- **Visual analytics**: Word frequency, word clouds, activity rankings, interaction heatmaps, time-of-day breakdowns — understand your chat history at a glance. +- **AI chat**: Connect an AI model and explore your chat history in natural language — search, summarize, and analyze with an AI Agent. +- **Privacy first**: Everything stays on your device. No uploads, no cloud, no exceptions. +- **What's next**: ChatLab's goal is to become the standard infrastructure for social memory — building a persistent record of every relationship, and serving as the local interface through which AI Agents understand your social world. See the [Roadmap](https://chatlab.fun/roadmap/intro). + +## Supported platforms + +| Platform | Status | +|----------|--------| +| QQ | ✅ Supported | +| WhatsApp | ✅ Supported | +| LINE | ✅ Supported | +| Discord | ✅ Supported | +| Instagram | ✅ Supported | +| Telegram | ✅ Supported | +| iMessage | ✅ Supported | +| Messenger / KakaoTalk | 🔜 Coming soon | + +## Two ways to run ChatLab + +**Desktop app**: Download the installer and double-click to get started. Best for personal use. + +**CLI**: Install via npm and run from the terminal. Good for server deployments, automation scripts, or pairing with an AI Agent like Claude Desktop. + +Both share the same underlying data engine — your data works across both. + +## Next steps + +Ready to install and import your first chat records? See [Quick Start](./usage/quick-start.md). + +Running into import or AI issues? Check [Troubleshooting](./usage/troubleshooting.md). + +Your export format isn't supported yet? See the [AI Conversion Guide](./standard/ai-converter.md). + +Building a tool or integrating with the API? See [API & Standards](./standard/chatlab-api.md). + +For anything else, join the community: [Community](https://chatlab.fun/other/community) diff --git a/docs/en/standard/ai-converter.md b/docs/en/standard/ai-converter.md new file mode 100644 index 000000000..f3b27b391 --- /dev/null +++ b/docs/en/standard/ai-converter.md @@ -0,0 +1,82 @@ +--- +outline: deep +--- + +# AI Conversion Guide + +If your chat record format (such as CSV, HTML, TXT, or other database exports) is not directly supported by ChatLab, you can use AI (like ChatGPT, Claude, DeepSeek, etc.) to quickly write a conversion script to transform your data into ChatLab's standard format. + +## Preparation + +1. **View the standard specification**: [ChatLab Standard Format Specification v0.0.1](./chatlab-format.md) +2. **Prepare your data**: Have your exported original chat record file ready (if using online services, we recommend providing only a few hundred anonymized samples). + +## Choose Target Format + +Select the appropriate prompt based on your data size. + +### Scenario 1: Small to Medium Data (Recommended) + +- **Target Format**: JSON (`.json`) +- **Use Case**: Less than 1 million records, file size < 100MB. +- **Features**: Clear structure, best compatibility. + +#### Copy JSON Conversion Prompt + +```markdown +**Role Setting**: You are an expert in data processing and script writing. + +**Task Objective**: Based on the [ChatLab Standard Format Specification] (chatlab-format.md) I provide, please write a script to convert my uploaded [original chat records] into the compliant **JSON format**. + +**Requirements**: + +1. **Analyze Structure**: Analyze the text patterns or data structure of the original chat records. +2. **Field Mapping**: + - Map original fields to ChatLab standard fields (`timestamp`, `sender`, `content`, `type`, etc.). + - If the original data lacks `sender` (user ID), please automatically generate a unique hash or virtual ID based on `accountName` (username). + - Default `type` to 0 (text). If you can identify images, voice, or other types from the content, please try to map them. +3. **Script Generation**: + - Please write a **complete, executable script** (Python or Node.js recommended). + - **Output Structure**: The script should build a complete JSON object containing `chatlab`, `meta`, `members`, `messages`, and write it to a file at once. + - The script should include necessary error handling and print progress. +4. **Result Validation**: + - Ensure the generated JSON structure strictly conforms to the definitions in `chatlab-format.md`. + +**Output**: Please provide the code directly and briefly explain how to run the script. +``` + +### Scenario 2: Very Large Data + +- **Target Format**: JSONL (`.jsonl`) +- **Use Case**: More than 1 million records, or very large file size. +- **Features**: Streaming read/write, extremely low memory usage, won't crash due to large data volumes. + +#### Copy JSONL Conversion Prompt + +```markdown +**Role Setting**: You are an expert in big data processing and stream computing. + +**Task Objective**: Based on the [ChatLab Standard Format Specification] (chatlab-format.md) I provide, please write a script to convert my uploaded [original chat records] into the compliant **JSONL (JSON Lines) format**. + +**Requirements**: + +1. **Analyze Structure**: Analyze the text patterns of the original chat records. +2. **Stream Processing**: + - **Must use streaming read/write** (Line-by-Line) approach; do not load all data into memory at once. + - Read the original file line by line, write to the target file line by line. +3. **JSONL Structure Requirements**: + - **First line**: Must write the `_type: "header"` line (containing `chatlab` and `meta` information). + - **Member information**: If possible, scan once or collect member information during processing, write `_type: "member"` lines. + - **Message records**: Each chat record writes one `_type: "message"` line. +4. **Script Generation**: + - Please write an **efficient Python script**. + - Ensure constant memory usage during processing, suitable for GB-level large files. + +**Output**: Please provide the code directly and briefly explain how to run the script. +``` + +## Next Steps + +1. **Run the script**: Run the AI-generated script in your local environment. +2. **Check results**: Open the generated file and confirm the format is correct. +3. **Import to ChatLab**: Import the generated file into ChatLab for analysis. diff --git a/docs/en/standard/chatlab-api.md b/docs/en/standard/chatlab-api.md new file mode 100644 index 000000000..90b0d163b --- /dev/null +++ b/docs/en/standard/chatlab-api.md @@ -0,0 +1,499 @@ +# ChatLab API Documentation + +ChatLab provides a local RESTful API service that allows external tools, scripts, and MCP to query chat records, execute SQL queries, and import chat data via HTTP. + +## Quick Start + +### 1. Enable the Service + +Open ChatLab → Settings → ChatLab API → Enable Service. + +Once enabled, an API Token is automatically generated. The default port is `3110`. + +### 2. Verify Service Status + +```bash +curl http://127.0.0.1:3110/api/v1/status \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +Response example: + +```json +{ + "success": true, + "data": { + "name": "ChatLab API", + "version": "1.0.0", + "uptime": 3600, + "sessionCount": 5 + }, + "meta": { + "timestamp": 1711468800, + "version": "0.0.2" + } +} +``` + +## General Information + +| Item | Description | +| -------------- | ---------------------------- | +| Base URL | `http://127.0.0.1:3110` | +| API Prefix | `/api/v1` | +| Authentication | Bearer Token | +| Data Format | JSON | +| Bind Address | `127.0.0.1` (localhost only) | + +### Authentication + +All requests must include the `Authorization` header: + +``` +Authorization: Bearer clb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +The Token can be viewed and regenerated in Settings → ChatLab API. + +### Unified Response Format + +**Success response:** + +```json +{ + "success": true, + "data": { ... }, + "meta": { + "timestamp": 1711468800, + "version": "0.0.2" + } +} +``` + +**Error response:** + +```json +{ + "success": false, + "error": { + "code": "SESSION_NOT_FOUND", + "message": "Session not found: abc123" + } +} +``` + +--- + +## Endpoint List + +### System + +| Method | Path | Description | +| ------ | ---------------- | -------------------------- | +| GET | `/api/v1/status` | Service status | +| GET | `/api/v1/schema` | ChatLab Format JSON Schema | + +### Data Query (Export) + +| Method | Path | Description | +| ------ | ------------------------------------- | ------------------------------ | +| GET | `/api/v1/sessions` | List all sessions | +| GET | `/api/v1/sessions/:id` | Get single session details | +| GET | `/api/v1/sessions/:id/messages` | Query messages (paginated) | +| GET | `/api/v1/sessions/:id/members` | Get member list | +| GET | `/api/v1/sessions/:id/stats/overview` | Get overview statistics | +| POST | `/api/v1/sessions/:id/sql` | Execute custom SQL (read-only) | +| GET | `/api/v1/sessions/:id/export` | Export ChatLab Format JSON | + +### Data Import + +| Method | Path | Description | +| ------ | ----------------------------- | -------------------------------------- | +| POST | `/api/v1/import` | Import chat records (new session) | +| POST | `/api/v1/sessions/:id/import` | Incremental import to existing session | + +--- + +## Endpoint Details + +### GET /api/v1/status + +Get the running status of the API service. + +**Response:** + +| Field | Type | Description | +| -------------- | ------ | ---------------------------- | +| `name` | string | Service name (`ChatLab API`) | +| `version` | string | ChatLab application version | +| `uptime` | number | Service uptime in seconds | +| `sessionCount` | number | Total number of sessions | + +--- + +### GET /api/v1/schema + +Get the JSON Schema definition for ChatLab Format, useful for building compliant import data. + +--- + +### GET /api/v1/sessions + +Get all imported sessions. + +**Response example:** + +```json +{ + "success": true, + "data": [ + { + "id": "session_abc123", + "name": "Tech Discussion Group", + "platform": "whatsapp", + "type": "group", + "messageCount": 58000, + "memberCount": 120 + } + ] +} +``` + +--- + +### GET /api/v1/sessions/:id + +Get detailed information for a single session. + +**Path parameters:** + +| Parameter | Type | Description | +| --------- | ------ | ----------- | +| `id` | string | Session ID | + +--- + +### GET /api/v1/sessions/:id/messages + +Query messages from a specific session with pagination and filtering support. + +**Query parameters:** + +| Parameter | Type | Default | Description | +| ----------- | ------ | ------- | ------------------------------ | +| `page` | number | 1 | Page number | +| `limit` | number | 100 | Items per page (max 1000) | +| `startTime` | number | - | Start timestamp (Unix seconds) | +| `endTime` | number | - | End timestamp (Unix seconds) | +| `keyword` | string | - | Keyword search | +| `senderId` | string | - | Filter by sender's platformId | +| `type` | number | - | Filter by message type | + +**Request example:** + +```bash +curl "http://127.0.0.1:3110/api/v1/sessions/abc123/messages?page=1&limit=50&keyword=hello" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**Response:** + +```json +{ + "success": true, + "data": { + "messages": [ + { + "senderPlatformId": "123456", + "senderName": "John", + "timestamp": 1703001600, + "type": 0, + "content": "Hello!" + } + ], + "total": 1500, + "page": 1, + "limit": 50, + "totalPages": 30 + } +} +``` + +--- + +### GET /api/v1/sessions/:id/members + +Get all members of a specific session. + +--- + +### GET /api/v1/sessions/:id/stats/overview + +Get overview statistics for a specific session. + +**Response:** + +```json +{ + "success": true, + "data": { + "messageCount": 58000, + "memberCount": 120, + "timeRange": { + "start": 1609459200, + "end": 1703001600 + }, + "messageTypeDistribution": { + "0": 45000, + "1": 8000, + "5": 3000, + "80": 2000 + }, + "topMembers": [ + { + "platformId": "123456", + "name": "John", + "messageCount": 5800, + "percentage": 10.0 + } + ] + } +} +``` + +| Field | Description | +| --- | --- | +| `messageCount` | Total message count | +| `memberCount` | Total member count | +| `timeRange` | Earliest/latest message timestamps (Unix seconds) | +| `messageTypeDistribution` | Count per message type (key is [message type](./chatlab-format.md#message-type-reference) enum value) | +| `topMembers` | Top 10 active members (sorted by message count descending) | + +--- + +### POST /api/v1/sessions/:id/sql + +Execute a read-only SQL query against a specific session's database. Only `SELECT` statements are allowed. + +**Request body:** + +```json +{ + "sql": "SELECT sender, COUNT(*) as count FROM messages GROUP BY sender ORDER BY count DESC LIMIT 10" +} +``` + +**Response:** + +```json +{ + "success": true, + "data": { + "columns": ["sender", "count"], + "rows": [ + ["123456", 5800], + ["789012", 3200] + ] + } +} +``` + +> For database schema details, refer to ChatLab internal documentation or use `SELECT * FROM sqlite_master WHERE type='table'` to inspect the tables. + +--- + +### GET /api/v1/sessions/:id/export + +Export complete session data in [ChatLab Format](./chatlab-format.md) JSON. + +**Limit:** Maximum **100,000 messages** per export. If the session exceeds this limit, a `400 EXPORT_TOO_LARGE` error is returned. For larger sessions, use the paginated `/messages` API. + +**Response:** + +```json +{ + "success": true, + "data": { + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800, + "generator": "ChatLab API" + }, + "meta": { + "name": "Tech Discussion Group", + "platform": "whatsapp", + "type": "group" + }, + "members": [...], + "messages": [...] + } +} +``` + +--- + +### POST /api/v1/import + +Import chat records into ChatLab, **creating a new session**. + +#### Supported Content-Types + +| Content-Type | Format | Use Case | Body Limit | +| --- | --- | --- | --- | +| `application/json` | ChatLab Format JSON | Small to medium data (quick testing, script integration) | **50MB** | +| `application/x-ndjson` | ChatLab JSONL format | Large-scale data (production integration) | **Unlimited** | + +#### JSON Mode Example + +```bash +curl -X POST http://127.0.0.1:3110/api/v1/import \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800 + }, + "meta": { + "name": "Import Test", + "platform": "qq", + "type": "group" + }, + "members": [ + { "platformId": "123", "accountName": "Test User" } + ], + "messages": [ + { + "sender": "123", + "accountName": "Test User", + "timestamp": 1711468800, + "type": 0, + "content": "Hello World" + } + ] + }' +``` + +#### JSONL Mode Example + +```bash +cat data.jsonl | curl -X POST http://127.0.0.1:3110/api/v1/import \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/x-ndjson" \ + --data-binary @- +``` + +**Response:** + +```json +{ + "success": true, + "data": { + "mode": "new", + "sessionId": "session_xyz789" + } +} +``` + +> For the full ChatLab Format specification, see [ChatLab Format Specification](./chatlab-format.md). + +--- + +### POST /api/v1/sessions/:id/import + +**Incrementally import** chat records into an existing session. Duplicate messages are automatically skipped. + +**Deduplication rules:** + +The unique key for each message is `timestamp + senderPlatformId + contentLength`. If a message's timestamp, sender, and content length match an existing message exactly, it is considered a duplicate and skipped. + +**Path parameters:** + +| Parameter | Type | Description | +| --------- | ------ | ----------------- | +| `id` | string | Target session ID | + +Content-Type and request body format are the same as `POST /api/v1/import`. + +**Response:** + +```json +{ + "success": true, + "data": { + "mode": "incremental", + "sessionId": "session_abc123", + "newMessageCount": 150 + } +} +``` + +--- + +## Concurrency & Limits + +| Limit | Value | Description | +| -------------------- | --------- | ------------------------------------------- | +| JSON body size | 50MB | `application/json` mode | +| JSONL body size | Unlimited | `application/x-ndjson` streaming mode | +| Export message limit | 100,000 | `/export` endpoint | +| Max page size | 1,000 | `/messages` endpoint | +| Import concurrency | 1 | Only one import operation allowed at a time | + +--- + +## Error Codes + +| Error Code | HTTP Status | Description | +| ------------------------ | ----------- | ----------------------------------------------- | +| `UNAUTHORIZED` | 401 | Invalid or missing token | +| `SESSION_NOT_FOUND` | 404 | Session not found | +| `INVALID_FORMAT` | 400 | Request body does not conform to ChatLab Format | +| `SQL_READONLY_VIOLATION` | 400 | SQL is not a SELECT statement | +| `SQL_EXECUTION_ERROR` | 400 | SQL execution error | +| `EXPORT_TOO_LARGE` | 400 | Message count exceeds export limit (100K) | +| `BODY_TOO_LARGE` | 413 | Request body exceeds 50MB (JSON mode only) | +| `IMPORT_IN_PROGRESS` | 409 | Another import is already in progress | +| `IMPORT_FAILED` | 500 | Import failed | +| `SERVER_ERROR` | 500 | Internal server error | + +--- + +## Security + +- **Localhost only**: API binds to `127.0.0.1`, not exposed to the network +- **Token authentication**: All endpoints require a valid Bearer Token +- **Read-only SQL**: `/sql` endpoint only allows `SELECT` queries +- **Disabled by default**: API service must be manually enabled + +--- + +## Use Cases + +### 1. MCP Integration + +Connect the ChatLab API to AI tools like ClaudeCode, enabling AI to directly query and analyze chat records. + +### 2. Automation Scripts + +Write scripts to periodically export chat records from other platforms, convert to ChatLab Format, and automatically import via the Push API. + +### 3. Data Analysis + +Use the SQL endpoint to run custom queries, combined with Python/R for advanced data analysis. + +### 4. Data Backup + +Periodically export important session data via the `/export` endpoint as JSON backups. + +### 5. Scheduled Pulling + +Configure external data source URLs in the Settings page. ChatLab will automatically fetch and import new data at the configured interval. + +--- + +## Version History + +| Version | Description | +| ------- | --------------------------------------------------------------------------------------------------- | +| v1 | Initial release — session query, message search, SQL, export, import (JSON + JSONL), Pull scheduler | diff --git a/docs/en/standard/chatlab-format.md b/docs/en/standard/chatlab-format.md new file mode 100644 index 000000000..56970c69b --- /dev/null +++ b/docs/en/standard/chatlab-format.md @@ -0,0 +1,311 @@ +--- +outline: deep +--- + +# ChatLab Standard Format Specification v0.0.1 + +ChatLab defines a standard chat record data exchange format to support unified import and analysis of multi-platform data. + +As long as you convert your chat records to this format, ChatLab can parse and analyze them. + +::: warning Notice +This format specification is still in its early development stage. Some fields and structures may be adjusted in future versions. +::: + +## Overview + +### Supported File Formats + +| Format | Extension | Use Case | +| --------- | --------- | ----------------------------------------------------------- | +| **JSON** | `.json` | Small to medium records (<1 million), clear structure | +| **JSONL** | `.jsonl` | Very large records (>1 million), streaming, constant memory | + +### Format Comparison + +| Feature | JSON | JSONL | +| --------------- | ------------------------------- | ------------------------------- | +| Memory Usage | Requires loading full structure | Line-by-line, constant (~100MB) | +| File Size Limit | ~1GB (depends on memory) | No practical limit | +| Append Writing | Requires rewriting entire file | ✅ Direct line append | +| Error Recovery | Single error invalidates file | Can skip error lines | +| Readability | ⭐⭐⭐ Easy to read | ⭐⭐ One record per line | +| Recommended For | Small/medium (<1M records) | Large (>1M records) | + +## Quick Start + +Here's a **minimal** ChatLab format example with only required fields: + +```json +{ + "chatlab": { + "version": "0.0.1", + "exportedAt": 1703001600 + }, + "meta": { + "name": "My Group Chat", + "platform": "qq", + "type": "group" + }, + "members": [ + { + "platformId": "123456", + "accountName": "John" + } + ], + "messages": [ + { + "sender": "123456", + "accountName": "John", + "timestamp": 1703001600, + "type": 0, + "content": "Hello everyone!" + } + ] +} +``` + +--- + +## JSON Format Detailed Specification + +### File Header (chatlab) + +| Field | Type | Required | Description | +| ------------- | ------ | -------- | --------------------------------------- | +| `version` | string | ✅ | Format version, currently `"0.0.1"` | +| `exportedAt` | number | ✅ | Export time (Unix timestamp in seconds) | +| `generator` | string | - | Generator tool name | +| `description` | string | - | Description | + +### Metadata (meta) + +| Field | Type | Required | Description | +| ------------- | ------ | -------- | ------------------------------------------------------------------- | +| `name` | string | ✅ | Group name or conversation name | +| `platform` | string | ✅ | Platform identifier: `qq` / `discord` / `whatsapp` / `slack`, etc. | +| `type` | string | ✅ | Chat type: `group` / `private` | +| `groupId` | string | - | Group ID (group chat only) | +| `groupAvatar` | string | - | Group avatar (Data URL format) | + +### Members (members) + +| Field | Type | Required | Description | +| --------------- | -------- | -------- | ----------------------------- | +| `platformId` | string | ✅ | User unique identifier | +| `accountName` | string | ✅ | Account name | +| `groupNickname` | string | - | Group nickname (group only) | +| `aliases` | string[] | - | User-defined aliases | +| `avatar` | string | - | User avatar (Data URL format) | + +### Messages (messages) + +| Field | Type | Required | Description | +| --------------- | -------------- | -------- | ------------------------------------- | +| `sender` | string | ✅ | Sender's `platformId` | +| `accountName` | string | ✅ | Account name when sending | +| `groupNickname` | string | - | Group nickname when sending | +| `timestamp` | number | ✅ | Unix timestamp in seconds | +| `type` | number | ✅ | Message type (see table below) | +| `content` | string \| null | ✅ | Message content (`null` for non-text) | + +--- + +## Message Type Reference + +::: warning Tip +If you have other special types in your chat records that need support, please submit an issue explaining your situation. We'll evaluate whether to add them to the standard message types. +::: + +### Basic Message Types (0-19) + +| Value | Name | Description | +| ----- | -------- | ------------- | +| 0 | TEXT | Text message | +| 1 | IMAGE | Image | +| 2 | VOICE | Voice | +| 3 | VIDEO | Video | +| 4 | FILE | File | +| 5 | EMOJI | Emoji/Sticker | +| 7 | LINK | Link/Card | +| 8 | LOCATION | Location | + +### Interactive Message Types (20-39) + +| Value | Name | Description | +| ----- | ---------- | --------------------------------- | +| 20 | RED_PACKET | Red packet | +| 21 | TRANSFER | Transfer | +| 22 | POKE | Poke/Nudge | +| 23 | CALL | Voice/Video call | +| 24 | SHARE | Share (music, mini program, etc.) | +| 25 | REPLY | Quote reply | +| 26 | FORWARD | Forward message | +| 27 | CONTACT | Contact card | + +### System Message Types (80+) + +| Value | Name | Description | +| ----- | ------ | ---------------------------------------- | +| 80 | SYSTEM | System message (join/leave/announcement) | +| 81 | RECALL | Recalled message | +| 99 | OTHER | Other/Unknown | + +## Avatar Format + +The `avatar` and `groupAvatar` fields use **Data URL** format: + +``` +data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD... +``` + +Supported image formats: + +- `image/jpeg` - JPEG format (recommended, smaller size) +- `image/png` - PNG format +- `image/gif` - GIF format +- `image/webp` - WebP format + +::: tip Suggestion +When exporting, we recommend compressing avatars to 100×100 pixels or less to reduce file size. +::: + +## Complete Examples + +### Group Chat Example (with optional fields) + +```json +{ + "chatlab": { + "version": "0.0.1", + "exportedAt": 1703001600, + "generator": "My Converter Tool", + "description": "2024 Tech Exchange Group Chat Backup" + }, + "meta": { + "name": "Tech Exchange Group", + "platform": "whatsapp", + "type": "group", + "groupId": "38988428513", + "groupAvatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + }, + "members": [ + { + "platformId": "abc123", + "accountName": "John", + "groupNickname": "Admin-John", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + }, + { + "platformId": "def456", + "accountName": "Jane", + "groupNickname": "Moderator", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + } + ], + "messages": [ + { + "sender": "abc123", + "accountName": "John", + "groupNickname": "Admin-John", + "timestamp": 1703001600, + "type": 0, + "content": "Hello everyone! Welcome to the Tech Exchange Group~" + }, + { + "sender": "def456", + "accountName": "Jane", + "groupNickname": "Moderator", + "timestamp": 1703001610, + "type": 1, + "content": "[Image: screenshot.jpg]" + } + ] +} +``` + +### Private Chat Example + +```json +{ + "chatlab": { + "version": "0.0.1", + "exportedAt": 1703001600 + }, + "meta": { + "name": "Conversation with Mike", + "platform": "qq", + "type": "private" + }, + "members": [ + { + "platformId": "123456789", + "accountName": "Me", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + }, + { + "platformId": "987654321", + "accountName": "Mike", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + } + ], + "messages": [ + { + "sender": "123456789", + "accountName": "Me", + "timestamp": 1703001600, + "type": 0, + "content": "Hey, are you there?" + } + ] +} +``` + +## JSONL Streaming Format + +JSONL (JSON Lines) format is suitable for **very large chat records** (>1 million messages) to avoid memory overflow issues. + +### Format Features + +- One JSON object per line +- Distinguish line types by `_type` field: `header` / `member` / `message` +- Constant memory usage (~100MB), supports GB-level files +- Supports streaming writes, can append while exporting + +### Line Type Description + +| `_type` | Description | Required | +| --------- | ------------------------------------- | --------------------- | +| `header` | File header with `chatlab` and `meta` | ✅ Must be first line | +| `member` | Member information | - Optional | +| `message` | Message record | ✅ At least one | + +### Complete Example + +```jsonl +{"_type":"header","chatlab":{"version":"0.0.1","exportedAt":1703001600},"meta":{"name":"Tech Exchange Group","platform":"qq","type":"group"}} +{"_type":"member","platformId":"123456","accountName":"John","groupNickname":"Admin"} +{"_type":"member","platformId":"789012","accountName":"Jane"} +{"_type":"message","sender":"123456","accountName":"John","groupNickname":"Admin","timestamp":1703001600,"type":0,"content":"Hello everyone!"} +{"_type":"message","sender":"789012","accountName":"Jane","timestamp":1703001610,"type":0,"content":"Hi there!"} +{"_type":"message","sender":"123456","accountName":"John","groupNickname":"Admin","timestamp":1703001620,"type":1,"content":"[Image]"} +``` + +### Parsing Rules + +1. **First line must be header**: Contains `chatlab` version and `meta` information +2. **Member lines before messages**: Optional; if omitted, member info will be collected from messages +3. **Messages sorted by time**: Recommended to sort by `timestamp` in ascending order +4. **Each line is independent**: Single line parsing errors can be skipped +5. **Comment lines supported**: Lines starting with `#` are skipped (can be used for notes) + +::: warning Notice + +- Each line must be **valid JSON** (cannot span lines) +- Lines are separated by newline `\n` ::: + +## Version History + +| Version | Date | Changes | +| ------- | ------- | --------------- | +| 0.0.1 | 2025-12 | Initial version | diff --git a/docs/en/standard/chatlab-import.md b/docs/en/standard/chatlab-import.md new file mode 100644 index 000000000..2d14973a3 --- /dev/null +++ b/docs/en/standard/chatlab-import.md @@ -0,0 +1,475 @@ +--- +outline: deep +--- + +# Push Import Protocol + +> v1 + +This document defines the standard import protocol for external data sources to push chat data into ChatLab. It covers three scenarios: initial full import, historical backfill, and periodic incremental sync. + +::: tip Two Import Modes + +- **Push mode** (this document): The external system actively pushes data to ChatLab's import endpoint. Suitable for script integrations and one-time file imports. +- **[Pull mode](./chatlab-pull.md)**: A third-party exposes standard HTTP endpoints and ChatLab pulls data on demand. **The recommended integration approach for third-party tools.** + +Both modes share the same underlying import pipeline (deduplication, meta/members update, FTS indexing). Data format is unified as [ChatLab Format](./chatlab-format.md). + +::: + +## Design Principles + +1. **Single endpoint**: Initial and incremental imports use the same endpoint — callers don't need to distinguish. +2. **Minimal surface**: 1 import endpoint + 2 query endpoints cover all Push scenarios. +3. **Two-layer idempotency**: Request-level (`Idempotency-Key`) + record-level dedup (`platformMessageId` / content hash), guaranteeing **at-least-once + deterministic dedupe**. +4. **Synchronous by default**: Small batches return `200 OK` with write results synchronously. +5. **Auto-update by default**: `meta` and `members` are updated automatically with each import request; controllable via `options`. + +--- + +## Basics + +### Base URL + +``` +Base URL: http://: (desktop default: 127.0.0.1:3110) +Prefix: /api/v1 +``` + +### Authentication + +All requests must include a Bearer Token: + +``` +Authorization: Bearer +``` + +Tokens are generated in ChatLab settings and are formatted as `clb_` + 64 hex characters. + +### Content-Type + +``` +application/json # Standard JSON body (≤50MB) +``` + +--- + +## Endpoints + +| Method | Path | Description | +| --- | --- | --- | +| `POST` | `/api/v1/imports/:sessionId` | Import messages into a session (auto-creates on first import, appends on subsequent calls) | +| `GET` | `/api/v1/sessions/:id` | Query session status (for reconciliation) | +| `GET` | `/api/v1/sessions` | List all sessions (for discovering target sessions) | + +For query endpoint details, see [ChatLab API](./chatlab-api.md). + +--- + +## POST /api/v1/imports/:sessionId + +**The single import entry point.** Creates the session if it doesn't exist; appends data and updates meta/members if it does. + +### Path Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| `sessionId` | string | Session ID, generated and maintained by the caller. Must remain stable for the same chat source across batches. See generation strategy below. | + +**Session ID Generation Strategy:** + +| Priority | Scenario | Recommended Format | Example | +| --- | --- | --- | --- | +| 1 (preferred) | Platform-native ID available | `{platform}_{originalId}` | `whatsapp_112233445566`, `qq_123456789` | +| 2 | File import (structured ID available) | `{platform}_{meta.groupId}` or `{platform}_{platformId}` | `whatsapp_112233445566` | +| 3 | File import (no structured identifier) | `file_{SHA256(content)[:16]}` | `file_a1b2c3d4e5f6g7h8` | +| 4 (fallback) | One-off import | `import_{UUID}` | `import_550e8400-e29b-41d4-a716-446655440000` | + +::: warning +- Avoid using file paths as sessionId input — renaming the file changes the sessionId. +- The same sessionId **must** be used for both the initial import and all subsequent incremental imports for the same chat source. +::: + +### Request Headers + +| Header | Required | Description | +| --- | --- | --- | +| `Authorization` | Yes | `Bearer ` | +| `Content-Type` | Yes | `application/json` | +| `Idempotency-Key` | Recommended | Unique identifier for the current batch, for safe retries. Suggested format: `{sessionId}-{batchIndex}-{windowStart}` | + +### Quick Test + +Copy the command below to test immediately (replace `YOUR_TOKEN` and the port with your actual values): + +```bash +curl http://127.0.0.1:3110/api/v1/imports/group_abc123 \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "chatlab": { "version": "0.0.2", "exportedAt": 1711468800, "generator": "test" }, + "meta": { "name": "Product Discussion", "platform": "whatsapp", "type": "group", "groupId": "112233445566" }, + "members": [ + { "platformId": "user_a", "accountName": "Alice", "roles": [{ "id": "owner" }] } + ], + "messages": [ + { "platformMessageId": "msg_1001", "sender": "user_a", "timestamp": 1711468800, "type": 0, "content": "Hello" } + ] +}' +``` + +A successful response returns `"success": true` with write statistics. Repeating the same `platformMessageId` is deduplicated — `duplicateCount` increases while `writtenCount` stays the same. + +--- + +### Request Body (JSON) + +```json +{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800, + "generator": "YourSystem/1.0" + }, + "meta": { + "name": "Product Discussion", + "platform": "whatsapp", + "type": "group", + "groupId": "112233445566", + "groupAvatar": "data:image/jpeg;base64,...", + "ownerId": "user_owner" + }, + "members": [ + { + "platformId": "user_a", + "accountName": "Alice", + "groupNickname": "Product", + "avatar": "data:image/jpeg;base64,...", + "roles": [{ "id": "owner" }] + } + ], + "messages": [ + { + "platformMessageId": "msg_1001", + "sender": "user_a", + "accountName": "Alice", + "groupNickname": "Product", + "timestamp": 1711468800, + "type": 0, + "content": "Hello", + "replyToMessageId": "msg_1000" + } + ], + "options": { + "metaUpdateMode": "patch", + "memberUpdateMode": "upsert" + } +} +``` + +### options Object (Optional) + +| Field | Type | Default | Values | Description | +| --- | --- | --- | --- | --- | +| `metaUpdateMode` | string | `patch` | `patch` / `none` | `patch`: overwrite non-empty fields; `none`: skip update | +| `memberUpdateMode` | string | `upsert` | `upsert` / `none` | `upsert`: insert + update; `none`: skip update | + +::: tip +When backfilling historical data, pass `"metaUpdateMode": "none"` to prevent old group names from overwriting the current value. +::: + +### Block Requirements + +| Block | First Import | Incremental Import | Notes | +| --- | --- | --- | --- | +| `chatlab` | Required | Optional | First use creates the session; subsequent use for version compatibility | +| `meta` | Required | Optional | First use sets initial values; subsequent use patches non-empty fields | +| `members` | Required | Optional | First use writes all members; subsequent use upserts by `platformId` | +| `messages` | Required | Required | Every batch must include at least one message | + +**Meta Auto-Update Rules:** + +- Fields provided by the caller with non-empty values → overwrite +- Fields not provided or empty → keep existing value +- `platform` and `type` cannot be updated after session creation + +**Members Upsert Rules:** + +- Matched by `platformId`: new `platformId` → insert; existing → update non-empty fields +- If `members` is omitted: unknown `sender` values in messages are auto-created as members (with minimal info only) + +--- + +### Field Definitions + +#### chatlab Object + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `version` | string | Yes | Format version, currently `"0.0.2"` | +| `exportedAt` | number | Yes | Export/generation time (Unix timestamp in seconds) | +| `generator` | string | No | Name of the generating tool or system | + +#### meta Object + +| Field | Type | Required (first import) | Description | +| --- | --- | --- | --- | +| `name` | string | Yes | Group or conversation name | +| `platform` | string | Yes | Platform identifier (see enum below) | +| `type` | string | Yes | Conversation type: `group` or `private` | +| `groupId` | string | No | Platform-native group ID | +| `groupAvatar` | string | No | Group avatar as base64 Data URL or network URL | +| `ownerId` | string | No | `platformId` of the exporter/owner | + +**Platform Identifier Enum:** + +| Value | Platform | +| --- | --- | +| `wechat` | WeChat | +| `qq` | QQ | +| `telegram` | Telegram | +| `discord` | Discord | +| `whatsapp` | WhatsApp | +| `line` | LINE | +| `slack` | Slack | +| `unknown` | Unknown / Other | + +::: tip +If your platform isn't listed, use a lowercase identifier (e.g. `signal`, `matrix`). ChatLab will apply the `unknown` analysis strategy. +::: + +#### members Array Elements + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `platformId` | string | Yes | Member's unique platform identifier | +| `accountName` | string | Recommended | Account name (original nickname, unchanged across groups) | +| `groupNickname` | string | No | Group-specific nickname | +| `avatar` | string | No | Avatar as base64 Data URL or network URL | +| `roles` | array | No | Role list, see [Role Definitions](./chatlab-format.md) | + +#### messages Array Elements + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `sender` | string | Yes | Sender's `platformId` or reserved identifier (e.g. `SYSTEM`) | +| `timestamp` | number | Yes | Message timestamp (Unix seconds) | +| `type` | number | Yes | Message type enum (see [Message Types](./chatlab-format.md)) | +| `accountName` | string | Recommended | Account name at the time of sending | +| `groupNickname` | string | No | Group nickname at the time of sending | +| `content` | string\|null | No | Plain text content; can be null for non-text messages | +| `platformMessageId` | string | Strongly recommended | Platform-native message ID, the preferred deduplication key | +| `replyToMessageId` | string | No | `platformMessageId` of the message being replied to | + +**sender Field Rules:** + +1. Must be a stable `platformId` or the reserved identifier `SYSTEM` +2. If `sender` is not in `members`, ChatLab auto-creates the member with minimal info +3. `SYSTEM` is for system messages (join/leave/announcements) and is excluded from member statistics + +--- + +### Success Response + +```json +{ + "success": true, + "data": { + "sessionId": "group_abc123", + "created": false, + "batch": { + "receivedCount": 5000, + "writtenCount": 4986, + "duplicateCount": 14 + }, + "session": { + "totalCount": 128640, + "memberCount": 86, + "firstTimestamp": 1609459200, + "lastTimestamp": 1711468800 + }, + "updates": { + "metaUpdated": true, + "membersAdded": 3, + "membersUpdated": 5 + } + } +} +``` + +| Field | Description | +| --- | --- | +| `created` | `true` if this request triggered session creation (first import) | +| `batch.receivedCount` | Number of messages received in this batch | +| `batch.writtenCount` | Number of messages actually written | +| `batch.duplicateCount` | Number of messages skipped due to deduplication | +| `session.totalCount` | Total message count in the session after this write | +| `session.memberCount` | Total member count in the session | +| `session.firstTimestamp` | Earliest message timestamp in the session | +| `session.lastTimestamp` | Latest message timestamp in the session | +| `updates.metaUpdated` | Whether meta was updated by this request | +| `updates.membersAdded` | Number of new members added | +| `updates.membersUpdated` | Number of existing members updated | + +--- + +## Deduplication + +Deduplication is scoped to a single session and does not cross sessions. + +**Priority:** + +1. If `platformMessageId` is provided, it is used as the unique key (high precision, recommended). +2. If `platformMessageId` is absent, falls back to content hash: `sha256(timestamp + '\0' + sender + '\0' + contentTag + '\0' + normalizedContent)` + +| Layer | Mechanism | Scope | Precision | +| --- | --- | --- | --- | +| Request-level idempotency | `Idempotency-Key` | Retries of the same HTTP request | Exact | +| Message-level dedup (primary) | `platformMessageId` | Same message across batches/windows | Exact | +| Message-level dedup (fallback) | Content hash | Fallback when `platformMessageId` is absent | Best effort | + +::: warning +- A message with the same `platformMessageId` will not be written again, even if `content` differs (first write wins). +- Content hash dedup treats "same sender, same second, identical content" as a duplicate; there is a very small false positive rate. +- **Strongly recommended**: provide `platformMessageId` — it is the most reliable deduplication key. +::: + +--- + +## Batching + +### Recommended Batch Size + +**5,000 messages per batch.** + +| Constraint | Value | Notes | +| --- | --- | --- | +| JSON body size limit | 50MB | Exceeding returns `BODY_TOO_LARGE` (413) | +| Recommended batch size | 5,000 messages | Balances performance and memory | + +### Batching Rules + +- Send batches in chronological order, oldest first +- Overlapping time windows between batches are allowed (5–10 minutes recommended); deduplication handles the overlap +- Each batch is an independent request; a failed batch does not affect others +- The first batch must include `chatlab` + `meta` + `members` (triggers session creation) +- Subsequent batches may include `messages` only + +### Cursor Maintenance (caller's responsibility) + +ChatLab does not maintain cursors for callers. Recommended structure: + +```json +{ + "sessionId": "group_abc123", + "lastSyncedTimestamp": 1711468800, + "lastSyncedMessageId": "msg_900000" +} +``` + +After each successful batch, update the cursor with `session.lastTimestamp` from the response. On failure, keep the cursor unchanged and retry with the same `Idempotency-Key`. + +### Concurrency + +Current version: **only one import task is allowed at a time per session**. Concurrent requests to the same sessionId return `IMPORT_IN_PROGRESS` (409); imports to different sessions run independently. + +--- + +## Standard Workflows + +### Initial Full Import (Bootstrap) + +``` +1. Prepare all chat data and split into N batches in chronological order (≤5,000 messages each) + +2. First batch: + POST /api/v1/imports/group_abc123 + Body: { chatlab, meta, members, messages } + → Response: created: true, session created + +3. Batches 2–N: + POST /api/v1/imports/group_abc123 + Body: { messages } + Idempotency-Key: {sessionId}-{batchIndex}-{windowStart} + +4. After each batch, record cursor; on failure, retry with the same Idempotency-Key + +5. After all batches, reconcile: + GET /api/v1/sessions/group_abc123 + → Verify totalCount, firstTimestamp, lastTimestamp +``` + +### Historical Backfill + +``` +1. Identify the time range [start, end] to fill +2. Split into batches by time window +3. Call POST /api/v1/imports/:sessionId for each batch + (Recommended: set options.metaUpdateMode: "none" to prevent old names overwriting current) +4. Update local cursor after each successful batch +5. Optional: GET /api/v1/sessions/:id for reconciliation +``` + +### Scheduled Incremental Sync + +``` +1. Fixed sessionId + scheduler (e.g. hourly/daily) +2. Start from last cursor, with 5–10 minutes of overlap as the window start +3. Fetch new messages in that time window +4. If there are new members or name changes, include meta/members +5. Call POST /api/v1/imports/:sessionId +6. Deduplication absorbs overlapping data +7. Update local cursor +``` + +--- + +## Media Attachments (Reserved) + +The current version focuses on stable text message import. `attachments` is a reserved protocol field: callers may include it in messages, but ChatLab does not guarantee full persistence or rendering in this version. + +See [ChatLab Format Specification](./chatlab-format.md) for the reserved field structure. + +--- + +## Error Codes and Retry Strategy + +| Error Code | HTTP Status | Description | Retryable | +| --- | --- | --- | --- | +| `UNAUTHORIZED` | 401 | Invalid or missing token | No | +| `INVALID_FORMAT` | 400 | Unsupported Content-Type or malformed body | No | +| `INVALID_PAYLOAD` | 400 | Missing required fields, type errors, or validation failures | No | +| `BODY_TOO_LARGE` | 413 | JSON body exceeds 50MB | No | +| `IMPORT_IN_PROGRESS` | 409 | Another import is currently running | Yes | +| `IDEMPOTENCY_CONFLICT` | 409 | Same idempotency key but different request body | No | +| `IMPORT_FAILED` | 500 | Internal error during import | Yes | +| `SERVER_ERROR` | 500 | Internal server error | Yes | + +**Recommended Retry Strategy:** + +``` +Max retries: 3 +Backoff: exponential + random jitter + Retry 1: 5s + random(0, 1s) + Retry 2: 15s + random(0, 3s) + Retry 3: 45s + random(0, 5s) +Always reuse the same Idempotency-Key on retry +``` + +--- + +## Versioning and Compatibility + +- `chatlab.version`: `0.0.2` +- API path prefix: `/api/v1` +- **Backward compatible**: all new fields are optional and do not break existing callers +- **Deprecation policy**: deprecated fields are marked first, kept for two versions, then removed +- **Extensible platform identifiers**: `platform` is not limited to the predefined enum; any lowercase identifier is accepted + +--- + +## Related Docs + +- [ChatLab API](./chatlab-api.md) — Query, export, and system endpoints +- [Pull Remote Data Source Protocol](./chatlab-pull.md) — Third-party exposes endpoints; ChatLab pulls +- [ChatLab Standard Format Specification](./chatlab-format.md) — Data interchange format definition diff --git a/docs/en/standard/chatlab-pull.md b/docs/en/standard/chatlab-pull.md new file mode 100644 index 000000000..8a1cba12d --- /dev/null +++ b/docs/en/standard/chatlab-pull.md @@ -0,0 +1,351 @@ +--- +outline: deep +--- + +# Pull Remote Data Source Protocol + +> v1 + +This document defines the protocol for third-party data sources to expose standard HTTP endpoints that ChatLab pulls from. This is the **recommended integration approach** for the ChatLab ecosystem. + +::: tip Two Import Modes + +- **[Push mode](./chatlab-import.md)**: The external system actively pushes data to ChatLab's import endpoint. Suitable for script integrations and one-time file imports. +- **Pull mode** (this document): A third-party exposes standard HTTP endpoints and ChatLab pulls data on demand. **The recommended integration approach.** + +::: + +## Why Pull is Recommended + +- Third-party tools are data producers — exposing data is their natural role. ChatLab is the consumer/analyzer — pulling data is its natural role. +- Users only need to enter a data source URL in the ChatLab UI to browse, select, and sync — the entire operation happens within ChatLab. +- Push mode requires third parties to implement HTTP client logic (batch management, retries, cursor maintenance), which has a higher implementation cost. +- The Pull protocol defines a **general data exposure standard**, not just for ChatLab — any compatible tool can integrate with it. + +**Suitable scenarios:** + +- The external collector runs on a remote device and only needs to expose an HTTP endpoint +- Users want to browse available conversations in the ChatLab UI, select what to import, and click "Sync Now" +- Long-running scenarios that need scheduled automatic incremental sync + +--- + +## Overview + +The Pull mode workflow has three phases: + +``` +1. Discovery: ChatLab retrieves the list of all available conversations from the data source +2. Pull: After the user selects conversations, ChatLab fetches the message history +3. Sync: Scheduled incremental pulls for new messages (optional SSE real-time notifications for lower latency) +``` + +Third-party data sources only need to implement the standard HTTP endpoints defined here. ChatLab (and any future compatible tools) will automatically handle discovery, full pull, and incremental sync. + +--- + +## Phase 1: Discover Available Conversations + +After connecting to a remote data source, ChatLab first fetches the list of all pullable conversations. + +### GET /sessions + +``` +GET {baseUrl}/sessions +Authorization: Bearer {token} ← only included if a token is configured +Accept: application/json +``` + +**Optional parameters:** + +| Parameter | Type | Description | +| --- | --- | --- | +| `keyword` | string | Fuzzy search by conversation name. Search semantics are defined server-side; fuzzy match on `name` is recommended, optionally extending to `id`. | +| `limit` | number | Maximum number of results. If omitted, returns all results; if the server supports pagination, a reasonable cap is recommended. | +| `cursor` | string | Pagination cursor. Only used when the server supports paginated discovery. Must restart from the first page when `keyword` changes. | + +**Response:** + +```json +{ + "sessions": [ + { + "id": "112233445566", + "name": "Product Discussion", + "platform": "whatsapp", + "type": "group", + "messageCount": 58000, + "memberCount": 86, + "lastMessageAt": 1711468800 + }, + { + "id": "user_a", + "name": "Alice", + "platform": "whatsapp", + "type": "private", + "messageCount": 1200, + "memberCount": 2, + "lastMessageAt": 1711465200 + } + ], + "page": { + "hasMore": true, + "nextCursor": "eyJsYXN0TWVzc2FnZUF0IjoxNzExNDY1MjAwLCJpZCI6Ind4aWRfZnJpZW5kX2EifQ==" + } +} +``` + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | string | Yes | Unique conversation identifier in the data source | +| `name` | string | Yes | Conversation name (group name or contact name) | +| `platform` | string | Yes | Platform identifier (same as Push mode) | +| `type` | string | Yes | `group` or `private` | +| `messageCount` | number | No | Total message count (shown in ChatLab UI as an estimate) | +| `memberCount` | number | No | Member count | +| `lastMessageAt` | number | No | Latest message timestamp | + +`page` is an **optional enhancement field**: + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `hasMore` | boolean | No | Whether more pages exist. Only returned when the server supports paginated discovery. | +| `nextCursor` | string | No | Cursor for the next page. Should be returned when `hasMore=true`; the client passes it back verbatim. | + +**Compatibility rules:** + +- Older servers can continue returning only `{ "sessions": [...] }` without `page`. +- When ChatLab receives a response without a `page` field, it treats the result as a complete single-page response. +- When `page` is present, ChatLab can offer manual "load more" or auto-continue, depending on the product interaction. +- ChatLab currently recommends manual "load more" in the UI, advancing via `hasMore / nextCursor`. + +**Pagination consistency recommendations:** + +- Servers should maintain stable page order; a fixed sort (e.g. `lastMessageAt desc, id asc`) is recommended. +- `cursor` must be bound to the current query; whenever `keyword` changes, the old `cursor` should be considered invalid. +- Avoid `offset`-based pagination on the `/sessions` discovery endpoint to prevent duplicates or gaps when the list changes. + +ChatLab displays this list in the UI and the user selects which conversations to import. + +--- + +## Phase 2: Pull Conversation Data + +After the user selects a conversation, ChatLab fetches its data. + +### GET /sessions/:id/messages + +``` +GET {baseUrl}/sessions/{sessionId}/messages?format=chatlab&since={timestamp} +Authorization: Bearer {token} +Accept: application/json +``` + +| Parameter | Required | Description | +| --- | --- | --- | +| `sessionId` | Yes | Conversation `id` from Phase 1 | +| `format` | Yes | Fixed as `chatlab`; requests ChatLab standard format from the data source | +| `since` | No | Unix timestamp (seconds). Omitted or `0` = full pull; greater than 0 = incremental pull | +| `limit` | No | Maximum messages per response, for pagination | + +::: tip Future Evolution +A future version may support `Accept: application/x-ndjson` for NDJSON streaming responses. Current version uses JSON only. +::: + +### Data Carrying Rules + +- **Initial full pull** (`since` is absent or 0): **Must** include `chatlab` + `meta` + `members` + `messages` +- **Incremental sync** (`since > 0`): **Must** include `messages`. `meta` / `members` should **only be included when they have actually changed**; omit them otherwise to avoid overwriting current state with historical snapshots +- Return an empty `messages` array when there is no new data + +::: tip Data Preparation +If the data source needs time to prepare data for a `since=0` request (e.g. loading from disk, building indexes), it may return an empty `messages` + `hasMore: false`. ChatLab will retry automatically (up to 3 times with increasing intervals) while waiting for the data source to be ready. +::: + +### Response Format + +The response is standard [ChatLab Format](./chatlab-format.md) (JSON or JSONL), plus a `sync` metadata block. + +```json +{ + "chatlab": { "version": "0.0.2", "exportedAt": 1711468800 }, + "meta": { "name": "Product Discussion", "platform": "whatsapp", "type": "group" }, + "members": [ ... ], + "messages": [ ... ], + "sync": { + "hasMore": true, + "nextSince": 1711468800 + } +} +``` + +### sync Metadata + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `hasMore` | boolean | **Yes** | Whether more data exists. When `true`, ChatLab automatically continues pulling. | +| `nextSince` | number | **Yes** | Suggested `since` value for the next request (typically the timestamp of the last message in this batch). | + +ChatLab's pagination is driven entirely by `hasMore` + `nextSince`. After returning a batch, the data source sets `nextSince` to the last message's timestamp; ChatLab passes that value as `since` on the next request. ChatLab's built-in deduplication handles any overlap at timestamp boundaries correctly. + +::: details Reserved Protocol Fields (not used in current version) +The following fields are reserved in the protocol. ChatLab does not currently use them but may enable them in future versions: + +| Field | Type | Description | +| --- | --- | --- | +| `nextOffset` | number | Pagination offset, used with the `offset` parameter | +| `watermark` | number | Snapshot upper-bound timestamp for pagination consistency | + +Data sources do not need to implement these fields. ChatLab's deduplication (based on `platformMessageId` or content hash) already ensures data integrity. +::: + +**sync Block Requirements:** + +| Data Source Behavior | sync Block | Notes | +| --- | --- | --- | +| Returns all data in one response (no pagination) | Optional | ChatLab treats `messages` as the complete result | +| Supports `limit`-based pagination | **Required** | Must include at least `hasMore` + `nextSince` | + +::: warning +If a data source supports pagination but does not return a `sync` block, ChatLab does not guarantee automatic continuation — only the first response will be processed. +::: + +### Batch Pull Strategy + +For large histories (e.g. tens of thousands of messages), the recommended batching approach: + +**Timestamp-chained batching** (recommended): Use `since` + `limit` to pull in batches. The data source returns the next request's start timestamp via `sync.nextSince`, and ChatLab automatically continues until `hasMore=false`. + +``` +Page 1: GET /sessions/:id/messages?format=chatlab&since=0&limit=1000 + → Returns 1000 messages, sync: { hasMore: true, nextSince: 1711400000 } + +Page 2: GET /sessions/:id/messages?format=chatlab&since=1711400000&limit=1000 + → Returns 1000 messages, sync: { hasMore: true, nextSince: 1711440000 } + +Page N: ... + → Returns 500 messages, sync: { hasMore: false, nextSince: 1711468800 } +``` + +ChatLab's built-in deduplication ensures no duplicate writes, even if there is message overlap at `nextSince` boundaries. + +--- + +## Phase 3: Scheduled Incremental Sync + +ChatLab periodically performs incremental pulls on subscribed conversations at the user-configured interval: + +``` +GET {baseUrl}/sessions/{sessionId}/messages?format=chatlab&since={lastPullAt} +``` + +The remote data source returns incremental messages since `lastPullAt`. ChatLab processes them through the internal import pipeline (deduplication, meta/members update, FTS indexing — all the same as Push mode). + +--- + +## Optional: SSE Real-Time Notifications + +In addition to scheduled polling, a remote data source may optionally implement an SSE (Server-Sent Events) endpoint to **notify ChatLab that new data is available**. + +::: warning Important +SSE is a notification channel only, not the primary data sync channel. ChatLab does not assume SSE events are reliably delivered (network drops and process restarts can cause missed events). Final data consistency is always guaranteed by scheduled pulls. SSE reduces incremental sync latency from minutes to seconds. +::: + +### GET /push/messages + +``` +GET {baseUrl}/push/messages +Authorization: Bearer {token} +Accept: text/event-stream +``` + +**Event format:** + +``` +event: message.new +data: {"eventId":"evt_001","sessionId":"112233445566","timestamp":1711468800} +``` + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `eventId` | string | Yes | Unique event ID, used by ChatLab to deduplicate already-processed notifications | +| `sessionId` | string | Yes | ID of the conversation that has new messages | +| `timestamp` | number | Yes | Timestamp of the new message | +| `platformMessageId` | string | No | Platform-native message ID (if available) | + +When ChatLab receives an SSE event, it **triggers one incremental pull for that session** (calling `GET /sessions/:id/messages?format=chatlab&since={lastPullAt}`), rather than writing the event data directly to storage. + +--- + +## Authentication + +Remote data sources may optionally require authentication. If needed, use `Authorization: Bearer {token}`. + +::: tip SSE Authentication +Some data sources additionally support the `?access_token=TOKEN` query parameter for passing tokens (recommended for SSE long connections, since the `EventSource` API does not support custom headers). ChatLab also supports the query parameter approach when connecting to SSE. +::: + +--- + +## Implementation Guide + +### Minimal Implementation (2 endpoints) + +Only two endpoints are needed to integrate with ChatLab: + +| Endpoint | Description | +| --- | --- | +| `GET /sessions` | Returns the conversation list | +| `GET /sessions/:id/messages?format=chatlab&since=X` | Returns data in ChatLab format | + +A minimal implementation does not require pagination, SSE, or a complex `sync` block. ChatLab treats the response's `messages` as the complete dataset. + +### Enhanced Implementation + +| Capability | Description | +| --- | --- | +| `GET /push/messages` | SSE real-time notifications (wakes up a pull; does not transmit data directly) | +| `limit` + `sync` pagination | Batched pulling for large histories via `hasMore` + `nextSince` | + +### Data Format + +All data responses must conform to the [ChatLab Standard Format Specification](./chatlab-format.md) (JSON or JSONL), including the four standard blocks: `chatlab`, `meta`, `members`, and `messages`. + +### Media Files + +If messages in the data source contain media references, `attachments` fields (`filePath` or `dataUri`) may point to media endpoints on the data source. ChatLab currently treats this as a reserved protocol field; future versions will support pulling media files from the data source. + +--- + +## Example Scenario + +A collector running on a phone continuously captures WeChat messages and exposes `GET /sessions` and `GET /sessions/:id/messages`. The user operates in ChatLab: + +``` +1. Add a remote data source in ChatLab settings (enter collector URL + optional token) + +2. ChatLab calls GET {baseUrl}/sessions + → Displays 86 groups and 200 private chats + +3. User selects 5 groups to import + +4. ChatLab immediately performs a full pull: + GET {baseUrl}/sessions/{id}/messages?format=chatlab&since=0 + → If sync.hasMore=true, auto-continues until complete + +5. Hourly incremental sync thereafter: + GET {baseUrl}/sessions/{id}/messages?format=chatlab&since={lastPullAt} + +6. If the collector implements SSE: + On receiving a message.new event → immediately trigger an incremental pull (no waiting for the timer) + +7. User can click "Sync Now" in the ChatLab UI at any time +``` + +--- + +## Related Docs + +- [ChatLab API](./chatlab-api.md) — Query, export, and system endpoints +- [Push Import Protocol](./chatlab-import.md) — External system actively pushes data to ChatLab +- [ChatLab Standard Format Specification](./chatlab-format.md) — Data interchange format definition diff --git a/docs/en/usage/how-to-config-ai.md b/docs/en/usage/how-to-config-ai.md new file mode 100644 index 000000000..c9761e0b2 --- /dev/null +++ b/docs/en/usage/how-to-config-ai.md @@ -0,0 +1,31 @@ +--- +outline: deep +--- + +# How to Configure AI Models + +## Online AI Models + +Here we use DeepSeek as an example. For other models, please search for their configuration methods: + +1. Visit [DeepSeek official website](https://www.deepseek.com/), select API Platform, register and log in + +![](/en/img/ai-guide/1.png) + +2. Select "Recharge" on the left side and add credit (we recommend starting with a small amount - it will last a long time) + +![](/en/img/ai-guide/2.png) + +3. Select "API Keys" on the left side, click "Create API key", enter any title, then copy the API Key + +![](/en/img/ai-guide/3.png) + +4. Open ChatLab, click Settings in the bottom right corner, then "Model Configuration" > "Add New Configuration" + +Paste the API Key you copied into the API Keys field, click Verify to confirm it works, then click Add in the bottom right corner to start using AI features + +![](/en/img/ai-guide/4.png) + +5. In the usage information on the left, you can check your credit balance and consumption + +![](/en/img/ai-guide/5.png) diff --git a/docs/en/usage/how-to-export.md b/docs/en/usage/how-to-export.md new file mode 100644 index 000000000..4186dae97 --- /dev/null +++ b/docs/en/usage/how-to-export.md @@ -0,0 +1,116 @@ +--- +outline: deep +--- + +# Export Chat Records Guide + +ChatLab focuses on analyzing exported data - we don't provide data extraction features. You'll need to first use official features or third-party tools from the open-source community to export your chat records, then import them into ChatLab for analysis. + +Tips: Welcome to join the [ChatLab Community](https://chatlab.fun/other/community) to discuss issues and share feedback. + +## WhatsApp + +For WhatsApp, we currently support the official "Export Chat" feature. + +We currently support exports in English and Chinese languages. For other language needs, please contact the developer. + +- **Export Method**: + 1. Open WhatsApp and go to the conversation you want to export. + 2. Tap the contact name at the top -> Export Chat. + 3. Select "Without Media". +- **Format**: Extract the `txt` file from the exported `.zip` package and drag the `txt` file into ChatLab. + +## Discord + +For Discord, we currently support the JSON format exported by **DiscordChatExporter**. + +- **Project URL**: [https://github.com/Tyrrrz/DiscordChatExporter](https://github.com/Tyrrrz/DiscordChatExporter) +- **Supported Platforms**: Windows / macOS / Linux +- **Usage Guide**: Refer to the project README. +- **Tip**: Please make sure to select **JSON** as the export format for ChatLab to parse correctly. + +## Instagram + +For Instagram, we currently support the official export feature. + +- **Export Method**: + 1. Open the Instagram app or web version, go to "Settings". + 2. Click "Accounts Center" -> "Your information and permissions" -> "Download your information". + 3. Select "Some of your information", then check "Messages". + 4. Select format as **JSON**, and date range as "All time". + 5. Click "Submit request" and wait for Instagram to process, then download. +- **Format**: After extracting the downloaded archive, find the `message_1.json` file in the `your_instagram_activity/messages/inbox/` directory for the corresponding chat, and drag it into ChatLab. +- **Tip**: If the conversation has a lot of content, there may be multiple `message_*.json` files. We recommend importing them one by one. + +## iMessage + +For iMessage, **imessage-chatlab** now supports exporting to the ChatLab standard JSON format. + +- **Project URL**: [https://github.com/gamesme/imessage-chatlab](https://github.com/gamesme/imessage-chatlab) +- **Supported Platform**: macOS +- **Installation**: If you have the Rust toolchain installed, run `cargo install imessage-chatlab`. You can also install from source by following the project README. +- **Export Method**: Refer to the project README. For example, run `imessage-chatlab -c clone -o ~/imessage_chatlab_export` to export local iMessage data. +- **Format**: The tool exports one ChatLab standard JSON file per conversation. Drag the exported `.json` files into ChatLab. +- **Tip**: This tool reads the local macOS Messages database. Please read the project documentation carefully and make sure you have legal permission to access and analyze the relevant chat records. + +## LINE + +For LINE, we currently support the official chat export feature. + +- **Export Method**: + 1. Open LINE and go to the conversation you want to export. + 2. On mobile: tap the menu in the top-right corner of the chat -> Settings -> Export chat history. + 3. On desktop (Windows / macOS): open Chats, enter the target conversation, then click the top-right menu -> Save chat. + 4. Save or share the exported text file. +- **Format**: Drag the exported `.txt` file directly into ChatLab. +- **Tip**: According to LINE's official help, the desktop app only saves messages that are currently loaded and visible in the chat window. + +## Telegram + +For Telegram, we currently support the official export feature provided by Telegram Desktop. + +- **Export Method**: + 1. Open the latest version of Telegram Desktop. + 2. Go to `Settings` -> `Advanced` -> `Export Telegram data`. + 3. In the export panel, select the chats you want to export. + 4. Choose **Machine-readable JSON** as the format. If you also want a readable copy, you can choose **Both**, but JSON must be included. + 5. Choose the export folder and wait for Telegram to finish processing. +- **Format**: Import the main JSON file from the export folder (usually `result.json`) into ChatLab. +- **Tip**: Telegram's official export entry is on desktop. For some accounts, the first export request may be delayed for security reasons and must be completed later on the same device. + +## Google Chat + +For Google Chat, we currently support the ZIP archive exported by Google Takeout. + +- **Export Method**: + 1. Go to [Google Takeout](https://takeout.google.com/) and sign in with your Google account. + 2. Click "Deselect all", then check only **Google Chat** to reduce the export size. + 3. Select **.zip** as the file type (**.tgz is not currently supported**). + 4. Click "Create export" and wait for Google to process it — you'll receive an email when the download is ready. +- **Format**: Drag the downloaded `.zip` file directly into ChatLab. ChatLab will scan all conversations inside the archive and show a selection list. Check the conversations you want and import them one by one. +- **Tips**: + - Only ZIP format is supported. If Google Takeout offers .tgz, re-export and select .zip instead. + - The first export request may take several hours to process. + - Attachments (images, files, etc.) are not imported along with the chat history in the current version. + +## Q&A: Can I analyze chat records from other chat applications? + +For various chat analysis needs, here's a unified response: + +ChatLab's function is to **analyze exported chat records in fixed text formats**, but the prerequisite is that **you have already exported chat records through legal and compliant channels**. + +We **do not provide any decryption, packet capture, or export tools and scripts**. We only support compatibility with exported chat record formats. As long as you can provide anonymized chat record text samples, we can try to support analysis. + +If you have some technical background, you can try using **AI-assisted conversion** to convert your data to the standard format. For details, please check the [AI Conversion Guide](../standard/ai-converter.md). + +Additionally, if you're a developer and have already supported chat record export for other chat applications, you're welcome to [make it compatible with ChatLab format](../standard/chatlab-format.md), and we'll add your GitHub link here. + +## ⚠️ Legal & Security Disclaimer + +Before attempting to analyze data from the above applications, please be aware: + +- **Legal Authorization Principle**: You may only process chat records that **you personally participated in**. If privacy of others is involved, please ensure you have obtained informed consent from the relevant parties. +- **Prohibited Illegal Use**: It is strictly forbidden to use this software for stealing, monitoring, or analyzing unauthorized private information of others, or for any behavior that infringes on others' rights. +- **Compliance Self-responsibility**: Obtaining data from third-party platforms is your personal behavior. If your analysis violates the original data source platform's terms of service resulting in account restrictions or other consequences, ChatLab assumes no responsibility. +- **No Commercial Use**: It is strictly forbidden for any individual or organization to use this software or analysis results for any form of commercial profit. +- **Result Accuracy**: Analysis results generated by the software may contain errors or "hallucinations" and are for technical reference only. They should not be used as legal evidence or decision-making basis. diff --git a/docs/en/usage/how-to-import.md b/docs/en/usage/how-to-import.md new file mode 100644 index 000000000..be10f93b0 --- /dev/null +++ b/docs/en/usage/how-to-import.md @@ -0,0 +1,18 @@ +--- +outline: deep +--- + +# Import Chat Records Guide + +After completing the export, you simply need to: + +1. Drag the exported **data file** directly into the upload area on ChatLab's homepage. +2. Wait for ChatLab to finish parsing. + +## Bug Troubleshooting + +If the import fails, you can quickly troubleshoot the issue through logs: + +Go to "Settings" in the bottom left corner > "Basic Settings" > "Log Files", and open that directory. Inside, there's an "import" folder containing all import log records. + +If you can't understand the logs, you can submit an issue on GitHub. diff --git a/docs/en/usage/index.md b/docs/en/usage/index.md new file mode 100644 index 000000000..6b7cb8ef9 --- /dev/null +++ b/docs/en/usage/index.md @@ -0,0 +1,5 @@ +--- +outline: deep +--- + +# USAGE diff --git a/docs/en/usage/quick-start.md b/docs/en/usage/quick-start.md new file mode 100644 index 000000000..b93267274 --- /dev/null +++ b/docs/en/usage/quick-start.md @@ -0,0 +1,72 @@ +--- +outline: deep +--- + +# Quick Start + +## Step 1: Install ChatLab + +There are two ways to install ChatLab: + +**Option 1: Download from the website** + +Go to the [ChatLab website](https://chatlab.fun) or [GitHub Releases](https://github.com/ChatLab/ChatLab/releases) to download the installer for your operating system, then run it. + +**Option 2: CLI** + +```bash +npm i chatlab-cli -g +``` + +Requires Node.js ≥ 20. + +After installation, start ChatLab with: + +```bash +chatlab start # Start API + Web UI and open in browser +chatlab start --no-open # Start API + Web UI without auto-opening the browser +chatlab start --headless # API-only mode, no Web UI (for scripts / AI Agents) +``` + +Common options: `--port ` (default 3110), `--host
`, `--token `. + +To keep ChatLab running persistently (auto-start on login), add the `--daemon` flag: + +```bash +chatlab start --daemon # Install as a system service (macOS / Linux) +chatlab status # Check service status +chatlab stop # Stop and remove the service +``` + +::: tip +`clb` is a shorthand alias for `chatlab` — both are equivalent. +::: + +## Step 2: Import chat records + +ChatLab supports three ways to bring in your chat records: + +| Method | When to use | +|--------|-------------| +| **File import** | Drag an exported file straight into the ChatLab home screen — the simplest option for most users | +| **Auto sync** | Connect an external data source and let ChatLab sync new messages on a schedule | +| **API push** | Open ChatLab's local API so external tools or scripts can push records in directly | + +### Regular users + +Use **file import** — you need to: + +1. Export your chat records using a third-party tool. See [Export Chat Records](/usage/how-to-export) for details. +2. Drag the exported files into the ChatLab homepage. If you run into issues, see [Import Chat Records Guide](/usage/how-to-import). + +### Developers + +If you're a developer looking to integrate **auto sync** or **API push**, see: + +- [ChatLab Format](/standard/chatlab-format) — understand the data format specification + +## Step 3: Configure AI + +ChatLab comes with a built-in AI Agent. Connect a model and start asking questions about your chat history in natural language. + +See [How to Configure AI](/usage/how-to-config-ai) for detailed setup steps. diff --git a/docs/en/usage/troubleshooting.md b/docs/en/usage/troubleshooting.md new file mode 100644 index 000000000..dd8f9edbe --- /dev/null +++ b/docs/en/usage/troubleshooting.md @@ -0,0 +1,81 @@ +--- +outline: deep +--- + +# Troubleshooting Guide + +This document helps users and developers troubleshoot issues encountered when using ChatLab. + +## Log Files + +**Access log files**: Bottom left corner **"Settings" > "Storage Management" > "Log Files" > Open Directory** + +Logs are stored in the `Documents/ChatLab/logs/` directory: + +``` +ChatLab/logs/ +├── app.log # Main program log +├── ai/ # AI-related logs +│ └── ai_YYYY-MM-DD_HH-mm.log +└── import/ # Import logs + └── import_{sessionId}_{timestamp}.log +``` + +### Log File Description + +| Directory/File | Contents | +| -------------- | ------------------------------------------------- | +| `app.log` | Main program log: file parsing, database ops, IPC | +| `ai/*.log` | AI logs: LLM calls, Agent execution, tool calls | +| `import/*.log` | Import performance: speed, memory usage, timing | + +## Common Issues + +### 1. Import Failed + +**Symptoms**: Parsing error after dragging in a file + +**Troubleshooting Steps**: + +1. Confirm the file format is supported (.json / .jsonl / .txt) +2. Check if the file is corrupted (open with a text editor) +3. Check `[Parser]` related errors in the log files + +### 2. AI Features Not Responding + +**Symptoms**: No response after sending a message in AI Lab + +**Troubleshooting Steps**: + +1. Check if API Key is configured (Settings > AI Settings) +2. Click "Verify" to confirm API connection is working +3. Check `[LLM]` or `[Agent]` related errors in the log files + +**Common Causes**: + +- Invalid API Key or insufficient balance +- API provider rate limiting + +### 3. Database Error + +**Symptoms**: Error when opening a session + +**Troubleshooting Steps**: + +1. Check `[Database]` related errors in the log files +2. Verify the database file exists + +## Reporting Issues + +If the above methods don't solve your problem, please: + +1. Collect log files +2. Describe the steps to reproduce the issue +3. Submit an Issue on GitHub + +**When submitting an Issue, please include**: + +- Operating system and version +- ChatLab version +- Problem description and reproduction steps +- Relevant log snippets (remember to anonymize sensitive data) diff --git a/docs/env.d.ts b/docs/env.d.ts new file mode 100644 index 000000000..286f0d2d1 --- /dev/null +++ b/docs/env.d.ts @@ -0,0 +1,4 @@ +declare module '*.md?raw' { + const content: string + export default content +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 000000000..7b81b6d66 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,15 @@ +{ + "name": "@openchatlab/docs", + "version": "0.21.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vitepress dev .", + "build": "vitepress build .", + "preview": "vitepress preview ." + }, + "devDependencies": { + "vitepress": "^1.6.4", + "vue": "^3.5.25" + } +} diff --git a/docs/public/assets/logo.svg b/docs/public/assets/logo.svg new file mode 100644 index 000000000..1a89ebf19 --- /dev/null +++ b/docs/public/assets/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/docs/public/cn/img/ai-guide/1.png b/docs/public/cn/img/ai-guide/1.png new file mode 100644 index 000000000..936d0711d Binary files /dev/null and b/docs/public/cn/img/ai-guide/1.png differ diff --git a/docs/public/cn/img/ai-guide/2.png b/docs/public/cn/img/ai-guide/2.png new file mode 100644 index 000000000..e233a667b Binary files /dev/null and b/docs/public/cn/img/ai-guide/2.png differ diff --git a/docs/public/cn/img/ai-guide/3.png b/docs/public/cn/img/ai-guide/3.png new file mode 100644 index 000000000..ed89929da Binary files /dev/null and b/docs/public/cn/img/ai-guide/3.png differ diff --git a/docs/public/cn/img/ai-guide/4.png b/docs/public/cn/img/ai-guide/4.png new file mode 100644 index 000000000..d4a4a7be2 Binary files /dev/null and b/docs/public/cn/img/ai-guide/4.png differ diff --git a/docs/public/cn/img/ai-guide/5.png b/docs/public/cn/img/ai-guide/5.png new file mode 100644 index 000000000..7733fabd0 Binary files /dev/null and b/docs/public/cn/img/ai-guide/5.png differ diff --git a/docs/public/en/img/ai-guide/1.png b/docs/public/en/img/ai-guide/1.png new file mode 100644 index 000000000..936d0711d Binary files /dev/null and b/docs/public/en/img/ai-guide/1.png differ diff --git a/docs/public/en/img/ai-guide/2.png b/docs/public/en/img/ai-guide/2.png new file mode 100644 index 000000000..e233a667b Binary files /dev/null and b/docs/public/en/img/ai-guide/2.png differ diff --git a/docs/public/en/img/ai-guide/3.png b/docs/public/en/img/ai-guide/3.png new file mode 100644 index 000000000..ed89929da Binary files /dev/null and b/docs/public/en/img/ai-guide/3.png differ diff --git a/docs/public/en/img/ai-guide/4.png b/docs/public/en/img/ai-guide/4.png new file mode 100644 index 000000000..d4a4a7be2 Binary files /dev/null and b/docs/public/en/img/ai-guide/4.png differ diff --git a/docs/public/en/img/ai-guide/5.png b/docs/public/en/img/ai-guide/5.png new file mode 100644 index 000000000..7733fabd0 Binary files /dev/null and b/docs/public/en/img/ai-guide/5.png differ diff --git a/docs/tw/index.md b/docs/tw/index.md new file mode 100644 index 000000000..c6c30f12b --- /dev/null +++ b/docs/tw/index.md @@ -0,0 +1,26 @@ +--- +layout: doc +title: ChatLab 文件 +--- + +# ChatLab 文件 + +歡迎閱讀 ChatLab 文件。可透過側邊欄瀏覽主題,或直接跳轉: + +- [開始使用](/tw/intro) — 了解 ChatLab 是什麼以及基本用法。 +- [ChatLab Format](/tw/standard/chatlab-format) — 了解 ChatLab 的資料交換格式。 + +## 使用 + +- [快速開始](/tw/usage/quick-start) — 匯入第一份聊天記錄,了解 ChatLab 的基礎使用流程。 +- [匯出與匯入](/tw/usage/how-to-export) — 準備來自不同平台的聊天記錄,並匯入 ChatLab 分析。 +- [配置 AI](/tw/usage/how-to-config-ai) — 連接 AI 模型,透過 Agent 工具探索聊天歷史。 +- [故障排除](/tw/usage/troubleshooting) — 處理匯入、解析和 AI 配置中的常見問題。 + +## 標準 + +- [標準與 API](/tw/standard/chatlab-api) — 使用 ChatLab Format 和本機 REST API 對接外部工具。 + +## 更多 + +- [官網與路線圖](https://chatlab.fun/tw/) — 前往官網下載 ChatLab,查看路線圖和社群入口。 diff --git a/docs/tw/intro.md b/docs/tw/intro.md new file mode 100644 index 000000000..19d7a846c --- /dev/null +++ b/docs/tw/intro.md @@ -0,0 +1,32 @@ +--- +outline: deep +--- + +# ChatLab 介紹 + +ChatLab 是一個免費、開源、本地化的聊天記錄分析軟體。 + +在數位時代,聊天記錄早已不再是簡單的文字檔,它是長達十年的社交關係脈絡,是親人珍貴的語音片段,更是我們外掛在數位世界的情感大腦。 + +ChatLab 的誕生,就是為了**讓每位使用者都能安全地分析、回顧屬於自己的社交記憶。** + +## 如何使用 + +想要分析你的聊天記錄,你需要完成以下兩步: + +1. 匯出聊天記錄 +2. 匯入聊天記錄 + +由於每個聊天軟體的匯出方式不同,具體步驟請參照 [匯出聊天記錄](./usage/how-to-export.md)。 + +## 下一步 + + + +遇到了匯入、AI 對話錯誤等問題,請參照 [常見問題](./usage/faq.md) 解決。 + +如果您匯出的聊天記錄格式不在目前支援範圍,請參照 [AI 輔助轉換指南](./standard/ai-converter.md)。 + +如果您是開發者,並支援了其他聊天應用的聊天記錄匯出,歡迎相容 [聊天資料交換標準化格式](./standard/chatlab-format.md)。 + +如果還有其他問題,歡迎加入社群回饋與交流:[加入社群](https://chatlab.fun/tw/other/community) diff --git a/docs/tw/standard/ai-converter.md b/docs/tw/standard/ai-converter.md new file mode 100644 index 000000000..5f2664494 --- /dev/null +++ b/docs/tw/standard/ai-converter.md @@ -0,0 +1,82 @@ +--- +outline: deep +--- + +# AI 輔助轉換指南 + +如果你的聊天記錄格式(如 CSV, HTML, TXT 或其他資料庫匯出)目前不被 ChatLab 直接支援,你可以利用 AI(如 ChatGPT, Claude, DeepSeek 等)快速編寫一個轉換腳本,將你的資料轉換為 ChatLab 標準格式。 + +## 準備工作 + +1. **查看標準規範**:[ChatLab 標準格式規範 v0.0.2](./chatlab-format.md) +2. **準備資料**:準備好你匯出的原始聊天記錄檔案(如果是線上服務,建議僅提供幾百條脫敏後的樣本即可)。 + +## 選擇目標格式 + +請根據你的資料量大小,選擇合適的提示詞。 + +### 場景一:中小規模資料 (推薦) + +- **目標格式**:JSON (`.json`) +- **適用場景**:記錄數 < 100 萬條,檔案體積 < 100MB。 +- **特點**:結構清晰,相容性最好。 + +#### 複製 JSON 轉換提示詞 + +```markdown +**角色設定**:你是一個精通資料處理和腳本編寫的專家。 + +**任務目標**:請根據我提供的【ChatLab 標準格式規範】(chatlab-format.md),編寫一個腳本,將我上傳的【原始聊天記錄】轉換為符合該規範的 **JSON 格式**。 + +**執行要求**: + +1. **分析結構**:分析原始聊天記錄的文字規律或資料結構。 +2. **欄位映射**: + - 將原始欄位映射到 ChatLab 標準欄位(`timestamp`, `sender`, `content`, `type` 等)。 + - 如果原始資料缺少 `sender` (使用者 ID),請根據 `accountName` (使用者名) 自動產生一個唯一的雜湊值或虛擬 ID。 + - `type` 預設為 0 (文字)。如果能從內容中識別出圖片、語音等類型,請嘗試映射。 +3. **腳本產生**: + - 請編寫一個**完整的、可執行的腳本**(推薦 Python 或 Node.js)。 + - **輸出結構**:腳本應建構一個包含 `chatlab`, `meta`, `members`, `messages` 的完整 JSON 物件,並一次性寫入檔案。 + - 腳本需包含必要的錯誤處理,並列印進度。 +4. **結果驗證**: + - 請確保產生的 JSON 結構嚴格符合 `chatlab-format.md` 中的定義。 + +**輸出**:請直接提供程式碼,並簡要說明如何執行該腳本。 +``` + +### 場景二:超大規模資料 + +- **目標格式**:JSONL (`.jsonl`) +- **適用場景**:記錄數 > 100 萬條,或檔案體積巨大。 +- **特點**:串流讀寫,記憶體佔用極低,不會因為資料量大而崩潰。 + +#### 複製 JSONL 轉換提示詞 + +```markdown +**角色設定**:你是一個精通大資料處理和串流計算的專家。 + +**任務目標**:請根據我提供的【ChatLab 標準格式規範】(chatlab-format.md),編寫一個腳本,將我上傳的【原始聊天記錄】轉換為符合該規範的 **JSONL (JSON Lines) 格式**。 + +**執行要求**: + +1. **分析結構**:分析原始聊天記錄的文字規律。 +2. **串流處理**: + - **必須採用串流讀寫**(Line-by-Line)的方式,不要一次性將所有資料載入到記憶體中。 + - 逐行讀取原始檔案,逐行寫入目標檔案。 +3. **JSONL 結構要求**: + - **第一行**:必須寫入 `_type: "header"` 行(包含 `chatlab` 和 `meta` 資訊)。 + - **成員資訊**:如果可能,先掃描一遍或在處理過程中收集成員資訊,寫入 `_type: "member"` 行。 + - **訊息記錄**:每一條聊天記錄寫入一行 `_type: "message"`。 +4. **腳本產生**: + - 請編寫一個**高效的 Python 腳本**。 + - 確保處理過程記憶體佔用恆定,適合處理 GB 級別的大檔案。 + +**輸出**:請直接提供程式碼,並簡要說明如何執行該腳本。 +``` + +## 後續步驟 + +1. **執行腳本**:在本機環境中執行 AI 產生的腳本。 +2. **檢查結果**:開啟產生的檔案,確認格式是否正確。 +3. **匯入 ChatLab**:將產生的檔案匯入 ChatLab 進行分析。 diff --git a/docs/tw/standard/chatlab-api.md b/docs/tw/standard/chatlab-api.md new file mode 100644 index 000000000..e2f9e4aa6 --- /dev/null +++ b/docs/tw/standard/chatlab-api.md @@ -0,0 +1,499 @@ +# ChatLab API 文件 + +ChatLab 提供本機 RESTful API 服務,允許外部工具、腳本和 MCP 等透過 HTTP 介面查詢聊天記錄、執行 SQL 查詢、匯入聊天資料。 + +## 快速開始 + +### 1. 啟用服務 + +打開 ChatLab → 設定 → ChatLab API → 開啟服務。 + +啟用後會自動產生 API Token,預設監聽埠號 `5200`。 + +### 2. 驗證服務狀態 + +```bash +curl http://127.0.0.1:5200/api/v1/status \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +回應範例: + +```json +{ + "success": true, + "data": { + "name": "ChatLab API", + "version": "1.0.0", + "uptime": 3600, + "sessionCount": 5 + }, + "meta": { + "timestamp": 1711468800, + "version": "0.0.2" + } +} +``` + +## 基本資訊 + +| 項目 | 說明 | +| -------- | ------------------------- | +| 基礎 URL | `http://127.0.0.1:5200` | +| API 前綴 | `/api/v1` | +| 認證方式 | Bearer Token | +| 資料格式 | JSON | +| 繫定位址 | `127.0.0.1`(僅本機存取) | + +### 認證 + +所有請求必須攜帶 `Authorization` 請求標頭: + +``` +Authorization: Bearer clb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +Token 可在 設定 → ChatLab API 頁面查看和重新產生。 + +### 統一回應格式 + +**成功回應:** + +```json +{ + "success": true, + "data": { ... }, + "meta": { + "timestamp": 1711468800, + "version": "0.0.2" + } +} +``` + +**錯誤回應:** + +```json +{ + "success": false, + "error": { + "code": "SESSION_NOT_FOUND", + "message": "Session not found: abc123" + } +} +``` + +--- + +## 端点列表 + +### 系统 + +| 方法 | 路径 | 说明 | +| ---- | ---------------- | -------------------------- | +| GET | `/api/v1/status` | 服务状态 | +| GET | `/api/v1/schema` | ChatLab Format JSON Schema | + +### 数据查询(导出) + +| 方法 | 路径 | 说明 | +| ---- | ------------------------------------- | ------------------------ | +| GET | `/api/v1/sessions` | 获取所有会话列表 | +| GET | `/api/v1/sessions/:id` | 获取单个会话详情 | +| GET | `/api/v1/sessions/:id/messages` | 查询消息(分页) | +| GET | `/api/v1/sessions/:id/members` | 获取成员列表 | +| GET | `/api/v1/sessions/:id/stats/overview` | 获取概览统计 | +| POST | `/api/v1/sessions/:id/sql` | 执行自定义 SQL(只读) | +| GET | `/api/v1/sessions/:id/export` | 导出 ChatLab Format JSON | + +### 数据导入 + +| 方法 | 路径 | 说明 | +| ---- | ----------------------------- | ------------------------ | +| POST | `/api/v1/import` | 导入聊天记录(新建会话) | +| POST | `/api/v1/sessions/:id/import` | 增量导入到指定会话 | + +--- + +## 端点详细说明 + +### GET /api/v1/status + +获取 API 服务的运行状态。 + +**响应:** + +| 字段 | 类型 | 说明 | +| -------------- | ------ | ------------------------- | +| `name` | string | 服务名称(`ChatLab API`) | +| `version` | string | ChatLab 应用版本 | +| `uptime` | number | 服务运行时间(秒) | +| `sessionCount` | number | 当前会话总数 | + +--- + +### GET /api/v1/schema + +获取 ChatLab Format 的 JSON Schema 定义,便于构建符合规范的导入数据。 + +--- + +### GET /api/v1/sessions + +获取所有已导入的会话列表。 + +**响应示例:** + +```json +{ + "success": true, + "data": [ + { + "id": "session_abc123", + "name": "技术交流群", + "platform": "qq", + "type": "group", + "messageCount": 58000, + "memberCount": 120 + } + ] +} +``` + +--- + +### GET /api/v1/sessions/:id + +获取单个会话的详细信息。 + +**路径参数:** + +| 参数 | 类型 | 说明 | +| ---- | ------ | ------- | +| `id` | string | 会话 ID | + +--- + +### GET /api/v1/sessions/:id/messages + +分页查询指定会话的消息列表,支持多种过滤条件。 + +**查询参数:** + +| 参数 | 类型 | 默认值 | 说明 | +| ----------- | ------ | ------ | ------------------------ | +| `page` | number | 1 | 页码 | +| `limit` | number | 100 | 每页条数(最大 1000) | +| `startTime` | number | - | 起始时间戳(秒级 Unix) | +| `endTime` | number | - | 结束时间戳(秒级 Unix) | +| `keyword` | string | - | 关键词搜索 | +| `senderId` | string | - | 按发送者 platformId 筛选 | +| `type` | number | - | 按消息类型筛选 | + +**请求示例:** + +```bash +curl "http://127.0.0.1:5200/api/v1/sessions/abc123/messages?page=1&limit=50&keyword=你好" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +**响应:** + +```json +{ + "success": true, + "data": { + "messages": [ + { + "senderPlatformId": "123456", + "senderName": "张三", + "timestamp": 1703001600, + "type": 0, + "content": "你好!" + } + ], + "total": 1500, + "page": 1, + "limit": 50, + "totalPages": 30 + } +} +``` + +--- + +### GET /api/v1/sessions/:id/members + +获取指定会话的所有成员列表。 + +--- + +### GET /api/v1/sessions/:id/stats/overview + +获取指定会话的概览统计信息。 + +**响应:** + +```json +{ + "success": true, + "data": { + "messageCount": 58000, + "memberCount": 120, + "timeRange": { + "start": 1609459200, + "end": 1703001600 + }, + "messageTypeDistribution": { + "0": 45000, + "1": 8000, + "5": 3000, + "80": 2000 + }, + "topMembers": [ + { + "platformId": "123456", + "name": "张三", + "messageCount": 5800, + "percentage": 10.0 + } + ] + } +} +``` + +| 字段 | 说明 | +| ------------------------- | -------------------------------------------------------------------------------- | +| `messageCount` | 总消息数 | +| `memberCount` | 成员数 | +| `timeRange` | 最早/最新消息时间戳(秒级 Unix) | +| `messageTypeDistribution` | 各消息类型的数量(key 为 [消息类型](./chatlab-format.md#消息类型对照表) 枚举值) | +| `topMembers` | 前 10 活跃成员(按消息数降序) | + +--- + +### POST /api/v1/sessions/:id/sql + +对指定会话的数据库执行只读 SQL 查询。仅允许 `SELECT` 语句。 + +**请求体:** + +```json +{ + "sql": "SELECT sender, COUNT(*) as count FROM messages GROUP BY sender ORDER BY count DESC LIMIT 10" +} +``` + +**响应:** + +```json +{ + "success": true, + "data": { + "columns": ["sender", "count"], + "rows": [ + ["123456", 5800], + ["789012", 3200] + ] + } +} +``` + +> 关于数据库表结构,请参考 ChatLab 内部文档或使用 `SELECT * FROM sqlite_master WHERE type='table'` 查询。 + +--- + +### GET /api/v1/sessions/:id/export + +导出完整会话数据,格式为 [ChatLab Format](./chatlab-format.md) JSON。 + +**限制:** 最多导出 **10 万条** 消息。如果会话消息数超过此限制,返回 `400 EXPORT_TOO_LARGE` 错误。超大会话建议使用 `/messages` 分页 API 逐页获取。 + +**响应:** + +```json +{ + "success": true, + "data": { + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800, + "generator": "ChatLab API" + }, + "meta": { + "name": "技术交流群", + "platform": "qq", + "type": "group" + }, + "members": [...], + "messages": [...] + } +} +``` + +--- + +### POST /api/v1/import + +将聊天记录导入 ChatLab,**创建新会话**。 + +#### 支持的 Content-Type + +| Content-Type | 格式 | 适用场景 | Body 限制 | +| ---------------------- | ------------------- | ------------------------------ | ---------- | +| `application/json` | ChatLab Format JSON | 中小数据(快速测试、脚本集成) | **50MB** | +| `application/x-ndjson` | ChatLab JSONL 格式 | 大规模数据(生产级集成) | **无限制** | + +#### JSON 模式示例 + +```bash +curl -X POST http://127.0.0.1:5200/api/v1/import \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1711468800 + }, + "meta": { + "name": "导入测试", + "platform": "qq", + "type": "group" + }, + "members": [ + { "platformId": "123", "accountName": "测试用户" } + ], + "messages": [ + { + "sender": "123", + "accountName": "测试用户", + "timestamp": 1711468800, + "type": 0, + "content": "Hello World" + } + ] + }' +``` + +#### JSONL 模式示例 + +```bash +cat data.jsonl | curl -X POST http://127.0.0.1:5200/api/v1/import \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/x-ndjson" \ + --data-binary @- +``` + +**响应:** + +```json +{ + "success": true, + "data": { + "mode": "new", + "sessionId": "session_xyz789" + } +} +``` + +> 关于 ChatLab Format 的详细规范,请参考 [ChatLab 标准化格式规范](./chatlab-format.md)。 + +--- + +### POST /api/v1/sessions/:id/import + +将聊天记录**增量导入**到已存在的会话。支持去重,相同消息不会重复插入。 + +**去重规则:** + +消息唯一键为 `timestamp + senderPlatformId + contentLength`。如果一条消息的时间戳、发送者和内容长度与已有消息完全相同,则视为重复并跳过。 + +**路径参数:** + +| 参数 | 类型 | 说明 | +| ---- | ------ | ----------- | +| `id` | string | 目标会话 ID | + +Content-Type 和请求体格式与 `POST /api/v1/import` 相同。 + +**响应:** + +```json +{ + "success": true, + "data": { + "mode": "incremental", + "sessionId": "session_abc123", + "newMessageCount": 150 + } +} +``` + +--- + +## 并发与限制 + +| 限制项 | 值 | 说明 | +| ---------------- | ------- | ------------------------------- | +| JSON 请求体大小 | 50MB | `application/json` 模式 | +| JSONL 请求体大小 | 无限制 | `application/x-ndjson` 流式模式 | +| 导出消息上限 | 10 万条 | `/export` 端点 | +| 分页最大每页 | 1000 条 | `/messages` 端点 | +| 导入并发 | 1 | 同一时刻仅允许一个导入操作 | + +--- + +## 错误码 + +| 错误码 | HTTP 状态码 | 说明 | +| ------------------------ | ----------- | ------------------------------- | +| `UNAUTHORIZED` | 401 | Token 无效或缺失 | +| `SESSION_NOT_FOUND` | 404 | 会话不存在 | +| `INVALID_FORMAT` | 400 | 请求体不符合 ChatLab Format | +| `SQL_READONLY_VIOLATION` | 400 | SQL 不是 SELECT 语句 | +| `SQL_EXECUTION_ERROR` | 400 | SQL 执行出错 | +| `EXPORT_TOO_LARGE` | 400 | 消息数超过导出上限(10 万条) | +| `BODY_TOO_LARGE` | 413 | 请求体超过 50MB(仅 JSON 模式) | +| `IMPORT_IN_PROGRESS` | 409 | 有其他导入正在进行 | +| `IMPORT_FAILED` | 500 | 导入失败 | +| `SERVER_ERROR` | 500 | 服务内部错误 | + +--- + +## 安全说明 + +- **仅本机访问**:API 绑定 `127.0.0.1`,不对外暴露 +- **Token 认证**:所有端点需携带有效 Bearer Token +- **SQL 只读限制**:`/sql` 端点仅允许 `SELECT` 查询 +- **默认关闭**:API 服务需手动开启 + +--- + +## 使用场景 + +### 1. MCP 集成 + +将 ChatLab API 接入 ClaudeCode 等 AI 工具,实现 AI 对聊天记录的直接查询和分析。 + +### 2. 自动化脚本 + +编写脚本定期从其他平台导出聊天记录,转换为 ChatLab Format 后通过 Push API 自动导入。 + +### 3. 数据分析 + +通过 SQL 端点执行自定义查询,配合 Python/R 等工具进行高级数据分析。 + +### 4. 数据备份 + +通过 `/export` 端点定期导出重要会话数据作为 JSON 备份。 + +### 5. 定时拉取 + +在设置页配置外部数据源 URL,ChatLab 会按设定间隔自动拉取并导入新数据。 + +--- + +## 版本信息 + +| 版本 | 说明 | +| ---- | ------------------------------------------------------------------------------ | +| v1 | 初始版本,支持会话查询、消息搜索、SQL、导出、导入(JSON + JSONL)、Pull 调度器 | diff --git a/docs/tw/standard/chatlab-format.md b/docs/tw/standard/chatlab-format.md new file mode 100644 index 000000000..3b4b53c28 --- /dev/null +++ b/docs/tw/standard/chatlab-format.md @@ -0,0 +1,388 @@ +--- +outline: deep +--- + +# 聊天資料交換標準化格式 + +> v0.0.2 + +ChatLab 定義了一套標準的聊天記錄資料交換格式,用於支援多平台資料的統一匯入和分析。 + +只要你將聊天記錄轉為該格式,那麼就可以被 ChatLab 解析並使用其分析能力。 + +::: warning 注意 +該格式規範目前仍處於早期制定階段,部分欄位和結構可能會在後續版本中調整。 +::: + +## 概述 + +### 支援的檔案格式 + +| 格式 | 副檔名 | 適用場景 | +| --------- | -------- | --------------------------------------------------- | +| **JSON** | `.json` | 中小型記錄(<100 萬條),結構清晰,易於閱讀 | +| **JSONL** | `.jsonl` | 超大規模記錄(>100 萬條),流式處理,記憶體佔用恆定 | + +### 格式對比 + +| 特性 | JSON | JSONL | +| ------------ | ---------------------- | ----------------------- | +| 記憶體佔用 | 需載入完整結構 | 逐行處理,恆定 (~100MB) | +| 檔案大小限制 | ~1GB(取決於記憶體) | 無實際限制 | +| 追加寫入 | - 需重寫整個檔案 | ✅ 直接追加行 | +| 錯誤復原 | 單處錯誤整檔案失效 | 可跳過錯誤行繼續 | +| 可讀性 | ⭐⭐⭐ 易於閱讀 | ⭐⭐ 每行一條記錄 | +| 推薦場景 | 小中型記錄 (<100 萬條) | 大型記錄 (>100 萬條) | + +## 快速說明 + +以下是一個**最小化**的 ChatLab 格式範例,只包含必要欄位: + +```json +{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1703001600 + }, + "meta": { + "name": "我的群聊", + "platform": "qq", + "type": "group" + }, + "members": [ + { + "platformId": "123456", + "accountName": "張三" + } + ], + "messages": [ + { + "sender": "123456", + "accountName": "張三", + "timestamp": 1703001600, + "type": 0, + "content": "大家好!" + } + ] +} +``` + +--- + +## JSON 格式詳細說明 + +### 檔案頭 (chatlab) + +| 欄位 | 類型 | 必填 | 說明 | +| ------------- | ------ | ---- | ---------------------------- | +| `version` | string | ✅ | 格式版本號,目前為 `"0.0.2"` | +| `exportedAt` | number | ✅ | 匯出時間(秒級 Unix 時間戳) | +| `generator` | string | - | 產生工具名稱 | +| `description` | string | - | 描述資訊 | + +### 元資訊 (meta) + +| 欄位 | 類型 | 必填 | 說明 | +| ------------- | ------ | ---- | -------------------------------------------------------- | +| `name` | string | ✅ | 群名或對話名 | +| `platform` | string | ✅ | 平台標識,如 `qq` / `wechat` / `discord` / `whatsapp` 等 | +| `type` | string | ✅ | 聊天類型:`group`(群聊)/ `private`(私聊) | +| `groupId` | string | - | 群 ID(僅群聊) | +| `groupAvatar` | string | - | 群頭像(Data URL 格式) | +| `ownerId` | string | - | 所有者/匯出者的 platformId | + +### 成員 (members) + +| 欄位 | 類型 | 必填 | 說明 | +| --------------- | ------------ | ---- | --------------------------- | +| `platformId` | string | ✅ | 使用者唯一標識 | +| `accountName` | string | ✅ | 帳號名稱 | +| `groupNickname` | string | - | 群暱稱(僅群聊) | +| `aliases` | string[] | - | 使用者自訂別名 | +| `avatar` | string | - | 使用者頭像(Data URL 格式) | +| `roles` | MemberRole[] | - | 成員角色(可多個) | + +#### 角色 (roles) + +成員可以擁有一個或多個角色,用於標示群主、管理員等身份: + +| 欄位 | 類型 | 必填 | 說明 | +| ------ | ------ | ---- | ------------------------------------- | +| `id` | string | ✅ | 角色標識:`owner` / `admin` / 自訂 ID | +| `name` | string | - | 角色顯示名稱(自訂角色需要) | + +**標準角色 ID:** + +| ID | 說明 | +| ------- | ----------- | +| `owner` | 群主/建立者 | +| `admin` | 管理員 | + +**角色範例:** + +```json +// 群主 +"roles": [{ "id": "owner" }] + +// 管理員 +"roles": [{ "id": "admin" }] + +// 多角色 +"roles": [ + { "id": "owner" }, + { "id": "tech-team", "name": "技術組" }, + { "id": "vip", "name": "VIP會員" } +] +``` + +### 訊息 (messages) + +| 欄位 | 類型 | 必填 | 說明 | +| ------------------- | -------------- | ---- | --------------------------------- | +| `sender` | string | ✅ | 發送者的 `platformId` | +| `accountName` | string | ✅ | 發送時的帳號名稱 | +| `groupNickname` | string | - | 發送時的群暱稱 | +| `timestamp` | number | ✅ | 秒級 Unix 時間戳 | +| `type` | number | ✅ | 訊息類型(見下方對照表) | +| `content` | string \| null | ✅ | 訊息內容(非文字訊息可為 `null`) | +| `platformMessageId` | string | - | 訊息的平台原始 ID | +| `replyToMessageId` | string | - | 回覆的目標訊息 ID | + +#### 訊息 ID 與回覆關係說明 + +**`platformMessageId`**(訊息的平台原始 ID): + +- 儲存訊息在原始平台上的唯一標識(如 Discord 的 snowflake ID、QQ 的訊息 ID) +- 用於在查詢時關聯 `replyToMessageId`,以顯示被回覆訊息的內容 +- 如果平台不提供訊息 ID,可省略此欄位 + +**`replyToMessageId`**(回覆的目標訊息 ID): + +- 儲存被回覆訊息的**平台原始 ID** +- 透過與其他訊息的 `platformMessageId` 關聯,可查詢被回覆訊息的內容和發送者 +- 僅當訊息是回覆類型時才有意義 +- 如果平台不支援或資料不包含回覆關係,可省略此欄位 + +--- + +## 訊息類型對照表 + +::: tip 提示 +若您的聊天記錄中有其他特殊類型需要支援,請提交 issue 說明情況,我們會評估是否加入標準訊息類型中。 +::: + +### 基礎訊息類型 (0-19) + +| 值 | 名稱 | 說明 | +| --- | -------- | ----------- | +| 0 | TEXT | 文字訊息 | +| 1 | IMAGE | 圖片 | +| 2 | VOICE | 語音 | +| 3 | VIDEO | 影片 | +| 4 | FILE | 檔案 | +| 5 | EMOJI | 表情包/貼紙 | +| 7 | LINK | 連結/卡片 | +| 8 | LOCATION | 位置 | + +### 互動訊息類型 (20-39) + +| 值 | 名稱 | 說明 | +| --- | ---------- | ---------------------- | +| 20 | RED_PACKET | 紅包 | +| 21 | TRANSFER | 轉帳 | +| 22 | POKE | 拍一拍/戳一戳 | +| 23 | CALL | 語音/視訊通話 | +| 24 | SHARE | 分享(音樂、小程序等) | +| 25 | REPLY | 引用回覆 | +| 26 | FORWARD | 轉傳訊息 | +| 27 | CONTACT | 名片訊息 | + +### 系統訊息類型 (80+) + +| 值 | 名稱 | 說明 | +| --- | ------ | ------------------------------ | +| 80 | SYSTEM | 系統訊息(入群/退群/群公告等) | +| 81 | RECALL | 撤回訊息 | +| 99 | OTHER | 其他/未知 | + +## 頭像格式說明 + +頭像欄位 `avatar` 和 `groupAvatar` 支援兩種格式: + +### 1. Data URL + +內嵌式格式,圖片資料直接編碼在檔案中,離線可用: + +``` +data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD... +``` + +支援的圖片 MIME 類型: + +- `image/jpeg` - JPEG 格式(推薦,體積較小) +- `image/png` - PNG 格式 +- `image/gif` - GIF 格式 +- `image/webp` - WebP 格式 + +### 2. 網路 URL + +外連格式,圖片儲存在網路伺服器,體積更小但需網路存取: + +``` +https://example.com/avatars/user123.jpg +``` + +::: tip 建議 + +- 如果需要離線使用或長期存檔,推薦使用 Data URL 格式 +- 匯出 Data URL 時建議將頭像壓縮為 100×100 像素以內,以減小檔案體積 +- 如果頭像來自可靠的長期有效的 CDN,可使用網路 URL 以減小檔案體積 ::: + +## 完整範例 + +### 群聊範例(含可選欄位) + +```json +{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1703001600, + "generator": "My Converter Tool", + "description": "2024年技術交流群聊天記錄備份" + }, + "meta": { + "name": "技術交流群", + "platform": "wechat", + "type": "group", + "groupId": "38988428513", + "groupAvatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg...", + "ownerId": "abc123" + }, + "members": [ + { + "platformId": "abc123", + "accountName": "張三", + "groupNickname": "群主-張三", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg...", + "roles": [{ "id": "owner" }] + }, + { + "platformId": "def456", + "accountName": "李四", + "groupNickname": "管理員", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg...", + "roles": [{ "id": "admin" }] + } + ], + "messages": [ + { + "platformMessageId": "msg_001", + "sender": "abc123", + "accountName": "張三", + "groupNickname": "群主-張三", + "timestamp": 1703001600, + "type": 0, + "content": "大家好!歡迎加入技術交流群~" + }, + { + "platformMessageId": "msg_002", + "sender": "def456", + "accountName": "李四", + "groupNickname": "管理員", + "timestamp": 1703001610, + "type": 25, + "content": "收到!", + "replyToMessageId": "msg_001" + } + ] +} +``` + +### 私聊範例 + +```json +{ + "chatlab": { + "version": "0.0.2", + "exportedAt": 1703001600 + }, + "meta": { + "name": "與小明的對話", + "platform": "qq", + "type": "private" + }, + "members": [ + { + "platformId": "123456789", + "accountName": "我", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + }, + { + "platformId": "987654321", + "accountName": "小明", + "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." + } + ], + "messages": [ + { + "sender": "123456789", + "accountName": "我", + "timestamp": 1703001600, + "type": 0, + "content": "在嗎?" + } + ] +} +``` + +## JSONL 流式格式 + +JSONL(JSON Lines)格式適用於**超大規模聊天記錄**(>100 萬條),可避免記憶體溢位問題。 + +### 格式特點 + +- 每行一個 JSON 物件 +- 透過 `_type` 欄位區分行類型:`header` / `member` / `message` +- 記憶體佔用恆定(約 100MB),支援 GB 級檔案 +- 支援串流寫入,可邊匯出邊追加 + +### 行類型說明 + +| `_type` | 說明 | 是否必需 | +| --------- | -------------------------------- | --------------- | +| `header` | 檔案頭,包含 `chatlab` 和 `meta` | ✅ 必須在第一行 | +| `member` | 成員資訊 | - 可選 | +| `message` | 訊息記錄 | ✅ 至少一條 | + +### 完整範例 + +```jsonl +{"_type":"header","chatlab":{"version":"0.0.2","exportedAt":1703001600},"meta":{"name":"技術交流群","platform":"qq","type":"group"}} +{"_type":"member","platformId":"123456","accountName":"張三","groupNickname":"群主","roles":[{"id":"owner"}]} +{"_type":"member","platformId":"789012","accountName":"李四"} +{"_type":"message","platformMessageId":"msg_001","sender":"123456","accountName":"張三","groupNickname":"群主","timestamp":1703001600,"type":0,"content":"大家好!"} +{"_type":"message","sender":"789012","accountName":"李四","timestamp":1703001610,"type":0,"content":"你好!"} +{"_type":"message","sender":"123456","accountName":"張三","groupNickname":"群主","timestamp":1703001620,"type":1,"content":"[圖片]"} +``` + +### 解析規則 + +1. **第一行必須是 header**:包含 `chatlab` 版本和 `meta` 元資訊 +2. **成員行在訊息之前**:可選,如果省略,成員資訊會從訊息中自動收集 +3. **訊息按時間順序排列**:建議按 `timestamp` 升冪排列 +4. **每行獨立完整**:單行解析錯誤可跳過繼續處理 +5. **支援註解行**:以 `#` 開頭的行會被跳過(可用於新增備註) + +::: warning 注意 + +- 每行必須是**有效的 JSON**(不能跨行) +- 行之間用換行符 `\n` 分隔 + +::: + +## 版本歷史 + +| 版本 | 日期 | 變更 | +| ----- | ---------- | ------------------------------------------------------------------------------ | +| 0.0.1 | 2025-12-22 | 初始版本 | +| 0.0.2 | 2026-01-09 | 新增 roles、ownerId、platformMessageId、replyToMessageId 欄位;新增 JSONL 格式 | diff --git a/docs/tw/usage/faq.md b/docs/tw/usage/faq.md new file mode 100644 index 000000000..ca3a46ae8 --- /dev/null +++ b/docs/tw/usage/faq.md @@ -0,0 +1,23 @@ +--- +outline: deep +--- + +# 常見問題 + +這裡彙總 ChatLab 使用中高頻出現的問題與處理思路。 + +## 匯出問題 + +待補充。 + +## 匯入問題 + +待補充。 + +## AI相關問題 + +待補充。 + +## 軟體異常錯誤問題 + +待補充。 diff --git a/docs/tw/usage/how-to-config-ai.md b/docs/tw/usage/how-to-config-ai.md new file mode 100644 index 000000000..88275ecce --- /dev/null +++ b/docs/tw/usage/how-to-config-ai.md @@ -0,0 +1,31 @@ +--- +outline: deep +--- + +# 如何配置 AI 模型 + +## 在线 AI 模型 + +这里以 deepseek 为例,其他的模型请自行搜索配置方法: + +1. 访问 [Deepseek 官网](https://www.deepseek.com/),选择 API 开放平台,注册并登录 + +![](/cn/img/ai-guide/1.png) + +2. 选择左侧充值,充值你所需要的金额(建议先充 10 块钱,足够用很久了) + +![](/cn/img/ai-guide/2.png) + +3. 选择左侧 API Keys,点击创建 API key,随便填写一个标题,然后复制 API Key + +![](/cn/img/ai-guide/3.png) + +4. 打开 ChatLab,右下角点击设置,然后「模型配置」>「添加新配置」 + +把刚才复制的 API Key 填写到 API Keys 中,点击验证确认没问题后,点击右下角添加后,即可开始使用 AI 相关功能 + +![](/cn/img/ai-guide/4.png) + +5. 在左侧的用量信息中,你可以查看你充值和消费的额度 + +![](/cn/img/ai-guide/5.png) diff --git a/docs/tw/usage/how-to-export.md b/docs/tw/usage/how-to-export.md new file mode 100644 index 000000000..f1568a5d4 --- /dev/null +++ b/docs/tw/usage/how-to-export.md @@ -0,0 +1,101 @@ +--- +outline: deep +--- + +# 匯出聊天記錄 + +ChatLab 專注於對已匯出數據的分析,我們不提供抓取數據的功能。您需要先使用官方功能或開源社群的第三方工具,將聊天記錄匯出後,再匯入 ChatLab 進行分析。 + +Tips:歡迎訪問 [加入社群](https://chatlab.fun/tw/other/community),討論問題以及溝通需求。 + +## WhatsApp + +對於 WhatsApp, 目前已適配官方提供的”匯出聊天”功能。 + +目前已相容中文語言和英文語言的匯出,如有其他語言需求,請聯繫開發者。 + +- **匯出方式**: + 1. 打開 WhatsApp,進入想要匯出的對話。 + 2. 點擊頂部聯絡人名稱 -> 匯出聊天 (Export Chat)。 + 3. 選擇”不附加媒體”。 +- **格式**:將匯出後的 `.zip` 包解壓出其中的 `txt` 檔案,將 `txt` 檔案拖入 ChatLab 即可。 + +## Discord + +對於 Discord,目前已適配 **DiscordChatExporter** 匯出的 json 格式。 + +- **項目地址**:[https://github.com/Tyrrrz/DiscordChatExporter](https://github.com/Tyrrrz/DiscordChatExporter) +- **支援平台**:Windows / macOS / Linux +- **使用教程**:參考項目 README。 +- **提示**:請務必選擇匯出格式為 **JSON**,以便 ChatLab 正確解析。 + +## Instagram + +對於 Instagram,目前已適配官方提供的匯出功能。 + +- **匯出方式**: + 1. 打開 Instagram 應用或網頁版,進入「設定」。 + 2. 點擊「帳戶中心」->「你的資訊和權限」->「下載你的資訊」。 + 3. 選擇「部分資訊」,然後勾選「訊息」。 + 4. 選擇格式為 **JSON**,日期範圍選擇「所有時間」。 + 5. 點擊「提交請求」,等待 Instagram 處理完成後下載。 +- **格式**:將下載的壓縮包解壓後,找到 `your_instagram_activity/messages/inbox/` 目錄下對應聊天的 `message_1.json` 檔案,拖入 ChatLab 即可。 +- **提示**:如果對話內容較多,可能會有多個 `message_*.json` 檔案,建議逐一匯入。 + +## LINE + +對於 LINE,目前已適配官方提供的聊天記錄匯出功能。 + +- **匯出方式**: + 1. 打開 LINE,進入想要匯出的對話。 + 2. 移動端:點擊聊天右上角選單 -> 設定 -> 匯出聊天記錄。 + 3. 桌面端(Windows / macOS):進入 Chats,打開對應聊天後,點擊右上角選單 -> Save chat。 + 4. 保存或分享匯出的文本檔案。 +- **格式**:將匯出的 `.txt` 檔案直接拖入 ChatLab 即可。 +- **提示**:LINE 官方說明中提到,桌面端僅會保存當前已加載並顯示在聊天視窗中的訊息。 + +## iMessage + +對於 iMessage,目前 **imessage-chatlab** 已適配 ChatLab 標準 JSON 格式。 + +- **項目地址**:[https://github.com/gamesme/imessage-chatlab](https://github.com/gamesme/imessage-chatlab) +- **支援平台**:macOS +- **安裝方式**:如果已安裝 Rust 工具鏈,可以執行 `cargo install imessage-chatlab`;也可以依照項目 README 從源碼安裝。 +- **匯出方式**:參考項目 README。例如可執行 `imessage-chatlab -c clone -o ~/imessage_chatlab_export` 匯出本機 iMessage 數據。 +- **格式**:工具會按會話匯出 ChatLab 標準 JSON 檔案,將匯出的 `.json` 檔案拖入 ChatLab 即可。 +- **提示**:該工具會讀取 macOS 本機 Messages 資料庫。使用前請仔細閱讀項目文件,並確認您擁有讀取與分析相關聊天記錄的合法權限。 + +## Google Chat + +對於 Google Chat,目前已適配 Google 官方 Takeout 匯出的 ZIP 格式。 + +- **匯出方式**: + 1. 前往 [Google Takeout](https://takeout.google.com/),以您的 Google 帳號登入。 + 2. 點擊「取消全選」,再單獨勾選 **Google Chat**(可縮小匯出體積)。 + 3. 選擇檔案類型為 **.zip**(暫不支援 .tgz 格式)。 + 4. 點擊「建立匯出」,等待 Google 處理完成後下載(系統會寄送電子郵件通知)。 +- **格式**:將下載的 `.zip` 檔案直接拖入 ChatLab,ChatLab 會掃描壓縮包內所有對話並顯示選擇清單,勾選後逐一匯入即可。 +- **提示**: + - 僅支援 ZIP 格式,若 Takeout 提供的是 .tgz,請重新匯出時選擇 .zip。 + - 首次匯出請求可能需要等候數小時。 + - 附件(圖片、檔案等)目前不會隨聊天記錄一併匯入。 + +## Q&A:飛書/企微/千牛等的聊天記錄能分析嗎? + +針對各種聊天分析的需求,統一回覆: + +ChatLab 的功能是 **對已匯出的固定文本格式的聊天記錄進行分析**,但前提是**您已經透過合法合規的管道匯出了聊天記錄**。 + +如果您有一定的技術基礎,可以嘗試使用 **AI 輔助轉換** 的方式,將您的數據轉換為標準格式。詳情請查看 [AI 輔助轉換指南](/tw/standard/ai-converter)。 + +此外,如果您是開發者,並已支援了其他聊天應用的聊天記錄匯出,歡迎[相容 ChatLab 格式](/tw/standard/chatlab-format),我會將您的 Github 連結加到這裡。 + +## ⚠️ 法律與安全聲明 + +在嘗試分析上述應用的數據前,請務必知曉: + +- **合法授權原則**:您僅可處理您**本人參與**的聊天記錄。若涉及他人隱私,請務必確保已獲得相關人員的知情同意。 +- **禁止非法用途**:嚴禁將本軟體用於竊取、監控或分析未經授權的他人隱私,或用於任何侵犯他人權益的行為。 +- **合規性自負**:從第三方平台取得數據的行為屬於您的個人行為。若因分析行為違反了原始數據來源平台的服務條款而導致帳號受限或其他後果,ChatLab 不承擔任何責任。 +- **禁止商用**:嚴禁任何個人或機構將本軟體或分析結果用於任何形式的商業盈利行為。 +- **結果準確性**:軟體生成的分析結果可能存在錯誤或”幻覺”,僅供技術交流參考,不應作為法律證據或決策依據。 diff --git a/docs/tw/usage/how-to-import.md b/docs/tw/usage/how-to-import.md new file mode 100644 index 000000000..17674a63d --- /dev/null +++ b/docs/tw/usage/how-to-import.md @@ -0,0 +1,18 @@ +--- +outline: deep +--- + +# 匯入聊天記錄指南 + +完成匯出後,您只需在 ChatLab 的首頁: + +1. 將匯出的**資料檔案**直接拖入上傳區域。 +2. 等待 ChatLab 解析完成即可。 + +## BUG 排查 + +如果匯入失敗,可以透過日誌快速排查問題: + +軟體左下角「設定」 > 「基礎設定」 > 「日誌檔案」,開啟該目錄,該目錄下有個「import」目錄,就是所有匯入的日誌記錄了。 + +如果您看不懂,可以透過 Github issue 提交問題。 diff --git a/docs/tw/usage/index.md b/docs/tw/usage/index.md new file mode 100644 index 000000000..89a163543 --- /dev/null +++ b/docs/tw/usage/index.md @@ -0,0 +1,5 @@ +--- +outline: deep +--- + +# 使用說明 diff --git a/docs/tw/usage/qa.md b/docs/tw/usage/qa.md new file mode 100644 index 000000000..130e52e6f --- /dev/null +++ b/docs/tw/usage/qa.md @@ -0,0 +1,50 @@ +# Q&A + +## 未来会支持音频、图片导入吗? + +不确定,目前的文本分析功能仍然有非常多的 TODO 需要实现,计划文本分析的功能完善之后再考虑音频和图片的分析。 + +## 如何直接访问本地数据库 + +ChatLab 使用 SQLite 存储聊天记录,你可以用任何 SQLite 客户端工具直接查看数据。 + +### 数据库位置 + +你可直接通过软件的 设置 > 存储管理 > 聊天记录数据库 > 打开,打开数据库所在文件夹。 + +| 平台 | 路径 | +| ------- | ------------------------------------------------------- | +| macOS | `~/Library/Application Support/ChatLab/data/databases/` | +| Windows | `%APPDATA%/ChatLab/data/databases/` | +| Linux | `~/.config/ChatLab/data/databases/` | + +每个聊天记录是一个独立的 `.db` 文件。 + +### 推荐工具 + +- [DB Browser for SQLite](https://sqlitebrowser.org/) - 免费开源,新手友好 +- [TablePlus](https://tableplus.com/) - 界面美观 +- [DBeaver](https://dbeaver.io/) - 功能强大 + +### 命令行访问 + +```bash +# macOS/Linux +sqlite3 ~/Library/Application\ Support/ChatLab/data/databases/你的数据库.db + +# 常用命令 +.tables # 查看所有表 +.schema message # 查看 message 表结构 +SELECT * FROM message LIMIT 10; # 查询消息 +``` + +### 表结构 + +- `meta` - 聊天记录元信息 +- `member` - 成员信息 +- `message` - 消息内容 +- `member_name_history` - 成员改名历史 + +### 注意事项 + +⚠️ 建议在 ChatLab **关闭时**访问数据库,避免锁冲突。 diff --git a/docs/tw/usage/quick-start.md b/docs/tw/usage/quick-start.md new file mode 100644 index 000000000..45f16c0a8 --- /dev/null +++ b/docs/tw/usage/quick-start.md @@ -0,0 +1,68 @@ +--- +outline: deep +--- + +# 快速上手 + +## 第一步:安裝 ChatLab + +ChatLab 提供兩種安裝方式: + +**方式一:官網下載安裝包** + +前往 [ChatLab 官網](https://chatlab.fun) 下載對應作業系統的安裝包,雙擊安裝即可。也可以從 [GitHub Releases](https://github.com/ChatLab/ChatLab/releases) 下載。 + +**方式二:CLI 安裝** + +```bash +npm i chatlab-cli -g +``` + +需要 Node.js ≥ 20。CLI 適合伺服器部署或搭配 AI Agent(如 Claude Desktop)使用。 + +安裝完成後,執行以下指令啟動 ChatLab: + +```bash +chatlab start # 啟動 API + Web UI,並在瀏覽器中開啟 +chatlab start --no-open # 啟動 API + Web UI,但不自動開啟瀏覽器 +chatlab start --headless # 僅啟動 API,不掛載 Web UI(供腳本 / AI Agent 呼叫) +``` + +常用選項:`--port <連接埠>`(預設 3110)、`--host <位址>`、`--token <令牌>`。 + +若要讓服務常駐後台(登入自動啟動 + 崩潰自動重啟),加上 `--daemon` 參數: + +```bash +chatlab start --daemon # 註冊為系統服務,登入自動啟動(macOS / Linux) +chatlab status # 查看常駐狀態 +chatlab stop # 停止並移除系統服務 +``` + +## 第二步:匯入聊天記錄 + +ChatLab 提供三種匯入方式,適用於不同場景: + +| 方式 | 適用場景 | +|------|----------| +| **檔案匯入** | 將匯出的聊天記錄檔案直接拖入 ChatLab 首頁,適合一次性匯入 | +| **自動同步** | 設定外部平台的資料來源,讓聊天記錄定期自動同步到 ChatLab | +| **API 推送** | 開啟本機 API 服務,允許第三方工具/外掛或腳本主動推送聊天記錄至 ChatLab | + +### 普通使用者 + +使用**檔案匯入**即可,你需要: + +1. 先使用第三方工具將聊天記錄匯出為檔案,具體匯出方式請查看 [匯出聊天記錄](/tw/usage/how-to-export)。 +2. 將匯出的檔案直接拖入 ChatLab 首頁即可,如遇問題請查看 [匯入聊天記錄指南](/tw/usage/how-to-import)。 + +### 開發者 + +如果你是開發者,想要對接**自動同步**或 **API 推送**,請查看以下文件: + +- [ChatLab Format](/tw/standard/chatlab-format) — 了解資料格式規範 + +## 第三步:配置 AI + +ChatLab 內建 AI Agent 功能,接入 AI 模型後即可透過自然語言探索你的聊天歷史。 + +詳細配置步驟請查看 [如何配置 AI 模型](/tw/usage/how-to-config-ai)。 diff --git a/docs/tw/usage/troubleshooting.md b/docs/tw/usage/troubleshooting.md new file mode 100644 index 000000000..875902814 --- /dev/null +++ b/docs/tw/usage/troubleshooting.md @@ -0,0 +1,81 @@ +--- +outline: deep +--- + +# 故障排查指南 + +本文档帮助用户和开发者排查 ChatLab 使用中遇到的问题。 + +## 日志文件 + +**获取日志文件**:软件左下角的 **「设置」 > 「存储管理」 > 「日志文件」 > 打开目录** + +日志存储在 `文档/ChatLab/logs/` 目录下: + +``` +ChatLab/logs/ +├── app.log # 主程序日志 +├── ai/ # AI 相关日志 +│ └── ai_YYYY-MM-DD_HH-mm.log +└── import/ # 导入日志 + └── import_{sessionId}_{timestamp}.log +``` + +### 日志文件说明 + +| 目录/文件 | 内容 | +| -------------- | ------------------------------------------------ | +| `app.log` | 主程序日志,包含文件解析、数据库操作、IPC 通信等 | +| `ai/*.log` | AI 日志,包含 LLM 调用、Agent 执行、工具调用等 | +| `import/*.log` | 导入性能日志,包含导入速度、内存使用、各阶段耗时 | + +## 常见问题 + +### 1. 导入失败 + +**症状**:拖入文件后提示解析失败 + +**排查步骤**: + +1. 确认文件格式是否支持(.json / .jsonl / .txt) +2. 检查文件是否损坏(用文本编辑器打开查看) +3. 查看日志文件中的 `[Parser]` 相关错误 + +### 2. AI 功能无响应 + +**症状**:AI 实验室发送消息后无回复 + +**排查步骤**: + +1. 检查是否已配置 API Key(设置 > AI 设置) +2. 点击「验证」,确认 API 连接正常 +3. 查看日志文件中的 `[LLM]` 或 `[Agent]` 相关错误 + +**常见原因**: + +- API Key 无效或余额不足 +- API 服务商限流 + +### 3. 数据库错误 + +**症状**:打开会话时提示错误 + +**排查步骤**: + +1. 查看日志文件中的 `[Database]` 相关错误 +2. 检查数据库文件是否存在 + +## 反馈问题 + +如果以上方法无法解决问题,请: + +1. 收集日志文件 +2. 描述问题复现步骤 +3. 提交 Issue 到 GitHub + +**提交 Issue 时请包含**: + +- 操作系统及版本 +- ChatLab 版本 +- 问题描述及复现步骤 +- 相关日志片段(注意脱敏) diff --git a/electron.vite.config.ts b/electron.vite.config.ts deleted file mode 100644 index df911220d..000000000 --- a/electron.vite.config.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { resolve } from 'path' -import { defineConfig, externalizeDepsPlugin } from 'electron-vite' -import vue from '@vitejs/plugin-vue' -import ui from '@nuxt/ui/vite' -import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite' - -export default defineConfig(() => { - return { - main: { - plugins: [externalizeDepsPlugin()], - define: { - // 使用系统环境变量 - 'process.env.APTABASE_APP_KEY': JSON.stringify(process.env.APTABASE_APP_KEY || ''), - }, - build: { - rollupOptions: { - input: { - index: resolve(__dirname, 'electron/main/index.ts'), - 'worker/dbWorker': resolve(__dirname, 'electron/main/worker/dbWorker.ts'), - }, - }, - }, - }, - preload: { - plugins: [externalizeDepsPlugin()], - build: { - rollupOptions: { - input: { - index: resolve(__dirname, 'electron/preload/index.ts'), - }, - }, - }, - }, - renderer: { - resolve: { - alias: { - '@': resolve('src/'), - '~': resolve('src/'), - }, - }, - plugins: [ - vue(), - ui({ - ui: { - colors: { - primary: 'pink', // 使用自定义 pink 作为主色 - neutral: 'slate', - }, - }, - }), - VueI18nPlugin({ - // 指定全局语言包目录 - include: [resolve(__dirname, 'src/i18n/locales/**')], - // 默认语言 - defaultSFCLang: 'json', - // 启用组件内 块 - compositionOnly: true, - }), - ], - root: 'src/', - build: { - sourcemap: false, - rollupOptions: { - input: { - index: resolve(__dirname, 'src/index.html'), - }, - }, - }, - server: { - host: '0.0.0.0', - port: 3400, - hmr: { - protocol: 'ws', - host: 'localhost', - port: 3400, - }, - }, - }, - } -}) diff --git a/electron/main/ai/agent.ts b/electron/main/ai/agent.ts deleted file mode 100644 index 3d9ff5ebf..000000000 --- a/electron/main/ai/agent.ts +++ /dev/null @@ -1,834 +0,0 @@ -/** - * AI Agent 执行器 - * 处理 Function Calling 循环,支持多轮工具调用 - */ - -import type { ChatMessage, ChatOptions, ChatStreamChunk, ToolCall } from './llm/types' -import { chatStream, chat } from './llm' -import { getAllToolDefinitions, executeToolCalls } from './tools' -import type { ToolContext, OwnerInfo } from './tools/types' -import { aiLogger } from './logger' -import { randomUUID } from 'crypto' - -// ==================== Fallback 解析器 ==================== - -/** - * 从文本内容中提取 标签内容 - */ -function extractThinkingContent(content: string): { thinking: string; cleanContent: string } { - const thinkRegex = /([\s\S]*?)<\/think>/gi - let thinking = '' - let cleanContent = content - - const matches = content.matchAll(thinkRegex) - for (const match of matches) { - thinking += match[1].trim() + '\n' - cleanContent = cleanContent.replace(match[0], '') - } - - return { thinking: thinking.trim(), cleanContent: cleanContent.trim() } -} - -/** - * 从文本内容中解析 标签并转换为标准 ToolCall 格式 - */ -function parseToolCallTags(content: string): ToolCall[] | null { - const toolCallRegex = /\s*([\s\S]*?)\s*<\/tool_call>/gi - const toolCalls: ToolCall[] = [] - - const matches = content.matchAll(toolCallRegex) - for (const match of matches) { - try { - const jsonStr = match[1].trim() - const parsed = JSON.parse(jsonStr) - - if (parsed.name && parsed.arguments) { - toolCalls.push({ - id: `fallback-${randomUUID()}`, - type: 'function', - function: { - name: parsed.name, - arguments: typeof parsed.arguments === 'string' ? parsed.arguments : JSON.stringify(parsed.arguments), - }, - }) - } - } catch (e) { - aiLogger.warn('Agent', 'Failed to parse tool_call tag', { content: match[1], error: String(e) }) - } - } - - return toolCalls.length > 0 ? toolCalls : null -} - -/** - * 检测内容是否包含工具调用标签(用于判断是否需要 fallback 解析) - */ -function hasToolCallTags(content: string): boolean { - return //i.test(content) -} - -/** - * Agent 配置 - */ -export interface AgentConfig { - /** 最大工具调用轮数(防止无限循环) */ - maxToolRounds?: number - /** LLM 选项 */ - llmOptions?: ChatOptions - /** 中止信号,用于取消执行 */ - abortSignal?: AbortSignal -} - -/** - * Token 使用量 - */ -export interface TokenUsage { - promptTokens: number - completionTokens: number - totalTokens: number -} - -/** - * Agent 流式响应 chunk - */ -export interface AgentStreamChunk { - /** chunk 类型 */ - type: 'content' | 'tool_start' | 'tool_result' | 'done' | 'error' - /** 文本内容(type=content 时) */ - content?: string - /** 工具名称(type=tool_start/tool_result 时) */ - toolName?: string - /** 工具调用参数(type=tool_start 时) */ - toolParams?: Record - /** 工具执行结果(type=tool_result 时) */ - toolResult?: unknown - /** 错误信息(type=error 时) */ - error?: string - /** 是否完成 */ - isFinished?: boolean - /** Token 使用量(type=done 时返回累计值) */ - usage?: TokenUsage -} - -/** - * Agent 执行结果 - */ -export interface AgentResult { - /** 最终文本响应 */ - content: string - /** 使用的工具列表 */ - toolsUsed: string[] - /** 工具调用轮数 */ - toolRounds: number - /** 总 Token 使用量(累计所有 LLM 调用) */ - totalUsage?: TokenUsage -} - -// ==================== 提示词配置类型 ==================== - -/** - * 用户自定义提示词配置 - */ -export interface PromptConfig { - /** 角色定义(可编辑区) */ - roleDefinition: string - /** 回答要求(可编辑区) */ - responseRules: string -} - -// 国际化内容 -const i18nContent = { - 'zh-CN': { - currentDateIs: '当前日期是', - chatTypeDesc: { private: '私聊记录', group: '群聊记录' }, - chatContext: { private: '对话', group: '群聊' }, - ownerNote: (displayName: string, platformId: string, chatContext: string) => `当前用户身份: -- 用户在${chatContext}中的身份是「${displayName}」(platformId: ${platformId}) -- 当用户提到"我"、"我的"时,指的就是「${displayName}」 -- 查询"我"的发言时,使用 sender_id 参数筛选该成员 -`, - memberNotePrivate: `成员查询策略: -- 私聊只有两个人,可以直接获取成员列表 -- 当用户提到"对方"、"他/她"时,通过 get_group_members 获取另一方信息 -`, - memberNoteGroup: `成员查询策略: -- 当用户提到特定群成员(如"张三说过什么"、"小明的发言"等)时,应先调用 get_group_members 获取成员列表 -- 群成员有三种名称:accountName(原始昵称)、groupNickname(群昵称)、aliases(用户自定义别名) -- 通过 get_group_members 的 search 参数可以模糊搜索这三种名称 -- 找到成员后,使用其 id 字段作为 search_messages 的 sender_id 参数来获取该成员的发言 -`, - toolsIntro: (chatTypeDesc: string) => `你可以使用以下工具来获取${chatTypeDesc}数据:`, - toolDescriptions: [ - 'search_messages - 根据关键词搜索聊天记录,支持时间筛选和发送者筛选', - 'get_recent_messages - 获取指定时间段的聊天消息', - 'get_member_stats - 获取成员活跃度统计', - 'get_time_stats - 获取时间分布统计', - 'get_group_members - 获取成员列表,包括 id、QQ号、账号名称、昵称、别名和消息统计', - 'get_member_name_history - 获取成员的昵称变更历史,需要先通过 get_group_members 获取成员 ID', - 'get_conversation_between - 获取两个成员之间的对话记录,需要先通过 get_group_members 获取两人的成员 ID', - 'get_message_context - 根据消息 ID 获取前后的上下文消息,支持批量查询,消息 ID 可从其他搜索工具的返回结果中获取', - ], - timeParamsIntro: '时间参数:按用户提到的精度组合 year/month/day/hour', - timeParamExample1: (year: number) => `"10月" → year: ${year}, month: 10`, - timeParamExample2: (year: number) => `"10月1号" → year: ${year}, month: 10, day: 1`, - timeParamExample3: (year: number) => `"10月1号下午3点" → year: ${year}, month: 10, day: 1, hour: 15`, - defaultYearNote: (year: number, prevYear: number) => `未指定年份默认${year}年,若该月份未到则用${prevYear}年`, - responseInstruction: '根据用户的问题,选择合适的工具获取数据,然后基于数据给出回答。', - responseRulesTitle: '回答要求:', - fallbackRoleDefinition: (chatType: string) => `你是一个专业的${chatType}记录分析助手。 -你的任务是帮助用户理解和分析他们的${chatType}记录数据。`, - fallbackResponseRules: `1. 基于工具返回的数据回答,不要编造信息 -2. 如果数据不足以回答问题,请说明 -3. 回答要简洁明了,使用 Markdown 格式`, - }, - 'en-US': { - currentDateIs: 'Current date is', - chatTypeDesc: { private: 'private chat records', group: 'group chat records' }, - chatContext: { private: 'conversation', group: 'group chat' }, - ownerNote: (displayName: string, platformId: string, chatContext: string) => `Current user identity: -- The user's identity in this ${chatContext} is "${displayName}" (platformId: ${platformId}) -- When the user refers to "I" or "my", it refers to "${displayName}" -- When querying "my" messages, use the sender_id parameter to filter for this member -`, - memberNotePrivate: `Member query strategy: -- Private chats only have two participants, so the member list can be directly obtained -- When the user refers to "the other party" or "he/she", get the other participant's information via get_group_members -`, - memberNoteGroup: `Member query strategy: -- When the user refers to specific group members (e.g., "what did John say", "Mary's messages"), first call get_group_members to get the member list -- Group members have three names: accountName (original nickname), groupNickname (group nickname), aliases (user-defined aliases) -- The search parameter of get_group_members can be used for fuzzy searching these three names -- Once a member is found, use their id field as the sender_id parameter for search_messages to retrieve their messages -`, - toolsIntro: (chatTypeDesc: string) => `You can use the following tools to get ${chatTypeDesc} data:`, - toolDescriptions: [ - 'search_messages - Search chat records by keywords, supports time and sender filtering', - 'get_recent_messages - Get chat messages for a specified time period', - 'get_member_stats - Get member activity statistics', - 'get_time_stats - Get time distribution statistics', - 'get_group_members - Get member list, including ID, QQ number, account name, nickname, aliases, and message statistics', - 'get_member_name_history - Get member nickname change history, requires member ID from get_group_members first', - 'get_conversation_between - Get conversation records between two members, requires member IDs from get_group_members first', - 'get_message_context - Get context messages before and after a message ID, supports batch queries, message ID can be obtained from other search tool results', - ], - timeParamsIntro: 'Time parameters: combine year/month/day/hour based on user mention', - timeParamExample1: (year: number) => `"October" → year: ${year}, month: 10`, - timeParamExample2: (year: number) => `"October 1st" → year: ${year}, month: 10, day: 1`, - timeParamExample3: (year: number) => `"October 1st 3 PM" → year: ${year}, month: 10, day: 1, hour: 15`, - defaultYearNote: (year: number, prevYear: number) => - `If year is not specified, defaults to ${year}. If the month has not yet occurred, ${prevYear} is used.`, - responseInstruction: - "Based on the user's question, select appropriate tools to retrieve data, then provide an answer based on the data.", - responseRulesTitle: 'Response requirements:', - fallbackRoleDefinition: (chatType: string) => `You are a professional ${chatType} analysis assistant. -Your task is to help users understand and analyze their ${chatType} data.`, - fallbackResponseRules: `1. Answer based on data returned by tools, do not fabricate information -2. If data is insufficient to answer, please state so -3. Keep answers concise and clear, use Markdown format`, - }, -} - -/** - * 获取系统锁定部分的提示词(工具说明、时间处理等) - * @param chatType 聊天类型 ('group' | 'private') - * @param ownerInfo Owner 信息(当前用户在对话中的身份) - * @param locale 语言设置 - */ -function getLockedPromptSection( - chatType: 'group' | 'private', - ownerInfo?: OwnerInfo, - locale: string = 'zh-CN' -): string { - const content = i18nContent[locale as keyof typeof i18nContent] || i18nContent['zh-CN'] - const now = new Date() - const dateLocale = locale === 'zh-CN' ? 'zh-CN' : 'en-US' - const currentDate = now.toLocaleDateString(dateLocale, { - year: 'numeric', - month: 'long', - day: 'numeric', - weekday: 'long', - }) - - const isPrivate = chatType === 'private' - const chatTypeDesc = content.chatTypeDesc[chatType] - const chatContext = content.chatContext[chatType] - - // Owner 说明(当用户设置了"我是谁"时) - const ownerNote = ownerInfo ? content.ownerNote(ownerInfo.displayName, ownerInfo.platformId, chatContext) : '' - - // 成员说明(私聊只有2人) - const memberNote = isPrivate ? content.memberNotePrivate : content.memberNoteGroup - - const toolsList = content.toolDescriptions.map((desc, i) => `${i + 1}. ${desc}`).join('\n') - const year = now.getFullYear() - const prevYear = year - 1 - - return `${content.currentDateIs} ${currentDate}。 -${ownerNote} -${content.toolsIntro(chatTypeDesc)} - -${toolsList} - -${memberNote} -${content.timeParamsIntro} -- ${content.timeParamExample1(year)} -- ${content.timeParamExample2(year)} -- ${content.timeParamExample3(year)} -${content.defaultYearNote(year, prevYear)} - -${content.responseInstruction}` -} - -/** - * 获取 Fallback 角色定义(主要配置来自前端 src/config/prompts.ts) - * 仅在前端未传递 promptConfig 时使用 - */ -function getFallbackRoleDefinition(chatType: 'group' | 'private', locale: string = 'zh-CN'): string { - const content = i18nContent[locale as keyof typeof i18nContent] || i18nContent['zh-CN'] - const chatTypeDesc = chatType === 'private' ? (locale === 'zh-CN' ? '私聊' : 'private chat') : (locale === 'zh-CN' ? '群聊' : 'group chat') - return content.fallbackRoleDefinition(chatTypeDesc) -} - -/** - * 获取 Fallback 回答要求(主要配置来自前端 src/config/prompts.ts) - * 仅在前端未传递 promptConfig 时使用 - */ -function getFallbackResponseRules(locale: string = 'zh-CN'): string { - const content = i18nContent[locale as keyof typeof i18nContent] || i18nContent['zh-CN'] - return content.fallbackResponseRules -} - -/** - * 构建完整的系统提示词 - * - * 提示词配置主要来自前端 src/config/prompts.ts,通过 promptConfig 参数传递。 - * Fallback 仅在前端未传递配置时使用(一般不会发生)。 - * - * @param chatType 聊天类型 ('group' | 'private') - * @param promptConfig 用户自定义提示词配置(来自前端激活的预设) - * @param ownerInfo Owner 信息(当前用户在对话中的身份) - * @param locale 语言设置 - */ -function buildSystemPrompt( - chatType: 'group' | 'private' = 'group', - promptConfig?: PromptConfig, - ownerInfo?: OwnerInfo, - locale: string = 'zh-CN' -): string { - const content = i18nContent[locale as keyof typeof i18nContent] || i18nContent['zh-CN'] - - // 使用用户配置或 fallback - const roleDefinition = promptConfig?.roleDefinition || getFallbackRoleDefinition(chatType, locale) - const responseRules = promptConfig?.responseRules || getFallbackResponseRules(locale) - - // 获取锁定的系统部分(包含动态日期、工具说明和 Owner 信息) - const lockedSection = getLockedPromptSection(chatType, ownerInfo, locale) - - // 组合完整提示词 - return `${roleDefinition} - -${lockedSection} - -${content.responseRulesTitle} -${responseRules}` -} - -/** - * Agent 执行器类 - * 处理带 Function Calling 的对话流程 - */ -export class Agent { - private context: ToolContext - private config: AgentConfig - private messages: ChatMessage[] = [] - private toolsUsed: string[] = [] - private toolRounds: number = 0 - private abortSignal?: AbortSignal - private historyMessages: ChatMessage[] = [] - private chatType: 'group' | 'private' = 'group' - private promptConfig?: PromptConfig - private locale: string = 'zh-CN' - /** 累计 Token 使用量 */ - private totalUsage: TokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 } - - constructor( - context: ToolContext, - config: AgentConfig = {}, - historyMessages: ChatMessage[] = [], - chatType: 'group' | 'private' = 'group', - promptConfig?: PromptConfig, - locale: string = 'zh-CN' - ) { - this.context = context - this.abortSignal = config.abortSignal - this.historyMessages = historyMessages - this.chatType = chatType - this.promptConfig = promptConfig - this.locale = locale - this.config = { - maxToolRounds: config.maxToolRounds ?? 5, - llmOptions: config.llmOptions ?? { temperature: 0.7, maxTokens: 2048 }, - } - } - - /** - * 检查是否已中止 - */ - private isAborted(): boolean { - return this.abortSignal?.aborted ?? false - } - - /** - * 累加 Token 使用量 - */ - private addUsage(usage?: { promptTokens: number; completionTokens: number; totalTokens: number }): void { - if (usage) { - this.totalUsage.promptTokens += usage.promptTokens - this.totalUsage.completionTokens += usage.completionTokens - this.totalUsage.totalTokens += usage.totalTokens - } - } - - /** - * 执行对话(非流式) - * @param userMessage 用户消息 - */ - async execute(userMessage: string): Promise { - aiLogger.info('Agent', '用户问题', userMessage) - - // 检查是否已中止 - if (this.isAborted()) { - return { content: '', toolsUsed: [], toolRounds: 0, totalUsage: this.totalUsage } - } - - // 初始化消息(包含历史记录) - const systemPrompt = buildSystemPrompt(this.chatType, this.promptConfig, this.context.ownerInfo, this.locale) - this.messages = [ - { role: 'system', content: systemPrompt }, - ...this.historyMessages, // 插入历史对话 - { role: 'user', content: userMessage }, - ] - this.toolsUsed = [] - this.toolRounds = 0 - - // 获取所有工具定义 - const tools = await getAllToolDefinitions() - - // 执行循环 - while (this.toolRounds < this.config.maxToolRounds!) { - // 每轮开始时检查是否中止 - if (this.isAborted()) { - return { - content: '', - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - totalUsage: this.totalUsage, - } - } - - const response = await chat(this.messages, { - ...this.config.llmOptions, - tools, - abortSignal: this.abortSignal, - }) - - // 累加 Token 使用量 - this.addUsage(response.usage) - - let toolCallsToProcess = response.tool_calls - - // 如果没有标准 tool_calls,尝试 fallback 解析 - if (response.finishReason !== 'tool_calls' || !response.tool_calls) { - // Fallback: 检查内容中是否有 标签 - if (hasToolCallTags(response.content)) { - // 提取 thinking 内容 - const { cleanContent } = extractThinkingContent(response.content) - - // 解析 tool_call 标签 - const fallbackToolCalls = parseToolCallTags(response.content) - if (fallbackToolCalls && fallbackToolCalls.length > 0) { - toolCallsToProcess = fallbackToolCalls - } else { - // 解析失败,返回清理后的内容 - aiLogger.info('Agent', 'AI 回复', cleanContent) - return { - content: cleanContent, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - totalUsage: this.totalUsage, - } - } - } else { - // 没有 tool_call 标签,正常完成 - aiLogger.info('Agent', 'AI 回复', response.content) - return { - content: response.content, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - totalUsage: this.totalUsage, - } - } - } - - // 处理工具调用 - await this.handleToolCalls(toolCallsToProcess!) - this.toolRounds++ - } - - // 超过最大轮数,强制让 LLM 总结 - aiLogger.warn('Agent', '达到最大工具调用轮数', { maxRounds: this.config.maxToolRounds }) - this.messages.push({ - role: 'user', - content: '请根据已获取的信息给出回答,不要再调用工具。', - }) - - const finalResponse = await chat(this.messages, this.config.llmOptions) - this.addUsage(finalResponse.usage) - return { - content: finalResponse.content, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - totalUsage: this.totalUsage, - } - } - - /** - * 执行对话(流式) - * @param userMessage 用户消息 - * @param onChunk 流式回调 - */ - async executeStream(userMessage: string, onChunk: (chunk: AgentStreamChunk) => void): Promise { - aiLogger.info('Agent', '用户问题', userMessage) - - // 检查是否已中止 - if (this.isAborted()) { - onChunk({ type: 'done', isFinished: true, usage: this.totalUsage }) - return { content: '', toolsUsed: [], toolRounds: 0, totalUsage: this.totalUsage } - } - - // 初始化消息(包含历史记录) - const systemPrompt = buildSystemPrompt(this.chatType, this.promptConfig, this.context.ownerInfo, this.locale) - this.messages = [ - { role: 'system', content: systemPrompt }, - ...this.historyMessages, // 插入历史对话 - { role: 'user', content: userMessage }, - ] - this.toolsUsed = [] - this.toolRounds = 0 - - const tools = await getAllToolDefinitions() - let finalContent = '' - - // 执行循环 - while (this.toolRounds < this.config.maxToolRounds!) { - // 每轮开始时检查是否中止 - if (this.isAborted()) { - onChunk({ type: 'done', isFinished: true, usage: this.totalUsage }) - return { - content: finalContent, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - totalUsage: this.totalUsage, - } - } - - let accumulatedContent = '' - let displayedContent = '' // 已发送给前端的内容 - let toolCalls: ToolCall[] | undefined - let isBufferingToolCall = false // 是否正在缓冲 tool_call 内容 - let isBufferingThink = false // 是否正在缓冲 内容 - - // 流式调用 LLM(传入 abortSignal) - for await (const chunk of chatStream(this.messages, { - ...this.config.llmOptions, - tools, - abortSignal: this.abortSignal, - })) { - // 每个 chunk 时检查是否中止 - if (this.isAborted()) { - onChunk({ type: 'done', isFinished: true, usage: this.totalUsage }) - return { - content: finalContent + accumulatedContent, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - totalUsage: this.totalUsage, - } - } - if (chunk.content) { - accumulatedContent += chunk.content - - // 检测是否开始出现 标签(过滤思考内容) - if (!isBufferingThink && //i.test(accumulatedContent)) { - isBufferingThink = true - // 发送 标签之前的内容 - const thinkStart = accumulatedContent.toLowerCase().indexOf('') - if (thinkStart > displayedContent.length) { - const newContent = accumulatedContent.slice(displayedContent.length, thinkStart) - if (newContent) { - onChunk({ type: 'content', content: newContent }) - displayedContent = accumulatedContent.slice(0, thinkStart) - } - } - } - - // 检测 结束标签,退出思考缓冲模式 - if (isBufferingThink && /<\/think>/i.test(accumulatedContent)) { - isBufferingThink = false - // 跳过 ... 内容,更新 displayedContent - const thinkEnd = accumulatedContent.toLowerCase().indexOf('') + ''.length - displayedContent = accumulatedContent.slice(0, thinkEnd) - } - - // 如果正在缓冲思考内容,不发送 - if (isBufferingThink) { - continue - } - - // 检测是否开始出现 标签(用于 fallback 解析) - if (!isBufferingToolCall) { - if (//i.test(accumulatedContent)) { - isBufferingToolCall = true - // 发送标签之前的内容(如果有) - const tagStart = accumulatedContent.indexOf('') - if (tagStart > displayedContent.length) { - const newContent = accumulatedContent.slice(displayedContent.length, tagStart) - if (newContent) { - onChunk({ type: 'content', content: newContent }) - displayedContent = accumulatedContent.slice(0, tagStart) - } - } - } else { - // 正常发送内容(但要排除已发送的部分) - const newContent = accumulatedContent.slice(displayedContent.length) - if (newContent) { - onChunk({ type: 'content', content: newContent }) - displayedContent = accumulatedContent - } - } - } - // 如果已经在缓冲模式,不发送内容 - } - - if (chunk.tool_calls) { - toolCalls = chunk.tool_calls - } - - // 累加 Token 使用量(流式响应在最后一个 chunk 返回 usage) - if (chunk.usage) { - this.addUsage(chunk.usage) - } - - if (chunk.isFinished) { - // 如果没有标准 tool_calls,尝试 fallback 解析 - if (chunk.finishReason !== 'tool_calls' || !toolCalls) { - // Fallback: 检查内容中是否有 标签 - if (hasToolCallTags(accumulatedContent)) { - // 提取 thinking 内容 - const { cleanContent } = extractThinkingContent(accumulatedContent) - - // 解析 tool_call 标签 - const fallbackToolCalls = parseToolCallTags(accumulatedContent) - if (fallbackToolCalls && fallbackToolCalls.length > 0) { - toolCalls = fallbackToolCalls - // 更新累积内容为清理后的内容(移除 think 和 tool_call 标签) - accumulatedContent = cleanContent.replace(/[\s\S]*?<\/tool_call>/gi, '').trim() - // 不返回,继续执行工具调用 - } else { - // 解析失败,作为普通响应处理 - const remainingContent = cleanContent.slice(displayedContent.length) - if (remainingContent) { - onChunk({ type: 'content', content: remainingContent }) - } - finalContent = cleanContent - aiLogger.info('Agent', 'AI 回复', finalContent) - onChunk({ type: 'done', isFinished: true, usage: this.totalUsage }) - return { - content: finalContent, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - totalUsage: this.totalUsage, - } - } - } else { - // 没有 tool_call 标签,正常完成 - finalContent = extractThinkingContent(accumulatedContent).cleanContent - aiLogger.info('Agent', 'AI 回复', finalContent) - onChunk({ type: 'done', isFinished: true, usage: this.totalUsage }) - return { - content: finalContent, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - totalUsage: this.totalUsage, - } - } - } - } - } - - // 处理工具调用 - if (toolCalls && toolCalls.length > 0) { - // 通知前端开始执行工具(包含参数和时间范围) - for (const tc of toolCalls) { - let toolParams: Record | undefined - try { - toolParams = JSON.parse(tc.function.arguments || '{}') - - // 对于消息获取类工具,用用户配置的 limit 覆盖 LLM 传递的值(保持显示一致) - const toolsWithLimit = ['search_messages', 'get_recent_messages', 'get_conversation_between'] - if (this.context.maxMessagesLimit && toolsWithLimit.includes(tc.function.name)) { - toolParams = { - ...toolParams, - limit: this.context.maxMessagesLimit, // 用户配置优先 - } - } - - // 对于搜索类工具,添加时间范围信息 - if ( - this.context.timeFilter && - (tc.function.name === 'search_messages' || tc.function.name === 'get_recent_messages') - ) { - toolParams = { - ...toolParams, - _timeFilter: this.context.timeFilter, - } - } - } catch { - toolParams = undefined - } - onChunk({ type: 'tool_start', toolName: tc.function.name, toolParams }) - } - - await this.handleToolCalls(toolCalls, onChunk) - this.toolRounds++ - } - } - - // 超过最大轮数 - aiLogger.warn('Agent', '达到最大工具调用轮数', { maxRounds: this.config.maxToolRounds }) - - // 检查是否已中止 - if (this.isAborted()) { - onChunk({ type: 'done', isFinished: true, usage: this.totalUsage }) - return { - content: finalContent, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - totalUsage: this.totalUsage, - } - } - - this.messages.push({ - role: 'user', - content: '请根据已获取的信息给出回答,不要再调用工具。', - }) - - // 最后一轮不带 tools(传入 abortSignal) - for await (const chunk of chatStream(this.messages, { - ...this.config.llmOptions, - abortSignal: this.abortSignal, - })) { - if (this.isAborted()) { - onChunk({ type: 'done', isFinished: true, usage: this.totalUsage }) - break - } - if (chunk.content) { - finalContent += chunk.content - onChunk({ type: 'content', content: chunk.content }) - } - // 累加 Token 使用量 - if (chunk.usage) { - this.addUsage(chunk.usage) - } - if (chunk.isFinished) { - onChunk({ type: 'done', isFinished: true, usage: this.totalUsage }) - } - } - - return { - content: finalContent, - toolsUsed: this.toolsUsed, - toolRounds: this.toolRounds, - totalUsage: this.totalUsage, - } - } - - /** - * 处理工具调用 - */ - private async handleToolCalls(toolCalls: ToolCall[], onChunk?: (chunk: AgentStreamChunk) => void): Promise { - // 记录调用的工具及参数 - for (const tc of toolCalls) { - aiLogger.info('Agent', `工具调用: ${tc.function.name}`, tc.function.arguments) - } - - // 添加 assistant 消息(包含 tool_calls) - this.messages.push({ - role: 'assistant', - content: '', - tool_calls: toolCalls, - }) - - // 执行工具(传递 locale 用于工具返回结果的国际化) - const results = await executeToolCalls(toolCalls, { ...this.context, locale: this.locale }) - - // 添加 tool 消息 - for (let i = 0; i < toolCalls.length; i++) { - const tc = toolCalls[i] - const result = results[i] - - this.toolsUsed.push(tc.function.name) - - // 通知前端工具执行结果 - if (onChunk) { - onChunk({ - type: 'tool_result', - toolName: tc.function.name, - toolResult: result.success ? result.result : result.error, - }) - } - - // 记录工具执行结果 - if (result.success) { - aiLogger.info('Agent', `工具结果: ${tc.function.name}`, result.result) - } else { - aiLogger.warn('Agent', `工具失败: ${tc.function.name}`, result.error) - } - - // 添加工具结果消息 - this.messages.push({ - role: 'tool', - content: result.success ? JSON.stringify(result.result) : `错误: ${result.error}`, - tool_call_id: tc.id, - }) - } - } -} - -/** - * 创建 Agent 并执行对话(便捷函数) - */ -export async function runAgent( - userMessage: string, - context: ToolContext, - config?: AgentConfig, - historyMessages?: ChatMessage[], - chatType?: 'group' | 'private' -): Promise { - const agent = new Agent(context, config, historyMessages, chatType) - return agent.execute(userMessage) -} - -/** - * 创建 Agent 并流式执行对话(便捷函数) - */ -export async function runAgentStream( - userMessage: string, - context: ToolContext, - onChunk: (chunk: AgentStreamChunk) => void, - config?: AgentConfig, - historyMessages?: ChatMessage[], - chatType?: 'group' | 'private' -): Promise { - const agent = new Agent(context, config, historyMessages, chatType) - return agent.executeStream(userMessage, onChunk) -} diff --git a/electron/main/ai/conversations.ts b/electron/main/ai/conversations.ts deleted file mode 100644 index 44635d8db..000000000 --- a/electron/main/ai/conversations.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * AI 对话历史管理模块 - * 在主进程中执行,管理 AI 对话的持久化存储 - */ - -import Database from 'better-sqlite3' -import * as path from 'path' -import { getAiDataDir, ensureDir } from '../paths' - -// AI 数据库实例 -let AI_DB: Database.Database | null = null - -/** - * 获取 AI 数据库目录 - */ -function getAiDbDir(): string { - return getAiDataDir() -} - -/** - * 确保 AI 数据库目录存在 - */ -function ensureAiDbDir(): void { - ensureDir(getAiDbDir()) -} - -/** - * 获取 AI 数据库实例(单例) - */ -function getAiDb(): Database.Database { - if (AI_DB) return AI_DB - - ensureAiDbDir() - const dbPath = path.join(getAiDbDir(), 'conversations.db') - AI_DB = new Database(dbPath) - AI_DB.pragma('journal_mode = WAL') - - // 创建表结构 - AI_DB.exec(` - -- AI 对话表 - CREATE TABLE IF NOT EXISTS ai_conversation ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - title TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - -- AI 消息表 - CREATE TABLE IF NOT EXISTS ai_message ( - id TEXT PRIMARY KEY, - conversation_id TEXT NOT NULL, - role TEXT NOT NULL, - content TEXT NOT NULL, - timestamp INTEGER NOT NULL, - data_keywords TEXT, - data_message_count INTEGER, - content_blocks TEXT, - FOREIGN KEY(conversation_id) REFERENCES ai_conversation(id) ON DELETE CASCADE - ); - - -- 索引 - CREATE INDEX IF NOT EXISTS idx_ai_conversation_session ON ai_conversation(session_id); - CREATE INDEX IF NOT EXISTS idx_ai_message_conversation ON ai_message(conversation_id); - `) - - // 数据库迁移:为旧数据库添加缺失的列 - migrateAiDatabase(AI_DB) - - return AI_DB -} - -/** - * 数据库迁移:检查并添加缺失的列 - */ -function migrateAiDatabase(db: Database.Database): void { - try { - // 获取 ai_message 表的列信息 - const tableInfo = db.pragma('table_info(ai_message)') as Array<{ name: string }> - const columnNames = tableInfo.map((col) => col.name) - - // 检查并添加 content_blocks 列 - if (!columnNames.includes('content_blocks')) { - db.exec('ALTER TABLE ai_message ADD COLUMN content_blocks TEXT') - console.log('[AI DB Migration] 添加 content_blocks 列') - } - } catch (error) { - console.error('[AI DB Migration] 迁移失败:', error) - } -} - -/** - * 关闭 AI 数据库连接 - */ -export function closeAiDatabase(): void { - if (AI_DB) { - AI_DB.close() - AI_DB = null - } -} - -// ==================== 类型定义 ==================== - -/** - * AI 对话类型 - */ -export interface AIConversation { - id: string - sessionId: string - title: string | null - createdAt: number - updatedAt: number -} - -/** - * 内容块类型(用于 AI 消息的混合渲染) - */ -export type ContentBlock = - | { type: 'text'; text: string } - | { - type: 'tool' - tool: { - name: string - displayName: string - status: 'running' | 'done' | 'error' - params?: Record - } - } - -/** - * AI 消息类型 - */ -export interface AIMessage { - id: string - conversationId: string - role: 'user' | 'assistant' - content: string - timestamp: number - dataKeywords?: string[] - dataMessageCount?: number - /** AI 消息的内容块数组(按时序排列的文本和工具调用) */ - contentBlocks?: ContentBlock[] -} - -// ==================== 对话管理 ==================== - -/** - * 创建新对话 - */ -export function createConversation(sessionId: string, title?: string): AIConversation { - const db = getAiDb() - const now = Math.floor(Date.now() / 1000) - const id = `conv_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - - db.prepare(` - INSERT INTO ai_conversation (id, session_id, title, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - `).run(id, sessionId, title || null, now, now) - - return { - id, - sessionId, - title: title || null, - createdAt: now, - updatedAt: now, - } -} - -/** - * 获取会话的所有对话列表 - */ -export function getConversations(sessionId: string): AIConversation[] { - const db = getAiDb() - - const rows = db.prepare(` - SELECT id, session_id as sessionId, title, created_at as createdAt, updated_at as updatedAt - FROM ai_conversation - WHERE session_id = ? - ORDER BY updated_at DESC - `).all(sessionId) as AIConversation[] - - return rows -} - -/** - * 获取单个对话 - */ -export function getConversation(conversationId: string): AIConversation | null { - const db = getAiDb() - - const row = db.prepare(` - SELECT id, session_id as sessionId, title, created_at as createdAt, updated_at as updatedAt - FROM ai_conversation - WHERE id = ? - `).get(conversationId) as AIConversation | undefined - - return row || null -} - -/** - * 更新对话标题 - */ -export function updateConversationTitle(conversationId: string, title: string): boolean { - const db = getAiDb() - const now = Math.floor(Date.now() / 1000) - - const result = db.prepare(` - UPDATE ai_conversation - SET title = ?, updated_at = ? - WHERE id = ? - `).run(title, now, conversationId) - - return result.changes > 0 -} - -/** - * 删除对话(级联删除消息) - */ -export function deleteConversation(conversationId: string): boolean { - const db = getAiDb() - - // 先删除消息 - db.prepare('DELETE FROM ai_message WHERE conversation_id = ?').run(conversationId) - // 再删除对话 - const result = db.prepare('DELETE FROM ai_conversation WHERE id = ?').run(conversationId) - - return result.changes > 0 -} - -// ==================== 消息管理 ==================== - -/** - * 添加消息到对话 - */ -export function addMessage( - conversationId: string, - role: 'user' | 'assistant', - content: string, - dataKeywords?: string[], - dataMessageCount?: number, - contentBlocks?: ContentBlock[] -): AIMessage { - const db = getAiDb() - const now = Math.floor(Date.now() / 1000) - const id = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - - db.prepare(` - INSERT INTO ai_message (id, conversation_id, role, content, timestamp, data_keywords, data_message_count, content_blocks) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `).run( - id, - conversationId, - role, - content, - now, - dataKeywords ? JSON.stringify(dataKeywords) : null, - dataMessageCount ?? null, - contentBlocks ? JSON.stringify(contentBlocks) : null - ) - - // 更新对话的 updated_at - db.prepare('UPDATE ai_conversation SET updated_at = ? WHERE id = ?').run(now, conversationId) - - return { - id, - conversationId, - role, - content, - timestamp: now, - dataKeywords, - dataMessageCount, - contentBlocks, - } -} - -/** - * 获取对话的所有消息 - */ -export function getMessages(conversationId: string): AIMessage[] { - const db = getAiDb() - - const rows = db.prepare(` - SELECT - id, - conversation_id as conversationId, - role, - content, - timestamp, - data_keywords as dataKeywords, - data_message_count as dataMessageCount, - content_blocks as contentBlocks - FROM ai_message - WHERE conversation_id = ? - ORDER BY timestamp ASC - `).all(conversationId) as Array<{ - id: string - conversationId: string - role: string - content: string - timestamp: number - dataKeywords: string | null - dataMessageCount: number | null - contentBlocks: string | null - }> - - return rows.map((row) => ({ - id: row.id, - conversationId: row.conversationId, - role: row.role as 'user' | 'assistant', - content: row.content, - timestamp: row.timestamp, - dataKeywords: row.dataKeywords ? JSON.parse(row.dataKeywords) : undefined, - dataMessageCount: row.dataMessageCount ?? undefined, - contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined, - })) -} - -/** - * 删除单条消息 - */ -export function deleteMessage(messageId: string): boolean { - const db = getAiDb() - const result = db.prepare('DELETE FROM ai_message WHERE id = ?').run(messageId) - return result.changes > 0 -} - diff --git a/electron/main/ai/llm/deepseek.ts b/electron/main/ai/llm/deepseek.ts deleted file mode 100644 index 456dd66c0..000000000 --- a/electron/main/ai/llm/deepseek.ts +++ /dev/null @@ -1,341 +0,0 @@ -/** - * DeepSeek LLM Provider - * 使用 OpenAI 兼容的 API 格式,支持 Function Calling - */ - -import type { - ILLMService, - LLMProvider, - ChatMessage, - ChatOptions, - ChatResponse, - ChatStreamChunk, - ProviderInfo, - ToolCall, -} from './types' - -const DEFAULT_BASE_URL = 'https://api.deepseek.com' - -const MODELS = [ - { id: 'deepseek-chat', name: 'DeepSeek Chat', description: '通用对话模型' }, - { id: 'deepseek-coder', name: 'DeepSeek Coder', description: '代码生成模型' }, -] - -export const DEEPSEEK_INFO: ProviderInfo = { - id: 'deepseek', - name: 'DeepSeek', - description: 'DeepSeek AI 大语言模型', - defaultBaseUrl: DEFAULT_BASE_URL, - models: MODELS, -} - -export class DeepSeekService implements ILLMService { - private apiKey: string - private baseUrl: string - private model: string - - constructor(apiKey: string, model?: string, baseUrl?: string) { - this.apiKey = apiKey - this.baseUrl = baseUrl || DEFAULT_BASE_URL - this.model = model || 'deepseek-chat' - } - - getProvider(): LLMProvider { - return 'deepseek' - } - - getModels(): string[] { - return MODELS.map((m) => m.id) - } - - getDefaultModel(): string { - return 'deepseek-chat' - } - - async chat(messages: ChatMessage[], options?: ChatOptions): Promise { - // 构建请求体 - const requestBody: Record = { - model: this.model, - messages: messages.map((m) => { - const msg: Record = { role: m.role, content: m.content } - // 处理 tool 消息 - if (m.role === 'tool' && m.tool_call_id) { - msg.tool_call_id = m.tool_call_id - } - // 处理 assistant 消息中的 tool_calls - if (m.role === 'assistant' && m.tool_calls) { - msg.tool_calls = m.tool_calls - } - return msg - }), - temperature: options?.temperature ?? 0.7, - max_tokens: options?.maxTokens ?? 2048, - stream: false, - } - - // 如果有 tools,添加到请求体 - if (options?.tools && options.tools.length > 0) { - requestBody.tools = options.tools - } - - const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify(requestBody), - signal: options?.abortSignal, - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`DeepSeek API error: ${response.status} - ${error}`) - } - - const data = await response.json() - const choice = data.choices?.[0] - const message = choice?.message - - // 解析 finish_reason - let finishReason: ChatResponse['finishReason'] = 'error' - if (choice?.finish_reason === 'stop') { - finishReason = 'stop' - } else if (choice?.finish_reason === 'length') { - finishReason = 'length' - } else if (choice?.finish_reason === 'tool_calls') { - finishReason = 'tool_calls' - } - - // 解析 tool_calls - let toolCalls: ToolCall[] | undefined - if (message?.tool_calls && Array.isArray(message.tool_calls)) { - toolCalls = message.tool_calls.map((tc: Record) => ({ - id: tc.id as string, - type: 'function' as const, - function: { - name: (tc.function as Record)?.name as string, - arguments: (tc.function as Record)?.arguments as string, - }, - })) - } - - return { - content: message?.content || '', - finishReason, - tool_calls: toolCalls, - usage: data.usage - ? { - promptTokens: data.usage.prompt_tokens, - completionTokens: data.usage.completion_tokens, - totalTokens: data.usage.total_tokens, - } - : undefined, - } - } - - async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator { - // 构建请求体 - const requestBody: Record = { - model: this.model, - messages: messages.map((m) => { - const msg: Record = { role: m.role, content: m.content } - if (m.role === 'tool' && m.tool_call_id) { - msg.tool_call_id = m.tool_call_id - } - if (m.role === 'assistant' && m.tool_calls) { - msg.tool_calls = m.tool_calls - } - return msg - }), - temperature: options?.temperature ?? 0.7, - max_tokens: options?.maxTokens ?? 2048, - stream: true, - // 启用流式响应中的 usage 统计 - stream_options: { include_usage: true }, - } - - if (options?.tools && options.tools.length > 0) { - requestBody.tools = options.tools - } - - const response = await fetch(`${this.baseUrl}/v1/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify(requestBody), - signal: options?.abortSignal, - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`DeepSeek API error: ${response.status} - ${error}`) - } - - const reader = response.body?.getReader() - if (!reader) { - throw new Error('Failed to get response reader') - } - - const decoder = new TextDecoder() - let buffer = '' - - // 用于收集流式 tool_calls - const toolCallsAccumulator: Map = new Map() - - try { - while (true) { - // 检查是否已中止 - if (options?.abortSignal?.aborted) { - yield { content: '', isFinished: true, finishReason: 'stop' } - return - } - - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed || !trimmed.startsWith('data: ')) continue - - const data = trimmed.slice(6) - if (data === '[DONE]') { - // 如果有累积的 tool_calls,返回它们 - if (toolCallsAccumulator.size > 0) { - const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({ - id: tc.id, - type: 'function' as const, - function: { - name: tc.name, - arguments: tc.arguments, - }, - })) - yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCalls } - } else { - yield { content: '', isFinished: true, finishReason: 'stop' } - } - return - } - - try { - const parsed = JSON.parse(data) - const delta = parsed.choices?.[0]?.delta - const finishReason = parsed.choices?.[0]?.finish_reason - - // 处理文本内容 - if (delta?.content) { - yield { - content: delta.content, - isFinished: false, - } - } - - // 处理流式 tool_calls(增量累积) - if (delta?.tool_calls && Array.isArray(delta.tool_calls)) { - for (const tc of delta.tool_calls) { - const index = tc.index ?? 0 - const existing = toolCallsAccumulator.get(index) - if (existing) { - // 累积 arguments - if (tc.function?.arguments) { - existing.arguments += tc.function.arguments - } - } else { - // 新的 tool_call - toolCallsAccumulator.set(index, { - id: tc.id || '', - name: tc.function?.name || '', - arguments: tc.function?.arguments || '', - }) - } - } - } - - if (finishReason) { - let reason: ChatStreamChunk['finishReason'] = 'error' - if (finishReason === 'stop') { - reason = 'stop' - } else if (finishReason === 'length') { - reason = 'length' - } else if (finishReason === 'tool_calls') { - reason = 'tool_calls' - } - - // 解析 usage 信息 - const usage = parsed.usage - ? { - promptTokens: parsed.usage.prompt_tokens, - completionTokens: parsed.usage.completion_tokens, - totalTokens: parsed.usage.total_tokens, - } - : undefined - - // 如果有 tool_calls,返回它们 - if (toolCallsAccumulator.size > 0) { - const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({ - id: tc.id, - type: 'function' as const, - function: { - name: tc.name, - arguments: tc.arguments, - }, - })) - yield { content: '', isFinished: true, finishReason: reason, tool_calls: toolCalls, usage } - } else { - yield { content: '', isFinished: true, finishReason: reason, usage } - } - return - } - } catch { - // 忽略解析错误,继续处理下一行 - } - } - } - } catch (error) { - // 如果是中止错误,正常返回 - if (error instanceof Error && error.name === 'AbortError') { - yield { content: '', isFinished: true, finishReason: 'stop' } - return - } - throw error - } finally { - reader.releaseLock() - } - } - - async validateApiKey(): Promise<{ success: boolean; error?: string }> { - try { - // 发送一个简单请求验证 API Key - const response = await fetch(`${this.baseUrl}/v1/models`, { - method: 'GET', - headers: { - Authorization: `Bearer ${this.apiKey}`, - }, - }) - if (response.ok) { - return { success: true } - } - // 尝试获取错误详情 - const errorText = await response.text() - let errorMessage = `HTTP ${response.status}` - try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.error?.message || errorJson.message || errorMessage - } catch { - if (errorText) { - errorMessage = errorText.slice(0, 200) - } - } - return { success: false, error: errorMessage } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return { success: false, error: errorMessage } - } - } -} diff --git a/electron/main/ai/llm/gemini.ts b/electron/main/ai/llm/gemini.ts deleted file mode 100644 index 5522d903b..000000000 --- a/electron/main/ai/llm/gemini.ts +++ /dev/null @@ -1,530 +0,0 @@ -/** - * Google Gemini LLM Provider - * 使用 Gemini REST API 格式,支持 Function Calling - */ - -import type { - ILLMService, - LLMProvider, - ChatMessage, - ChatOptions, - ChatResponse, - ChatStreamChunk, - ProviderInfo, - ToolCall, - ToolDefinition, -} from './types' -import { aiLogger } from '../logger' - -const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com' - -const MODELS = [ - { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash Preview', description: '高速预览版' }, - { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro Preview', description: '专业预览版' }, -] - -export const GEMINI_INFO: ProviderInfo = { - id: 'gemini', - name: 'Gemini', - description: 'Google Gemini 大语言模型', - defaultBaseUrl: DEFAULT_BASE_URL, - models: MODELS, -} - -// ==================== Gemini API 类型定义 ==================== - -/** Gemini 消息 part(支持多种类型) */ -interface GeminiPart { - text?: string - functionCall?: { - name: string - args: Record - } - functionResponse?: { - name: string - response: unknown - } - /** Gemini 3+ 模型的思考签名 */ - thoughtSignature?: string -} - -/** Gemini 消息内容 */ -interface GeminiContent { - role: 'user' | 'model' - parts: GeminiPart[] -} - -/** Gemini 函数声明(对应 OpenAI 的 ToolDefinition) */ -interface GeminiFunctionDeclaration { - name: string - description: string - parameters: { - type: string - properties: Record - required?: string[] - } -} - -/** Gemini 请求体 */ -interface GeminiRequest { - contents: GeminiContent[] - generationConfig?: { - temperature?: number - maxOutputTokens?: number - } - systemInstruction?: { - parts: Array<{ text: string }> - } - tools?: Array<{ - functionDeclarations: GeminiFunctionDeclaration[] - }> -} - -/** Gemini 响应候选项 */ -interface GeminiCandidate { - content?: { - parts?: GeminiPart[] - role?: string - } - finishReason?: string -} - -/** Gemini API 响应 */ -interface GeminiResponse { - candidates?: GeminiCandidate[] - usageMetadata?: { - promptTokenCount?: number - candidatesTokenCount?: number - totalTokenCount?: number - } -} - -// ==================== GeminiService 类 ==================== - -export class GeminiService implements ILLMService { - private apiKey: string - private baseUrl: string - private model: string - - constructor(apiKey: string, model?: string, baseUrl?: string) { - this.apiKey = apiKey - this.baseUrl = baseUrl || DEFAULT_BASE_URL - this.model = model || 'gemini-3-flash-preview' - } - - getProvider(): LLMProvider { - return 'gemini' - } - - getModels(): string[] { - return MODELS.map((m) => m.id) - } - - getDefaultModel(): string { - return 'gemini-3-flash-preview' - } - - /** - * 将 OpenAI 格式的 tools 转换为 Gemini 格式 - */ - private convertTools(tools?: ToolDefinition[]): Array<{ functionDeclarations: GeminiFunctionDeclaration[] }> | undefined { - if (!tools || tools.length === 0) return undefined - - const functionDeclarations: GeminiFunctionDeclaration[] = tools.map((tool) => ({ - name: tool.function.name, - description: tool.function.description, - parameters: tool.function.parameters, - })) - - return [{ functionDeclarations }] - } - - /** - * 将 OpenAI 格式消息转换为 Gemini 格式 - */ - private convertMessages(messages: ChatMessage[]): { - contents: GeminiContent[] - systemInstruction?: { parts: Array<{ text: string }> } - } { - const contents: GeminiContent[] = [] - let systemInstruction: { parts: Array<{ text: string }> } | undefined - - for (const msg of messages) { - if (msg.role === 'system') { - // Gemini 使用 systemInstruction 处理系统提示 - systemInstruction = { - parts: [{ text: msg.content }], - } - } else if (msg.role === 'user') { - contents.push({ - role: 'user', - parts: [{ text: msg.content }], - }) - } else if (msg.role === 'assistant') { - // 处理 assistant 消息(可能包含 tool_calls) - if (msg.tool_calls && msg.tool_calls.length > 0) { - // 有工具调用的 assistant 消息 - const parts: GeminiPart[] = [] - if (msg.content) { - parts.push({ text: msg.content }) - } - for (const tc of msg.tool_calls) { - const part: GeminiPart = { - functionCall: { - name: tc.function.name, - args: JSON.parse(tc.function.arguments), - }, - } - // Gemini 3+ 需要包含 thoughtSignature - if (tc.thoughtSignature) { - part.thoughtSignature = tc.thoughtSignature - } - parts.push(part) - } - contents.push({ role: 'model', parts }) - } else { - // 普通文本消息 - contents.push({ - role: 'model', - parts: [{ text: msg.content }], - }) - } - } else if (msg.role === 'tool') { - // 工具结果消息 - 在 Gemini 中作为 user 角色的 functionResponse - // 注意:需要从消息内容解析工具名称和结果 - // tool_call_id 格式通常是 "call_xxx",我们需要从上下文获取工具名 - // 这里简化处理:假设内容是 JSON 格式的结果 - try { - const result = JSON.parse(msg.content) - // 尝试从上一条 assistant 消息中找到对应的 tool_call - // 由于 Gemini 需要 name,我们从 tool_call_id 推断或使用默认值 - contents.push({ - role: 'user', - parts: [ - { - functionResponse: { - name: msg.tool_call_id?.replace('call_', '') || 'unknown', - response: result, - }, - }, - ], - }) - } catch { - // 如果不是 JSON,直接作为文本结果 - contents.push({ - role: 'user', - parts: [ - { - functionResponse: { - name: msg.tool_call_id?.replace('call_', '') || 'unknown', - response: { result: msg.content }, - }, - }, - ], - }) - } - } - } - - return { contents, systemInstruction } - } - - /** - * 构建 API URL - */ - private buildUrl(stream: boolean): string { - const action = stream ? 'streamGenerateContent' : 'generateContent' - const base = this.baseUrl.replace(/\/$/, '') - return `${base}/v1beta/models/${this.model}:${action}?key=${this.apiKey}` - } - - /** - * 从 Gemini parts 中提取工具调用 - */ - private extractToolCalls(parts?: GeminiPart[]): ToolCall[] | undefined { - if (!parts) return undefined - - const toolCalls: ToolCall[] = [] - for (const part of parts) { - if (part.functionCall) { - toolCalls.push({ - id: `call_${part.functionCall.name}_${Date.now()}`, - type: 'function', - function: { - name: part.functionCall.name, - arguments: JSON.stringify(part.functionCall.args), - }, - // 保存 Gemini 3+ 的思考签名 - thoughtSignature: part.thoughtSignature, - }) - } - } - - return toolCalls.length > 0 ? toolCalls : undefined - } - - /** - * 从 Gemini parts 中提取文本内容 - */ - private extractText(parts?: GeminiPart[]): string { - if (!parts) return '' - return parts - .filter((p) => p.text) - .map((p) => p.text) - .join('') - } - - async chat(messages: ChatMessage[], options?: ChatOptions): Promise { - const { contents, systemInstruction } = this.convertMessages(messages) - - const requestBody: GeminiRequest = { - contents, - generationConfig: { - temperature: options?.temperature ?? 0.7, - maxOutputTokens: options?.maxTokens ?? 2048, - }, - } - - if (systemInstruction) { - requestBody.systemInstruction = systemInstruction - } - - // 添加工具定义 - const geminiTools = this.convertTools(options?.tools) - if (geminiTools) { - requestBody.tools = geminiTools - } - - const response = await fetch(this.buildUrl(false), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: options?.abortSignal, - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Gemini API error: ${response.status} - ${error}`) - } - - const data: GeminiResponse = await response.json() - const candidate = data.candidates?.[0] - const parts = candidate?.content?.parts - const content = this.extractText(parts) - const toolCalls = this.extractToolCalls(parts) - - // 解析 finish_reason - let finishReason: ChatResponse['finishReason'] = 'error' - const reason = candidate?.finishReason - if (reason === 'STOP') { - finishReason = toolCalls ? 'tool_calls' : 'stop' - } else if (reason === 'MAX_TOKENS') { - finishReason = 'length' - } - - return { - content, - finishReason, - tool_calls: toolCalls, - usage: data.usageMetadata - ? { - promptTokens: data.usageMetadata.promptTokenCount || 0, - completionTokens: data.usageMetadata.candidatesTokenCount || 0, - totalTokens: data.usageMetadata.totalTokenCount || 0, - } - : undefined, - } - } - - async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator { - const { contents, systemInstruction } = this.convertMessages(messages) - - const requestBody: GeminiRequest = { - contents, - generationConfig: { - temperature: options?.temperature ?? 0.7, - maxOutputTokens: options?.maxTokens ?? 2048, - }, - } - - if (systemInstruction) { - requestBody.systemInstruction = systemInstruction - } - - // 添加工具定义 - const geminiTools = this.convertTools(options?.tools) - if (geminiTools) { - requestBody.tools = geminiTools - } - - // Gemini 流式需要添加 alt=sse 参数 - const url = this.buildUrl(true) + '&alt=sse' - - aiLogger.info('Gemini', '开始流式请求', { - url: url.replace(/key=[^&]+/, 'key=***'), - model: this.model, - messagesCount: contents.length, - hasSystemInstruction: !!systemInstruction, - hasTools: !!geminiTools, - }) - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - signal: options?.abortSignal, - }) - - if (!response.ok) { - const error = await response.text() - aiLogger.error('Gemini', 'API 请求失败', { status: response.status, error: error.slice(0, 500) }) - throw new Error(`Gemini API error: ${response.status} - ${error}`) - } - - aiLogger.info('Gemini', 'API 响应成功,开始读取流') - - const reader = response.body?.getReader() - if (!reader) { - throw new Error('Failed to get response reader') - } - - const decoder = new TextDecoder() - let buffer = '' - // 用于累积工具调用(可能跨多个 chunk) - const toolCallsAccumulator: ToolCall[] = [] - // 用于追踪最新的 usage 信息 - let latestUsage: { promptTokens: number; completionTokens: number; totalTokens: number } | undefined - - try { - while (true) { - // 检查是否已中止 - if (options?.abortSignal?.aborted) { - yield { content: '', isFinished: true, finishReason: 'stop' } - return - } - - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - const trimmed = line.trim() - - if (!trimmed || !trimmed.startsWith('data: ')) continue - - const data = trimmed.slice(6) - if (data === '[DONE]') { - if (toolCallsAccumulator.length > 0) { - yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCallsAccumulator } - } else { - yield { content: '', isFinished: true, finishReason: 'stop' } - } - return - } - - try { - const parsed: GeminiResponse = JSON.parse(data) - const candidate = parsed.candidates?.[0] - const parts = candidate?.content?.parts - - // 处理文本内容 - const text = this.extractText(parts) - if (text) { - yield { content: text, isFinished: false } - } - - // 处理工具调用 - const toolCalls = this.extractToolCalls(parts) - if (toolCalls) { - aiLogger.info('Gemini', '检测到工具调用', { toolCalls: toolCalls.map((tc) => tc.function.name) }) - toolCallsAccumulator.push(...toolCalls) - } - - // 更新 usage 信息 - if (parsed.usageMetadata) { - latestUsage = { - promptTokens: parsed.usageMetadata.promptTokenCount || 0, - completionTokens: parsed.usageMetadata.candidatesTokenCount || 0, - totalTokens: parsed.usageMetadata.totalTokenCount || 0, - } - } - - // 检查是否完成 - const finishReason = candidate?.finishReason - if (finishReason) { - aiLogger.info('Gemini', '流式响应完成', { finishReason, toolCallsCount: toolCallsAccumulator.length, usage: latestUsage }) - - if (toolCallsAccumulator.length > 0) { - yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCallsAccumulator, usage: latestUsage } - } else { - let reason: ChatStreamChunk['finishReason'] = 'stop' - if (finishReason === 'MAX_TOKENS') { - reason = 'length' - } - yield { content: '', isFinished: true, finishReason: reason, usage: latestUsage } - } - return - } - } catch (e) { - // 记录解析错误 - aiLogger.warn('Gemini', 'SSE 数据解析失败', { data: data.slice(0, 200), error: String(e) }) - } - } - } - - // 如果循环正常结束,发送完成信号 - if (toolCallsAccumulator.length > 0) { - yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCallsAccumulator, usage: latestUsage } - } else { - yield { content: '', isFinished: true, finishReason: 'stop', usage: latestUsage } - } - } catch (error) { - // 如果是中止错误,正常返回 - if (error instanceof Error && error.name === 'AbortError') { - yield { content: '', isFinished: true, finishReason: 'stop' } - return - } - throw error - } finally { - reader.releaseLock() - } - } - - async validateApiKey(): Promise<{ success: boolean; error?: string }> { - try { - // 使用 models.list API 验证 API Key - const url = `${this.baseUrl.replace(/\/$/, '')}/v1beta/models?key=${this.apiKey}` - - const response = await fetch(url, { - method: 'GET', - }) - - if (response.ok) { - return { success: true } - } - - // 尝试获取错误详情 - const errorText = await response.text() - let errorMessage = `HTTP ${response.status}` - try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.error?.message || errorJson.message || errorMessage - } catch { - if (errorText) { - errorMessage = errorText.slice(0, 200) - } - } - return { success: false, error: errorMessage } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return { success: false, error: errorMessage } - } - } -} diff --git a/electron/main/ai/llm/index.ts b/electron/main/ai/llm/index.ts deleted file mode 100644 index 62982efa1..000000000 --- a/electron/main/ai/llm/index.ts +++ /dev/null @@ -1,491 +0,0 @@ -/** - * LLM 服务模块入口 - * 提供统一的 LLM 服务管理(支持多配置) - */ - -import * as fs from 'fs' -import * as path from 'path' -import { randomUUID } from 'crypto' -import { getAiDataDir, ensureDir } from '../../paths' -import type { - LLMConfig, - LLMProvider, - ILLMService, - ProviderInfo, - ChatMessage, - ChatOptions, - ChatStreamChunk, - AIServiceConfig, - AIConfigStore, -} from './types' -import { MAX_CONFIG_COUNT } from './types' -import { DeepSeekService, DEEPSEEK_INFO } from './deepseek' -import { QwenService, QWEN_INFO } from './qwen' -import { GeminiService, GEMINI_INFO } from './gemini' -import { OpenAICompatibleService, OPENAI_COMPATIBLE_INFO } from './openai-compatible' -import { aiLogger } from '../logger' - -// 导出类型 -export * from './types' - -// ==================== 新增提供商信息 ==================== - -/** MiniMax 提供商信息 */ -const MINIMAX_INFO: ProviderInfo = { - id: 'minimax', - name: 'MiniMax', - description: 'MiniMax 大语言模型,支持多模态和长上下文', - defaultBaseUrl: 'https://api.minimaxi.com/v1', - models: [ - { id: 'MiniMax-M2', name: 'MiniMax-M2', description: '旗舰模型' }, - { id: 'MiniMax-M2-Stable', name: 'MiniMax-M2-Stable', description: '稳定版本' }, - ], -} - -/** 智谱 GLM 提供商信息 */ -const GLM_INFO: ProviderInfo = { - id: 'glm', - name: 'GLM', - description: '智谱 AI 大语言模型,ChatGLM 系列', - defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', - models: [ - { id: 'glm-4-plus', name: 'GLM-4-Plus', description: '旗舰模型,效果最佳' }, - { id: 'glm-4-flash', name: 'GLM-4-Flash', description: '高速模型,性价比高' }, - { id: 'glm-4', name: 'GLM-4', description: '标准模型' }, - { id: 'glm-4v-plus', name: 'GLM-4V-Plus', description: '多模态视觉模型' }, - { id: 'glm-4.6v-flash', name: '4.6V免费版', description: '4.6V免费版模型' }, - { id: 'glm-4.5-flash', name: '4.5免费版', description: '4.5免费版模型' }, - ], -} - -/** Kimi (月之暗面 Moonshot) 提供商信息 */ -const KIMI_INFO: ProviderInfo = { - id: 'kimi', - name: 'Kimi', - description: 'Moonshot AI 大语言模型,支持超长上下文', - defaultBaseUrl: 'https://api.moonshot.cn/v1', - models: [ - { id: 'moonshot-v1-8k', name: 'Moonshot-V1-8K', description: '8K 上下文' }, - { id: 'moonshot-v1-32k', name: 'Moonshot-V1-32K', description: '32K 上下文' }, - { id: 'moonshot-v1-128k', name: 'Moonshot-V1-128K', description: '128K 超长上下文' }, - ], -} - -/** 豆包 (字节跳动 ByteDance) 提供商信息 */ -const DOUBAO_INFO: ProviderInfo = { - id: 'doubao', - name: '豆包', - description: '字节跳动豆包 AI 大语言模型', - defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', - models: [ - { id: 'doubao-seed-1-6-lite-251015', name: '豆包1.6-lite', description: '豆包1.6模型,性价比' }, - { id: 'doubao-seed-1-6-251015', name: '豆包1.6', description: '更强豆包1.6模型' }, - { id: 'doubao-seed-1-6-flash-250828', name: '豆包1.6-flash', description: '更快的豆包1.6模型' }, - { id: 'doubao-1-5-lite-32k-250115', name: '豆包1.5-lite', description: '豆包1.5Pro模型模型' }, - ], -} - -// 所有支持的提供商信息 -export const PROVIDERS: ProviderInfo[] = [ - DEEPSEEK_INFO, - QWEN_INFO, - GEMINI_INFO, - MINIMAX_INFO, - GLM_INFO, - KIMI_INFO, - DOUBAO_INFO, - OPENAI_COMPATIBLE_INFO, -] - -// 配置文件路径 -let CONFIG_PATH: string | null = null - -function getConfigPath(): string { - if (CONFIG_PATH) return CONFIG_PATH - CONFIG_PATH = path.join(getAiDataDir(), 'llm-config.json') - return CONFIG_PATH -} - -// ==================== 旧配置格式(用于迁移)==================== - -interface LegacyStoredConfig { - provider: LLMProvider - apiKey: string - model?: string - maxTokens?: number -} - -/** - * 检测是否为旧格式配置 - */ -function isLegacyConfig(data: unknown): data is LegacyStoredConfig { - if (!data || typeof data !== 'object') return false - const obj = data as Record - return 'provider' in obj && 'apiKey' in obj && !('configs' in obj) -} - -/** - * 迁移旧配置到新格式 - */ -function migrateLegacyConfig(legacy: LegacyStoredConfig): AIConfigStore { - const now = Date.now() - const newConfig: AIServiceConfig = { - id: randomUUID(), - name: getProviderInfo(legacy.provider)?.name || legacy.provider, - provider: legacy.provider, - apiKey: legacy.apiKey, - model: legacy.model, - maxTokens: legacy.maxTokens, - createdAt: now, - updatedAt: now, - } - - return { - configs: [newConfig], - activeConfigId: newConfig.id, - } -} - -// ==================== 多配置管理 ==================== - -/** - * 加载配置存储(自动处理迁移) - */ -export function loadConfigStore(): AIConfigStore { - const configPath = getConfigPath() - - if (!fs.existsSync(configPath)) { - return { configs: [], activeConfigId: null } - } - - try { - const content = fs.readFileSync(configPath, 'utf-8') - const data = JSON.parse(content) - - // 检查是否需要迁移 - if (isLegacyConfig(data)) { - aiLogger.info('LLM', '检测到旧配置格式,执行迁移') - const migrated = migrateLegacyConfig(data) - saveConfigStore(migrated) - return migrated - } - - return data as AIConfigStore - } catch { - return { configs: [], activeConfigId: null } - } -} - -/** - * 保存配置存储 - */ -export function saveConfigStore(store: AIConfigStore): void { - const configPath = getConfigPath() - const dir = path.dirname(configPath) - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - - fs.writeFileSync(configPath, JSON.stringify(store, null, 2), 'utf-8') -} - -/** - * 获取所有配置列表 - */ -export function getAllConfigs(): AIServiceConfig[] { - return loadConfigStore().configs -} - -/** - * 获取当前激活的配置 - */ -export function getActiveConfig(): AIServiceConfig | null { - const store = loadConfigStore() - if (!store.activeConfigId) return null - return store.configs.find((c) => c.id === store.activeConfigId) || null -} - -/** - * 获取单个配置 - */ -export function getConfigById(id: string): AIServiceConfig | null { - const store = loadConfigStore() - return store.configs.find((c) => c.id === id) || null -} - -/** - * 添加新配置 - */ -export function addConfig(config: Omit): { - success: boolean - config?: AIServiceConfig - error?: string -} { - const store = loadConfigStore() - - if (store.configs.length >= MAX_CONFIG_COUNT) { - return { success: false, error: `最多只能添加 ${MAX_CONFIG_COUNT} 个配置` } - } - - const now = Date.now() - const newConfig: AIServiceConfig = { - ...config, - id: randomUUID(), - createdAt: now, - updatedAt: now, - } - - store.configs.push(newConfig) - - // 如果是第一个配置,自动设为激活 - if (store.configs.length === 1) { - store.activeConfigId = newConfig.id - } - - saveConfigStore(store) - return { success: true, config: newConfig } -} - -/** - * 更新配置 - */ -export function updateConfig( - id: string, - updates: Partial> -): { success: boolean; error?: string } { - const store = loadConfigStore() - const index = store.configs.findIndex((c) => c.id === id) - - if (index === -1) { - return { success: false, error: '配置不存在' } - } - - store.configs[index] = { - ...store.configs[index], - ...updates, - updatedAt: Date.now(), - } - - saveConfigStore(store) - return { success: true } -} - -/** - * 删除配置 - */ -export function deleteConfig(id: string): { success: boolean; error?: string } { - const store = loadConfigStore() - const index = store.configs.findIndex((c) => c.id === id) - - if (index === -1) { - return { success: false, error: '配置不存在' } - } - - store.configs.splice(index, 1) - - // 如果删除的是当前激活的配置,选择第一个作为新的激活配置 - if (store.activeConfigId === id) { - store.activeConfigId = store.configs.length > 0 ? store.configs[0].id : null - } - - saveConfigStore(store) - return { success: true } -} - -/** - * 设置激活的配置 - */ -export function setActiveConfig(id: string): { success: boolean; error?: string } { - const store = loadConfigStore() - const config = store.configs.find((c) => c.id === id) - - if (!config) { - return { success: false, error: '配置不存在' } - } - - store.activeConfigId = id - saveConfigStore(store) - return { success: true } -} - -/** - * 检查是否有激活的配置 - */ -export function hasActiveConfig(): boolean { - const config = getActiveConfig() - return config !== null -} - -/** - * 扩展的 LLM 配置(包含本地服务特有选项) - */ -interface ExtendedLLMConfig extends LLMConfig { - disableThinking?: boolean -} - -/** - * 创建 LLM 服务实例 - */ -export function createLLMService(config: ExtendedLLMConfig): ILLMService { - // 获取提供商的默认 baseUrl - const providerInfo = getProviderInfo(config.provider) - const baseUrl = config.baseUrl || providerInfo?.defaultBaseUrl - - switch (config.provider) { - case 'deepseek': - return new DeepSeekService(config.apiKey, config.model, config.baseUrl) - case 'qwen': - return new QwenService(config.apiKey, config.model, config.baseUrl) - case 'gemini': - return new GeminiService(config.apiKey, config.model, config.baseUrl) - // 新增的官方API都使用 OpenAI 兼容格式 - case 'minimax': - case 'glm': - case 'kimi': - case 'doubao': - return new OpenAICompatibleService(config.apiKey, config.model, baseUrl) - case 'openai-compatible': - return new OpenAICompatibleService(config.apiKey, config.model, config.baseUrl, config.disableThinking) - default: - throw new Error(`Unknown LLM provider: ${config.provider}`) - } -} - -/** - * 获取当前配置的 LLM 服务实例 - */ -export function getCurrentLLMService(): ILLMService | null { - const activeConfig = getActiveConfig() - if (!activeConfig) { - return null - } - - return createLLMService({ - provider: activeConfig.provider, - apiKey: activeConfig.apiKey, - model: activeConfig.model, - baseUrl: activeConfig.baseUrl, - maxTokens: activeConfig.maxTokens, - disableThinking: activeConfig.disableThinking, - }) -} - -/** - * 获取提供商信息 - */ -export function getProviderInfo(provider: LLMProvider): ProviderInfo | null { - return PROVIDERS.find((p) => p.id === provider) || null -} - -/** - * 验证 API Key - */ -export async function validateApiKey( - provider: LLMProvider, - apiKey: string -): Promise<{ success: boolean; error?: string }> { - const service = createLLMService({ provider, apiKey }) - return service.validateApiKey() -} - -/** - * 发送聊天请求(使用当前配置) - * 返回完整的 ChatResponse 对象,包含 finishReason 和 tool_calls - */ -export async function chat( - messages: ChatMessage[], - options?: ChatOptions -): Promise<{ content: string; finishReason: string; tool_calls?: import('./types').ToolCall[] }> { - aiLogger.info('LLM', '开始非流式聊天请求', { - messagesCount: messages.length, - firstMessageRole: messages[0]?.role, - firstMessageLength: messages[0]?.content?.length, - options, - }) - - const service = getCurrentLLMService() - if (!service) { - aiLogger.error('LLM', '服务未配置') - throw new Error('LLM 服务未配置,请先在设置中配置 API Key') - } - - aiLogger.info('LLM', `使用提供商: ${service.getProvider()}`) - - try { - const response = await service.chat(messages, options) - aiLogger.info('LLM', '非流式请求成功', { - contentLength: response.content?.length, - finishReason: response.finishReason, - usage: response.usage, - }) - return response - } catch (error) { - aiLogger.error('LLM', '非流式请求失败', { error: String(error) }) - throw error - } -} - -/** - * 发送聊天请求(流式,使用当前配置) - */ -export async function* chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator { - aiLogger.info('LLM', '开始流式聊天请求', { - messagesCount: messages.length, - firstMessageRole: messages[0]?.role, - firstMessageLength: messages[0]?.content?.length, - options, - }) - - const service = getCurrentLLMService() - if (!service) { - aiLogger.error('LLM', '服务未配置(流式)') - throw new Error('LLM 服务未配置,请先在设置中配置 API Key') - } - - aiLogger.info('LLM', `使用提供商(流式): ${service.getProvider()}`) - - let chunkCount = 0 - let totalContent = '' - - let receivedFinish = false - let contentChunkCount = 0 - - try { - for await (const chunk of service.chatStream(messages, options)) { - chunkCount++ - totalContent += chunk.content - - // 追踪内容 chunk - if (chunk.content) { - contentChunkCount++ - } - - yield chunk - - if (chunk.isFinished) { - receivedFinish = true - aiLogger.info('LLM', '流式请求完成', { - chunkCount, - contentChunkCount, - totalContentLength: totalContent.length, - finishReason: chunk.finishReason, - }) - } - } - - // 如果循环正常结束但没有收到 isFinished 的 chunk,记录警告 - if (chunkCount > 0 && !receivedFinish) { - aiLogger.warn('LLM', '流式请求循环结束但未收到完成信号', { - chunkCount, - totalContentLength: totalContent.length, - }) - } - } catch (error) { - aiLogger.error('LLM', '流式请求失败', { - error: String(error), - chunkCountBeforeError: chunkCount, - }) - throw error - } -} diff --git a/electron/main/ai/llm/openai-compatible.ts b/electron/main/ai/llm/openai-compatible.ts deleted file mode 100644 index bf172956f..000000000 --- a/electron/main/ai/llm/openai-compatible.ts +++ /dev/null @@ -1,443 +0,0 @@ -/** - * OpenAI Compatible LLM Provider - * 支持任何兼容 OpenAI API 格式的服务(如 Ollama、LocalAI、vLLM 等) - */ - -import type { - ILLMService, - LLMProvider, - ChatMessage, - ChatOptions, - ChatResponse, - ChatStreamChunk, - ProviderInfo, - ToolCall, -} from './types' -import { aiLogger } from '../logger' - -const DEFAULT_BASE_URL = 'http://localhost:11434/v1' - -export const OPENAI_COMPATIBLE_INFO: ProviderInfo = { - id: 'openai-compatible', - name: 'OpenAI 兼容', - description: '支持任何兼容 OpenAI API 的服务(如 Ollama、LocalAI、vLLM 等)', - defaultBaseUrl: DEFAULT_BASE_URL, - models: [ - { id: 'llama3.2', name: 'Llama 3.2', description: 'Meta Llama 3.2 模型' }, - { id: 'qwen2.5', name: 'Qwen 2.5', description: '通义千问 2.5 模型' }, - { id: 'deepseek-r1', name: 'DeepSeek R1', description: 'DeepSeek R1 推理模型' }, - ], -} - -export class OpenAICompatibleService implements ILLMService { - private apiKey: string - private baseUrl: string - private model: string - private disableThinking: boolean - - constructor(apiKey: string, model?: string, baseUrl?: string, disableThinking?: boolean) { - this.apiKey = apiKey || 'sk-no-key-required' // 本地服务可能不需要 API Key - // 智能处理 baseUrl:如果用户已经包含 /chat/completions,则去掉它 - let processedBaseUrl = baseUrl || DEFAULT_BASE_URL - processedBaseUrl = processedBaseUrl.replace(/\/+$/, '') // 去掉尾部斜杠 - if (processedBaseUrl.endsWith('/chat/completions')) { - processedBaseUrl = processedBaseUrl.slice(0, -'/chat/completions'.length) - } - this.baseUrl = processedBaseUrl - this.model = model || 'llama3.2' - this.disableThinking = disableThinking ?? true // 默认禁用思考模式 - } - - /** - * 设置 Bearer Token 认证头 - */ - private setAuthHeaders(headers: Record): void { - if (this.apiKey && this.apiKey !== 'sk-no-key-required') { - headers['Authorization'] = `Bearer ${this.apiKey}` - } - } - - getProvider(): LLMProvider { - return 'openai-compatible' - } - - getModels(): string[] { - return OPENAI_COMPATIBLE_INFO.models.map((m) => m.id) - } - - getDefaultModel(): string { - return 'llama3.2' - } - - async chat(messages: ChatMessage[], options?: ChatOptions): Promise { - const requestBody: Record = { - model: this.model, - messages: messages.map((m) => { - const msg: Record = { role: m.role, content: m.content } - if (m.role === 'tool' && m.tool_call_id) { - msg.tool_call_id = m.tool_call_id - } - if (m.role === 'assistant' && m.tool_calls) { - // 确保 thoughtSignature 被传递(Gemini 3+ 通过 OpenAI 兼容 API 需要) - msg.tool_calls = m.tool_calls.map((tc) => ({ - ...tc, - // 如果没有签名,使用虚拟签名(用于 Vertex AI/Gemini 后端) - thought_signature: tc.thoughtSignature || 'context_engineering_is_the_way_to_go', - })) - } - return msg - }), - temperature: options?.temperature ?? 0.7, - max_tokens: options?.maxTokens ?? 2048, - stream: false, - } - - if (options?.tools && options.tools.length > 0) { - requestBody.tools = options.tools - } - - // 禁用思考模式(用于 Qwen3、DeepSeek-R1 等本地模型) - if (this.disableThinking) { - requestBody.chat_template_kwargs = { enable_thinking: false } - } - - const headers: Record = { - 'Content-Type': 'application/json', - } - this.setAuthHeaders(headers) - - const response = await fetch(`${this.baseUrl}/chat/completions`, { - method: 'POST', - headers, - body: JSON.stringify(requestBody), - signal: options?.abortSignal, - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`OpenAI Compatible API error: ${response.status} - ${error}`) - } - - const data = await response.json() - const choice = data.choices?.[0] - const message = choice?.message - - let finishReason: ChatResponse['finishReason'] = 'error' - if (choice?.finish_reason === 'stop') { - finishReason = 'stop' - } else if (choice?.finish_reason === 'length') { - finishReason = 'length' - } else if (choice?.finish_reason === 'tool_calls') { - finishReason = 'tool_calls' - } - - let toolCalls: ToolCall[] | undefined - if (message?.tool_calls && Array.isArray(message.tool_calls)) { - toolCalls = message.tool_calls.map((tc: Record) => ({ - id: tc.id as string, - type: 'function' as const, - function: { - name: (tc.function as Record)?.name as string, - arguments: (tc.function as Record)?.arguments as string, - }, - // 提取 thoughtSignature(Gemini 3+ 通过 OpenAI 兼容 API 可能返回此字段) - thoughtSignature: (tc.thought_signature || tc.thoughtSignature) as string | undefined, - })) - } - - return { - content: message?.content || '', - finishReason, - tool_calls: toolCalls, - usage: data.usage - ? { - promptTokens: data.usage.prompt_tokens, - completionTokens: data.usage.completion_tokens, - totalTokens: data.usage.total_tokens, - } - : undefined, - } - } - - async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator { - const requestBody: Record = { - model: this.model, - messages: messages.map((m) => { - const msg: Record = { role: m.role, content: m.content } - if (m.role === 'tool' && m.tool_call_id) { - msg.tool_call_id = m.tool_call_id - } - if (m.role === 'assistant' && m.tool_calls) { - // 确保 thoughtSignature 被传递(Gemini 3+ 通过 OpenAI 兼容 API 需要) - msg.tool_calls = m.tool_calls.map((tc) => ({ - ...tc, - // 如果没有签名,使用虚拟签名(用于 Vertex AI/Gemini 后端) - thought_signature: tc.thoughtSignature || 'context_engineering_is_the_way_to_go', - })) - } - return msg - }), - temperature: options?.temperature ?? 0.7, - max_tokens: options?.maxTokens ?? 2048, - stream: true, - // 启用流式响应中的 usage 统计(OpenAI API 兼容) - stream_options: { include_usage: true }, - } - - if (options?.tools && options.tools.length > 0) { - requestBody.tools = options.tools - } - - // 禁用思考模式(用于 Qwen3、DeepSeek-R1 等本地模型) - if (this.disableThinking) { - requestBody.chat_template_kwargs = { enable_thinking: false } - } - - const headers: Record = { - 'Content-Type': 'application/json', - } - this.setAuthHeaders(headers) - - const response = await fetch(`${this.baseUrl}/chat/completions`, { - method: 'POST', - headers, - body: JSON.stringify(requestBody), - signal: options?.abortSignal, - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`OpenAI Compatible API error: ${response.status} - ${error}`) - } - - const reader = response.body?.getReader() - if (!reader) { - throw new Error('Failed to get response reader') - } - - const decoder = new TextDecoder() - let buffer = '' - const toolCallsAccumulator: Map = new Map() - - let totalChunks = 0 - let totalContent = '' - - try { - while (true) { - // 检查是否已中止 - if (options?.abortSignal?.aborted) { - yield { content: '', isFinished: true, finishReason: 'stop' } - return - } - - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed || !trimmed.startsWith('data: ')) continue - - const data = trimmed.slice(6) - - if (data === '[DONE]') { - if (toolCallsAccumulator.size > 0) { - const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({ - id: tc.id, - type: 'function' as const, - function: { - name: tc.name, - arguments: tc.arguments, - }, - // 传递 thoughtSignature(如果存在) - thoughtSignature: tc.thoughtSignature, - })) - yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCalls } - } else { - yield { content: '', isFinished: true, finishReason: 'stop' } - } - return - } - - try { - const parsed = JSON.parse(data) - const delta = parsed.choices?.[0]?.delta - const finishReason = parsed.choices?.[0]?.finish_reason - - // 调试:如果有 delta 但没有 content,记录其他可能的内容字段(只写日志文件,不输出控制台) - if (delta && !delta.content && !delta.tool_calls && !finishReason) { - const deltaKeys = Object.keys(delta) - if (deltaKeys.length > 0 && !deltaKeys.every((k) => ['role', 'name', 'audio_content'].includes(k))) { - aiLogger.debug('OpenAI-Compatible', '检测到未处理的 delta 字段', { deltaKeys, delta }) - } - } - - if (delta?.content) { - totalChunks++ - totalContent += delta.content - yield { - content: delta.content, - isFinished: false, - } - } - - if (delta?.tool_calls && Array.isArray(delta.tool_calls)) { - for (const tc of delta.tool_calls) { - const index = tc.index ?? 0 - const existing = toolCallsAccumulator.get(index) - if (existing) { - if (tc.function?.arguments) { - existing.arguments += tc.function.arguments - } - // 更新 thoughtSignature(如果存在) - if (tc.thought_signature || tc.thoughtSignature) { - existing.thoughtSignature = tc.thought_signature || tc.thoughtSignature - } - } else { - toolCallsAccumulator.set(index, { - id: tc.id || '', - name: tc.function?.name || '', - arguments: tc.function?.arguments || '', - // 提取 thoughtSignature(Gemini 3+ 通过 OpenAI 兼容 API 可能返回此字段) - // 如果 API 不返回,使用 Gemini 文档提供的虚拟签名绕过验证 - thoughtSignature: tc.thought_signature || tc.thoughtSignature || 'context_engineering_is_the_way_to_go', - }) - } - } - } - - if (finishReason) { - let reason: ChatStreamChunk['finishReason'] = 'error' - if (finishReason === 'stop') { - reason = 'stop' - } else if (finishReason === 'length') { - reason = 'length' - } else if (finishReason === 'tool_calls') { - reason = 'tool_calls' - } - - // 解析 usage 信息 - const usage = parsed.usage - ? { - promptTokens: parsed.usage.prompt_tokens, - completionTokens: parsed.usage.completion_tokens, - totalTokens: parsed.usage.total_tokens, - } - : undefined - - if (toolCallsAccumulator.size > 0) { - const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({ - id: tc.id, - type: 'function' as const, - function: { - name: tc.name, - arguments: tc.arguments, - }, - // 传递 thoughtSignature(如果存在) - thoughtSignature: tc.thoughtSignature, - })) - yield { content: '', isFinished: true, finishReason: reason, tool_calls: toolCalls, usage } - } else { - yield { content: '', isFinished: true, finishReason: reason, usage } - } - return - } - } catch { - // 忽略解析错误,继续处理下一行 - } - } - } - - // 流读取完成后的处理(如果没有收到 [DONE] 或 finish_reason) - // 这种情况可能发生在某些 API 不发送标准结束标记时 - aiLogger.info('OpenAI-Compatible', '流循环结束,执行兜底处理', { - totalChunks, - totalContentLength: totalContent.length, - toolCallsCount: toolCallsAccumulator.size, - bufferRemaining: buffer.length, - }) - - // 如果有累积的 tool_calls,发送它们 - if (toolCallsAccumulator.size > 0) { - const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({ - id: tc.id, - type: 'function' as const, - function: { - name: tc.name, - arguments: tc.arguments, - }, - // 传递 thoughtSignature(如果存在) - thoughtSignature: tc.thoughtSignature, - })) - yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCalls } - } else { - // 没有 tool_calls,发送普通完成信号 - yield { content: '', isFinished: true, finishReason: 'stop' } - } - } catch (error) { - // 如果是中止错误,正常返回 - if (error instanceof Error && error.name === 'AbortError') { - yield { content: '', isFinished: true, finishReason: 'stop' } - return - } - aiLogger.error('OpenAI-Compatible', '流处理异常', { error: String(error) }) - throw error - } finally { - reader.releaseLock() - } - } - - async validateApiKey(): Promise<{ success: boolean; error?: string }> { - try { - const headers: Record = { - 'Content-Type': 'application/json', - } - this.setAuthHeaders(headers) - - const url = `${this.baseUrl}/chat/completions` - - // 发送一个简单的测试请求来验证连接和认证 - const response = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify({ - model: this.model, - messages: [{ role: 'user', content: 'Hi' }], - max_tokens: 1, - }), - }) - - // 200 表示成功,401/403 表示认证失败,其他状态可能是参数问题但服务可达 - if (response.ok) { - return { success: true } - } - - // 尝试获取错误详情 - const errorText = await response.text() - let errorMessage = `HTTP ${response.status}` - try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.error?.message || errorJson.message || errorMessage - } catch { - if (errorText) { - errorMessage = errorText.slice(0, 200) - } - } - - // 认证失败 - if (response.status === 401 || response.status === 403) { - return { success: false, error: errorMessage } - } - - // 其他错误(如 400 参数错误)但服务可达,认为验证通过 - // 因为这说明认证成功了,只是请求参数有问题 - return { success: true } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return { success: false, error: errorMessage } - } - } -} diff --git a/electron/main/ai/llm/qwen.ts b/electron/main/ai/llm/qwen.ts deleted file mode 100644 index b063e71ad..000000000 --- a/electron/main/ai/llm/qwen.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * 通义千问 (Qwen) LLM Provider - * 使用阿里云 DashScope 兼容 OpenAI 格式的 API,支持 Function Calling - */ - -import type { - ILLMService, - LLMProvider, - ChatMessage, - ChatOptions, - ChatResponse, - ChatStreamChunk, - ProviderInfo, - ToolCall, -} from './types' - -const DEFAULT_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1' - -const MODELS = [ - { id: 'qwen-turbo', name: 'Qwen Turbo', description: '通义千问超大规模语言模型,速度快' }, - { id: 'qwen-plus', name: 'Qwen Plus', description: '通义千问超大规模语言模型,效果好' }, - { id: 'qwen-max', name: 'Qwen Max', description: '通义千问千亿级别超大规模语言模型' }, -] - -export const QWEN_INFO: ProviderInfo = { - id: 'qwen', - name: '通义千问', - description: '阿里云通义千问大语言模型', - defaultBaseUrl: DEFAULT_BASE_URL, - models: MODELS, -} - -export class QwenService implements ILLMService { - private apiKey: string - private baseUrl: string - private model: string - - constructor(apiKey: string, model?: string, baseUrl?: string) { - this.apiKey = apiKey - this.baseUrl = baseUrl || DEFAULT_BASE_URL - this.model = model || 'qwen-turbo' - } - - getProvider(): LLMProvider { - return 'qwen' - } - - getModels(): string[] { - return MODELS.map((m) => m.id) - } - - getDefaultModel(): string { - return 'qwen-turbo' - } - - async chat(messages: ChatMessage[], options?: ChatOptions): Promise { - // 构建请求体 - const requestBody: Record = { - model: this.model, - messages: messages.map((m) => { - const msg: Record = { role: m.role, content: m.content } - if (m.role === 'tool' && m.tool_call_id) { - msg.tool_call_id = m.tool_call_id - } - if (m.role === 'assistant' && m.tool_calls) { - msg.tool_calls = m.tool_calls - } - return msg - }), - temperature: options?.temperature ?? 0.7, - max_tokens: options?.maxTokens ?? 2048, - stream: false, - } - - if (options?.tools && options.tools.length > 0) { - requestBody.tools = options.tools - } - - const response = await fetch(`${this.baseUrl}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify(requestBody), - signal: options?.abortSignal, - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Qwen API error: ${response.status} - ${error}`) - } - - const data = await response.json() - const choice = data.choices?.[0] - const message = choice?.message - - // 解析 finish_reason - let finishReason: ChatResponse['finishReason'] = 'error' - if (choice?.finish_reason === 'stop') { - finishReason = 'stop' - } else if (choice?.finish_reason === 'length') { - finishReason = 'length' - } else if (choice?.finish_reason === 'tool_calls') { - finishReason = 'tool_calls' - } - - // 解析 tool_calls - let toolCalls: ToolCall[] | undefined - if (message?.tool_calls && Array.isArray(message.tool_calls)) { - toolCalls = message.tool_calls.map((tc: Record) => ({ - id: tc.id as string, - type: 'function' as const, - function: { - name: (tc.function as Record)?.name as string, - arguments: (tc.function as Record)?.arguments as string, - }, - })) - } - - return { - content: message?.content || '', - finishReason, - tool_calls: toolCalls, - usage: data.usage - ? { - promptTokens: data.usage.prompt_tokens, - completionTokens: data.usage.completion_tokens, - totalTokens: data.usage.total_tokens, - } - : undefined, - } - } - - async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator { - // 构建请求体 - const requestBody: Record = { - model: this.model, - messages: messages.map((m) => { - const msg: Record = { role: m.role, content: m.content } - if (m.role === 'tool' && m.tool_call_id) { - msg.tool_call_id = m.tool_call_id - } - if (m.role === 'assistant' && m.tool_calls) { - msg.tool_calls = m.tool_calls - } - return msg - }), - temperature: options?.temperature ?? 0.7, - max_tokens: options?.maxTokens ?? 2048, - stream: true, - // 启用流式响应中的 usage 统计 - stream_options: { include_usage: true }, - } - - if (options?.tools && options.tools.length > 0) { - requestBody.tools = options.tools - } - - const response = await fetch(`${this.baseUrl}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify(requestBody), - signal: options?.abortSignal, - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Qwen API error: ${response.status} - ${error}`) - } - - const reader = response.body?.getReader() - if (!reader) { - throw new Error('Failed to get response reader') - } - - const decoder = new TextDecoder() - let buffer = '' - const toolCallsAccumulator: Map = new Map() - - try { - while (true) { - // 检查是否已中止 - if (options?.abortSignal?.aborted) { - yield { content: '', isFinished: true, finishReason: 'stop' } - return - } - - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed || !trimmed.startsWith('data: ')) continue - - const data = trimmed.slice(6) - if (data === '[DONE]') { - if (toolCallsAccumulator.size > 0) { - const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({ - id: tc.id, - type: 'function' as const, - function: { name: tc.name, arguments: tc.arguments }, - })) - yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCalls } - } else { - yield { content: '', isFinished: true, finishReason: 'stop' } - } - return - } - - try { - const parsed = JSON.parse(data) - const delta = parsed.choices?.[0]?.delta - const finishReason = parsed.choices?.[0]?.finish_reason - - if (delta?.content) { - yield { content: delta.content, isFinished: false } - } - - // 处理流式 tool_calls - if (delta?.tool_calls && Array.isArray(delta.tool_calls)) { - for (const tc of delta.tool_calls) { - const index = tc.index ?? 0 - const existing = toolCallsAccumulator.get(index) - if (existing) { - if (tc.function?.arguments) { - existing.arguments += tc.function.arguments - } - } else { - toolCallsAccumulator.set(index, { - id: tc.id || '', - name: tc.function?.name || '', - arguments: tc.function?.arguments || '', - }) - } - } - } - - if (finishReason) { - let reason: ChatStreamChunk['finishReason'] = 'error' - if (finishReason === 'stop') reason = 'stop' - else if (finishReason === 'length') reason = 'length' - else if (finishReason === 'tool_calls') reason = 'tool_calls' - - // 解析 usage 信息 - const usage = parsed.usage - ? { - promptTokens: parsed.usage.prompt_tokens, - completionTokens: parsed.usage.completion_tokens, - totalTokens: parsed.usage.total_tokens, - } - : undefined - - if (toolCallsAccumulator.size > 0) { - const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({ - id: tc.id, - type: 'function' as const, - function: { name: tc.name, arguments: tc.arguments }, - })) - yield { content: '', isFinished: true, finishReason: reason, tool_calls: toolCalls, usage } - } else { - yield { content: '', isFinished: true, finishReason: reason, usage } - } - return - } - } catch { - // 忽略解析错误,继续处理下一行 - } - } - } - } catch (error) { - // 如果是中止错误,正常返回 - if (error instanceof Error && error.name === 'AbortError') { - yield { content: '', isFinished: true, finishReason: 'stop' } - return - } - throw error - } finally { - reader.releaseLock() - } - } - - async validateApiKey(): Promise<{ success: boolean; error?: string }> { - try { - // 发送一个简单请求验证 API Key - const response = await fetch(`${this.baseUrl}/models`, { - method: 'GET', - headers: { - Authorization: `Bearer ${this.apiKey}`, - }, - }) - if (response.ok) { - return { success: true } - } - // 尝试获取错误详情 - const errorText = await response.text() - let errorMessage = `HTTP ${response.status}` - try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.error?.message || errorJson.message || errorMessage - } catch { - if (errorText) { - errorMessage = errorText.slice(0, 200) - } - } - return { success: false, error: errorMessage } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return { success: false, error: errorMessage } - } - } -} diff --git a/electron/main/ai/llm/types.ts b/electron/main/ai/llm/types.ts deleted file mode 100644 index e7d4f81ac..000000000 --- a/electron/main/ai/llm/types.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * LLM 服务类型定义 - */ - -/** - * 支持的 LLM 提供商 - */ -export type LLMProvider = 'deepseek' | 'qwen' | 'minimax' | 'glm' | 'kimi' | 'gemini' | 'doubao' | 'openai-compatible' - -/** - * LLM 配置 - */ -export interface LLMConfig { - provider: LLMProvider - apiKey: string - model?: string - baseUrl?: string - maxTokens?: number -} - -/** - * 聊天消息 - */ -export interface ChatMessage { - role: 'system' | 'user' | 'assistant' | 'tool' - content: string - /** tool 角色时的 tool_call_id */ - tool_call_id?: string - /** assistant 角色时的 tool_calls */ - tool_calls?: ToolCall[] -} - -/** - * 聊天请求选项 - */ -export interface ChatOptions { - temperature?: number - maxTokens?: number - stream?: boolean - /** 可用的工具列表 */ - tools?: ToolDefinition[] - /** 中止信号,用于取消请求 */ - abortSignal?: AbortSignal -} - -/** - * 非流式响应 - */ -export interface ChatResponse { - content: string - finishReason: 'stop' | 'length' | 'error' | 'tool_calls' - /** 如果 LLM 决定调用工具,返回 tool_calls */ - tool_calls?: ToolCall[] - usage?: { - promptTokens: number - completionTokens: number - totalTokens: number - } -} - -/** - * 流式响应 chunk - */ -export interface ChatStreamChunk { - content: string - isFinished: boolean - finishReason?: 'stop' | 'length' | 'error' | 'tool_calls' - /** 流式过程中的 tool_calls(增量) */ - tool_calls?: ToolCall[] - /** Token 使用量(通常在最后一个 chunk 中返回) */ - usage?: { - promptTokens: number - completionTokens: number - totalTokens: number - } -} - -// ==================== Function Calling 相关类型 ==================== - -/** - * 工具定义(OpenAI 兼容格式) - */ -export interface ToolDefinition { - type: 'function' - function: { - name: string - description: string - parameters: { - type: 'object' - properties: Record< - string, - { - type: string - description: string - enum?: string[] - items?: { type: string } - } - > - required?: string[] - } - } -} - -/** - * 工具调用 - */ -export interface ToolCall { - id: string - type: 'function' - function: { - name: string - arguments: string // JSON 字符串 - } - /** Gemini 3+ 模型需要的思考签名(用于工具调用验证) */ - thoughtSignature?: string -} - -/** - * 工具调用结果 - */ -export interface ToolCallResult { - tool_call_id: string - result: unknown -} - -/** - * LLM 服务接口 - */ -export interface ILLMService { - /** - * 获取提供商名称 - */ - getProvider(): LLMProvider - - /** - * 获取可用模型列表 - */ - getModels(): string[] - - /** - * 获取默认模型 - */ - getDefaultModel(): string - - /** - * 发送聊天请求(非流式) - * @param messages 消息列表 - * @param options 选项,可包含 tools 参数启用 Function Calling - */ - chat(messages: ChatMessage[], options?: ChatOptions): Promise - - /** - * 发送聊天请求(流式) - * @param messages 消息列表 - * @param options 选项,可包含 tools 参数启用 Function Calling - */ - chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncGenerator - - /** - * 验证 API Key 是否有效 - * @returns 验证结果和可能的错误信息 - */ - validateApiKey(): Promise<{ success: boolean; error?: string }> -} - -/** - * 提供商信息 - */ -export interface ProviderInfo { - id: LLMProvider - name: string - description: string - defaultBaseUrl: string - models: Array<{ - id: string - name: string - description?: string - }> -} - -// ==================== 多配置管理相关类型 ==================== - -/** - * 单个 AI 服务配置 - */ -export interface AIServiceConfig { - id: string // UUID - name: string // 用户自定义名称 - provider: LLMProvider - apiKey: string // 可为空(本地 API 场景) - model?: string - baseUrl?: string // 自定义端点 - maxTokens?: number - /** 禁用思考模式(用于本地服务,如 Qwen3、DeepSeek-R1 等) */ - disableThinking?: boolean - createdAt: number // 创建时间戳 - updatedAt: number // 更新时间戳 -} - -/** - * AI 配置存储结构 - */ -export interface AIConfigStore { - configs: AIServiceConfig[] - activeConfigId: string | null -} - -/** - * 最大配置数量限制 - */ -export const MAX_CONFIG_COUNT = 10 diff --git a/electron/main/ai/logger.ts b/electron/main/ai/logger.ts deleted file mode 100644 index 1a1f5341f..000000000 --- a/electron/main/ai/logger.ts +++ /dev/null @@ -1,171 +0,0 @@ -/** - * AI 日志模块 - * 将 AI 相关操作日志写入本地文件 - */ - -import * as fs from 'fs' -import * as path from 'path' -import { getLogsDir, ensureDir } from '../paths' - -// 日志目录 -let LOG_DIR: string | null = null -let LOG_FILE: string | null = null -let logStream: fs.WriteStream | null = null - -/** - * 获取日志目录 - */ -function getLogDir(): string { - if (LOG_DIR) return LOG_DIR - LOG_DIR = path.join(getLogsDir(), 'ai') - return LOG_DIR -} - -/** - * 确保日志目录存在 - */ -function ensureLogDir(): void { - const dir = getLogDir() - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } -} - -/** - * 获取当前日志文件路径 - * 日志文件名格式:ai_YYYY-MM-DD_HH-mm.log - */ -function getLogFilePath(): string { - if (LOG_FILE) return LOG_FILE - - ensureLogDir() - const now = new Date() - const date = now.toISOString().split('T')[0] - const hours = String(now.getHours()).padStart(2, '0') - const minutes = String(now.getMinutes()).padStart(2, '0') - LOG_FILE = path.join(getLogDir(), `ai_${date}_${hours}-${minutes}.log`) - - return LOG_FILE -} - -/** - * 获取日志写入流 - */ -function getLogStream(): fs.WriteStream { - if (logStream) return logStream - - const filePath = getLogFilePath() - logStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf-8' }) - - return logStream -} - -/** - * 格式化时间戳 - */ -function formatTimestamp(): string { - return new Date().toISOString() -} - -/** - * 日志级别 - */ -type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' - -/** - * 写入日志 - * @param level 日志级别 - * @param category 分类 - * @param message 消息 - * @param data 附加数据 - * @param toConsole 是否输出到控制台(默认只有 WARN/ERROR 输出) - */ -function writeLog(level: LogLevel, category: string, message: string, data?: any, toConsole: boolean = false): void { - const timestamp = formatTimestamp() - let logLine = `[${timestamp}] [${level}] [${category}] ${message}` - - if (data !== undefined) { - try { - const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2) - // 限制数据长度,避免日志过大 - const maxLength = 2000 - if (dataStr.length > maxLength) { - logLine += `\n${dataStr.slice(0, maxLength)}...[截断,共 ${dataStr.length} 字符]` - } else { - logLine += `\n${dataStr}` - } - } catch { - logLine += `\n[无法序列化的数据]` - } - } - - logLine += '\n' - - // 写入文件 - try { - const stream = getLogStream() - stream.write(logLine) - } catch (error) { - console.error('[AILogger] 写入日志失败:', error) - } - - // 只在需要时输出到控制台(WARN/ERROR 或明确指定) - if (toConsole || level === 'WARN' || level === 'ERROR') { - console.log(`[AI] ${message}`) - } -} - -/** - * AI 日志对象 - */ -export const aiLogger = { - debug(category: string, message: string, data?: any) { - writeLog('DEBUG', category, message, data) - }, - - info(category: string, message: string, data?: any) { - writeLog('INFO', category, message, data) - }, - - warn(category: string, message: string, data?: any) { - writeLog('WARN', category, message, data) - }, - - error(category: string, message: string, data?: any) { - writeLog('ERROR', category, message, data) - }, - - /** - * 关闭日志流 - */ - close() { - if (logStream) { - logStream.end() - logStream = null - } - }, - - /** - * 获取日志文件路径 - */ - getLogPath(): string { - return getLogFilePath() - }, -} - -// 导出便捷函数 -export function logAI(message: string, data?: any) { - aiLogger.info('AI', message, data) -} - -export function logLLM(message: string, data?: any) { - aiLogger.info('LLM', message, data) -} - -export function logSearch(message: string, data?: any) { - aiLogger.info('Search', message, data) -} - -export function logRAG(message: string, data?: any) { - aiLogger.info('RAG', message, data) -} diff --git a/electron/main/ai/tools/index.ts b/electron/main/ai/tools/index.ts deleted file mode 100644 index fed6a105a..000000000 --- a/electron/main/ai/tools/index.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * AI Tools 模块入口 - * 工具注册与管理 - */ - -import type { ToolDefinition, ToolCall } from '../llm/types' -import type { ToolRegistry, RegisteredTool, ToolContext, ToolExecutionResult, ToolExecutor } from './types' - -// 导出类型 -export * from './types' - -// 全局工具注册表 -const toolRegistry: ToolRegistry = new Map() - -// 工具是否已初始化 -let toolsInitialized = false -let initPromise: Promise | null = null - -/** - * 注册一个工具 - * @param definition 工具定义 - * @param executor 执行函数 - */ -export function registerTool(definition: ToolDefinition, executor: ToolExecutor): void { - const name = definition.function.name - toolRegistry.set(name, { definition, executor }) -} - -/** - * 初始化所有工具(确保工具已注册) - * 使用动态 import 避免循环依赖 - */ -export async function ensureToolsInitialized(): Promise { - if (toolsInitialized) return - if (initPromise) return initPromise - - initPromise = (async () => { - // 动态导入 registry 模块 - await import('./registry') - toolsInitialized = true - })() - - return initPromise -} - -/** - * 获取所有已注册的工具定义 - * @returns 工具定义数组(用于传给 LLM) - */ -export async function getAllToolDefinitions(): Promise { - await ensureToolsInitialized() - return Array.from(toolRegistry.values()).map((t) => t.definition) -} - -/** - * 获取指定工具 - * @param name 工具名称 - */ -export async function getTool(name: string): Promise { - await ensureToolsInitialized() - return toolRegistry.get(name) -} - -/** - * 执行单个工具调用 - * @param toolCall LLM 返回的 tool_call - * @param context 执行上下文 - */ -export async function executeToolCall( - toolCall: ToolCall, - context: ToolContext -): Promise { - await ensureToolsInitialized() - const toolName = toolCall.function.name - - // 查找工具 - const tool = toolRegistry.get(toolName) - if (!tool) { - return { - toolName, - success: false, - error: `工具 "${toolName}" 未注册`, - } - } - - try { - // 解析参数 - const params = JSON.parse(toolCall.function.arguments || '{}') - - // 执行工具 - const result = await tool.executor(params, context) - - return { - toolName, - success: true, - result, - } - } catch (error) { - return { - toolName, - success: false, - error: error instanceof Error ? error.message : String(error), - } - } -} - -/** - * 批量执行工具调用 - * @param toolCalls LLM 返回的 tool_calls 数组 - * @param context 执行上下文 - */ -export async function executeToolCalls( - toolCalls: ToolCall[], - context: ToolContext -): Promise { - // 并行执行所有工具调用 - return Promise.all(toolCalls.map((tc) => executeToolCall(tc, context))) -} - -/** - * 检查工具是否已注册 - */ -export async function hasToolsRegistered(): Promise { - await ensureToolsInitialized() - return toolRegistry.size > 0 -} - -/** - * 获取已注册工具数量 - */ -export async function getRegisteredToolCount(): Promise { - await ensureToolsInitialized() - return toolRegistry.size -} - diff --git a/electron/main/ai/tools/registry.ts b/electron/main/ai/tools/registry.ts deleted file mode 100644 index e76497e00..000000000 --- a/electron/main/ai/tools/registry.ts +++ /dev/null @@ -1,763 +0,0 @@ -/** - * 工具注册 - * 在这里注册所有可用的 AI 工具 - */ - -import { registerTool } from './index' -import type { ToolDefinition } from '../llm/types' -import type { ToolContext } from './types' -import * as workerManager from '../../worker/workerManager' - -// ==================== 国际化辅助函数 ==================== - -/** - * 判断是否使用中文 - * 中文环境返回 true,其他语言(包括英文)返回 false - */ -function isChineseLocale(locale?: string): boolean { - return locale === 'zh-CN' -} - -/** - * 工具返回结果的国际化文本 - */ -const i18nTexts = { - allTime: { zh: '全部时间', en: 'All time' }, - noContent: { zh: '[无内容]', en: '[No content]' }, - memberNotFound: { zh: '未找到该成员', en: 'Member not found' }, - untilNow: { zh: '至今', en: 'Present' }, - noChangeRecord: { zh: '无变更记录', en: 'No change record' }, - noConversation: { zh: '未找到这两人之间的对话', en: 'No conversation found between these two members' }, - noMessageContext: { zh: '未找到指定的消息或上下文', en: 'Message or context not found' }, - messages: { zh: '条', en: '' }, - alias: { zh: '别名', en: 'Alias' }, - weekdays: { - zh: ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'], - en: ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], - }, - dailySummary: { - zh: (days: number, total: number, avg: number) => `最近${days}天共${total}条,日均${avg}条`, - en: (days: number, total: number, avg: number) => `Last ${days} days: ${total} messages, avg ${avg}/day`, - }, -} - -/** - * 获取国际化文本 - */ -function t(key: keyof typeof i18nTexts, locale?: string): string | string[] { - const text = i18nTexts[key] - if (typeof text === 'object' && 'zh' in text && 'en' in text) { - return isChineseLocale(locale) ? text.zh : text.en - } - return '' -} - -// ==================== 时间参数辅助函数 ==================== - -/** - * 扩展的时间参数类型 - */ -interface ExtendedTimeParams { - year?: number - month?: number - day?: number - hour?: number - start_time?: string // 格式: "YYYY-MM-DD HH:mm" - end_time?: string // 格式: "YYYY-MM-DD HH:mm" -} - -/** - * 解析扩展的时间参数,返回时间过滤器 - * 优先级: start_time/end_time > year/month/day/hour 组合 > context.timeFilter - */ -function parseExtendedTimeParams( - params: ExtendedTimeParams, - contextTimeFilter?: { startTs: number; endTs: number } -): { startTs: number; endTs: number } | undefined { - // 1. 如果指定了 start_time 和/或 end_time,使用精确范围 - if (params.start_time || params.end_time) { - let startTs: number | undefined - let endTs: number | undefined - - if (params.start_time) { - const startDate = new Date(params.start_time.replace(' ', 'T')) - if (!isNaN(startDate.getTime())) { - startTs = Math.floor(startDate.getTime() / 1000) - } - } - - if (params.end_time) { - const endDate = new Date(params.end_time.replace(' ', 'T')) - if (!isNaN(endDate.getTime())) { - endTs = Math.floor(endDate.getTime() / 1000) - } - } - - // 至少有一个有效时间 - if (startTs !== undefined || endTs !== undefined) { - return { - startTs: startTs ?? 0, - endTs: endTs ?? Math.floor(Date.now() / 1000), - } - } - } - - // 2. 如果指定了 year/month/day/hour 组合 - if (params.year) { - const year = params.year - const month = params.month - const day = params.day - const hour = params.hour - - let startDate: Date - let endDate: Date - - if (month && day && hour !== undefined) { - // 精确到小时 - startDate = new Date(year, month - 1, day, hour, 0, 0) - endDate = new Date(year, month - 1, day, hour, 59, 59) - } else if (month && day) { - // 精确到天 - startDate = new Date(year, month - 1, day, 0, 0, 0) - endDate = new Date(year, month - 1, day, 23, 59, 59) - } else if (month) { - // 精确到月 - startDate = new Date(year, month - 1, 1) - endDate = new Date(year, month, 0, 23, 59, 59) // 下个月的第 0 天 = 当月最后一天 - } else { - // 只指定了年 - startDate = new Date(year, 0, 1) - endDate = new Date(year, 11, 31, 23, 59, 59) - } - - return { - startTs: Math.floor(startDate.getTime() / 1000), - endTs: Math.floor(endDate.getTime() / 1000), - } - } - - // 3. 使用 context 中的时间过滤器 - return contextTimeFilter -} - -/** - * 格式化时间范围用于返回结果 - */ -function formatTimeRange( - timeFilter?: { startTs: number; endTs: number }, - locale?: string -): string | { start: string; end: string } { - if (!timeFilter) return t('allTime', locale) as string - const localeStr = isChineseLocale(locale) ? 'zh-CN' : 'en-US' - return { - start: new Date(timeFilter.startTs * 1000).toLocaleString(localeStr), - end: new Date(timeFilter.endTs * 1000).toLocaleString(localeStr), - } -} - -// 消息内容最大长度(超过则截断) -const MAX_MESSAGE_CONTENT_LENGTH = 200 - -/** - * 格式化消息为简洁文本格式 - * 输出格式: "2025/3/3 07:25:04 张三: 消息内容" - * 超长内容会被截断 - */ -function formatMessageCompact( - msg: { - id?: number - senderName: string - content: string | null - timestamp: number - }, - locale?: string -): string { - const localeStr = isChineseLocale(locale) ? 'zh-CN' : 'en-US' - const time = new Date(msg.timestamp * 1000).toLocaleString(localeStr) - let content = msg.content || (t('noContent', locale) as string) - - // 截断超长消息内容 - if (content.length > MAX_MESSAGE_CONTENT_LENGTH) { - content = content.slice(0, MAX_MESSAGE_CONTENT_LENGTH) + '...' - } - - return `${time} ${msg.senderName}: ${content}` -} - -// ==================== 工具定义 ==================== - -/** - * 搜索消息工具 - * 根据关键词搜索群聊记录 - */ -const searchMessagesTool: ToolDefinition = { - type: 'function', - function: { - name: 'search_messages', - description: - '根据关键词搜索群聊记录。适用于用户想要查找特定话题、关键词相关的聊天内容。可以指定时间范围和发送者来筛选消息。支持精确到分钟级别的时间查询。', - parameters: { - type: 'object', - properties: { - keywords: { - type: 'array', - description: '搜索关键词列表,会用 OR 逻辑匹配包含任一关键词的消息。如果只需要按发送者筛选,可以传空数组 []', - items: { type: 'string' }, - }, - sender_id: { - type: 'number', - description: '发送者的成员 ID,用于筛选特定成员发送的消息。可以通过 get_group_members 工具获取成员 ID', - }, - limit: { - type: 'number', - description: '返回消息数量限制,默认 100,最大 5000', - }, - year: { - type: 'number', - description: '筛选指定年份的消息,如 2024', - }, - month: { - type: 'number', - description: '筛选指定月份的消息(1-12),需要配合 year 使用', - }, - day: { - type: 'number', - description: '筛选指定日期的消息(1-31),需要配合 year 和 month 使用', - }, - hour: { - type: 'number', - description: '筛选指定小时的消息(0-23),需要配合 year、month 和 day 使用', - }, - start_time: { - type: 'string', - description: - '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', - }, - end_time: { - type: 'string', - description: - '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', - }, - }, - required: ['keywords'], - }, - }, -} - -async function searchMessagesExecutor( - params: { - keywords: string[] - sender_id?: number - limit?: number - year?: number - month?: number - day?: number - hour?: number - start_time?: string - end_time?: string - }, - context: ToolContext -): Promise { - const { sessionId, timeFilter: contextTimeFilter, maxMessagesLimit, locale } = context - // 用户配置优先:如果用户设置了 maxMessagesLimit,使用它;否则使用 LLM 指定的值或默认值 100,上限 5000 - const limit = Math.min(maxMessagesLimit || params.limit || 100, 5000) - - // 使用扩展的时间参数解析 - const effectiveTimeFilter = parseExtendedTimeParams(params, contextTimeFilter) - - const result = await workerManager.searchMessages( - sessionId, - params.keywords, - effectiveTimeFilter, - limit, - 0, - params.sender_id - ) - - // 格式化为简洁的文本格式 - return { - total: result.total, - returned: result.messages.length, - timeRange: formatTimeRange(effectiveTimeFilter, locale), - messages: result.messages.map((m) => formatMessageCompact(m, locale)), - } -} - -/** - * 获取最近消息工具 - * 获取最近的群聊消息,用于回答概览性问题 - */ -const getRecentMessagesTool: ToolDefinition = { - type: 'function', - function: { - name: 'get_recent_messages', - description: - '获取指定时间段内的群聊消息。适用于回答"最近大家聊了什么"、"X月群里聊了什么"等概览性问题。支持精确到分钟级别的时间查询。', - parameters: { - type: 'object', - properties: { - limit: { - type: 'number', - description: '返回消息数量限制,默认 100(节省 token,可根据需要增加)', - }, - year: { - type: 'number', - description: '筛选指定年份的消息,如 2024', - }, - month: { - type: 'number', - description: '筛选指定月份的消息(1-12),需要配合 year 使用', - }, - day: { - type: 'number', - description: '筛选指定日期的消息(1-31),需要配合 year 和 month 使用', - }, - hour: { - type: 'number', - description: '筛选指定小时的消息(0-23),需要配合 year、month 和 day 使用', - }, - start_time: { - type: 'string', - description: - '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', - }, - end_time: { - type: 'string', - description: - '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', - }, - }, - }, - }, -} - -async function getRecentMessagesExecutor( - params: { - limit?: number - year?: number - month?: number - day?: number - hour?: number - start_time?: string - end_time?: string - }, - context: ToolContext -): Promise { - const { sessionId, timeFilter: contextTimeFilter, maxMessagesLimit, locale } = context - // 用户配置优先:如果用户设置了 maxMessagesLimit,使用它;否则使用 LLM 指定的值或默认值 100(节省 token) - const limit = maxMessagesLimit || params.limit || 100 - - // 使用扩展的时间参数解析 - const effectiveTimeFilter = parseExtendedTimeParams(params, contextTimeFilter) - - const result = await workerManager.getRecentMessages(sessionId, effectiveTimeFilter, limit) - - return { - total: result.total, - returned: result.messages.length, - timeRange: formatTimeRange(effectiveTimeFilter, locale), - messages: result.messages.map((m) => formatMessageCompact(m, locale)), - } -} - -/** - * 获取成员活跃度统计工具 - */ -const getMemberStatsTool: ToolDefinition = { - type: 'function', - function: { - name: 'get_member_stats', - description: '获取群成员的活跃度统计数据。适用于回答"谁最活跃"、"发言最多的是谁"等问题。', - parameters: { - type: 'object', - properties: { - top_n: { - type: 'number', - description: '返回前 N 名成员,默认 10', - }, - }, - }, - }, -} - -async function getMemberStatsExecutor(params: { top_n?: number }, context: ToolContext): Promise { - const { sessionId, timeFilter, locale } = context - const topN = params.top_n || 10 - - const result = await workerManager.getMemberActivity(sessionId, timeFilter) - - // 只返回前 N 名 - const topMembers = result.slice(0, topN) - - // 格式化为简洁文本:排名. 名字 消息数(百分比) - const msgSuffix = isChineseLocale(locale) ? '条' : '' - return { - totalMembers: result.length, - topMembers: topMembers.map((m, index) => `${index + 1}. ${m.name} ${m.messageCount}${msgSuffix}(${m.percentage}%)`), - } -} - -/** - * 获取时间分布统计工具 - */ -const getTimeStatsTool: ToolDefinition = { - type: 'function', - function: { - name: 'get_time_stats', - description: '获取群聊的时间分布统计。适用于回答"什么时候最活跃"、"大家一般几点聊天"等问题。', - parameters: { - type: 'object', - properties: { - type: { - type: 'string', - description: '统计类型:hourly(按小时)、weekday(按星期)、daily(按日期)', - enum: ['hourly', 'weekday', 'daily'], - }, - }, - required: ['type'], - }, - }, -} - -async function getTimeStatsExecutor( - params: { type: 'hourly' | 'weekday' | 'daily' }, - context: ToolContext -): Promise { - const { sessionId, timeFilter, locale } = context - const msgSuffix = isChineseLocale(locale) ? '条' : '' - - switch (params.type) { - case 'hourly': { - const result = await workerManager.getHourlyActivity(sessionId, timeFilter) - const peak = result.reduce((max, curr) => (curr.messageCount > max.messageCount ? curr : max)) - // 格式化为简洁文本:时间 消息数 - return { - peakHour: `${peak.hour}:00 (${peak.messageCount}${msgSuffix})`, - distribution: result.map((h) => `${h.hour}:00 ${h.messageCount}${msgSuffix}`), - } - } - case 'weekday': { - const weekdayNames = t('weekdays', locale) as string[] - const result = await workerManager.getWeekdayActivity(sessionId, timeFilter) - const peak = result.reduce((max, curr) => (curr.messageCount > max.messageCount ? curr : max)) - return { - peakDay: `${weekdayNames[peak.weekday]} (${peak.messageCount}${msgSuffix})`, - distribution: result.map((w) => `${weekdayNames[w.weekday]} ${w.messageCount}${msgSuffix}`), - } - } - case 'daily': { - const result = await workerManager.getDailyActivity(sessionId, timeFilter) - // 只返回最近 30 天 - const recent = result.slice(-30) - const total = recent.reduce((sum, d) => sum + d.messageCount, 0) - const avg = Math.round(total / recent.length) - const summaryFn = i18nTexts.dailySummary[isChineseLocale(locale) ? 'zh' : 'en'] - return { - summary: summaryFn(recent.length, total, avg), - trend: recent.map((d) => `${d.date} ${d.messageCount}${msgSuffix}`), - } - } - } -} - -/** - * 获取群成员列表工具 - * 返回所有群成员的详细信息,包括别名 - */ -const getGroupMembersTool: ToolDefinition = { - type: 'function', - function: { - name: 'get_group_members', - description: - '获取群成员列表,包括成员的基本信息、别名和消息统计。适用于查询"群里有哪些人"、"某人的别名是什么"、"谁的QQ号是xxx"等问题。', - parameters: { - type: 'object', - properties: { - search: { - type: 'string', - description: '可选的搜索关键词,用于筛选成员昵称、别名或QQ号', - }, - limit: { - type: 'number', - description: '返回成员数量限制,默认返回全部', - }, - }, - }, - }, -} - -async function getGroupMembersExecutor( - params: { search?: string; limit?: number }, - context: ToolContext -): Promise { - const { sessionId, locale } = context - - const members = await workerManager.getMembers(sessionId) - - // 如果有搜索关键词,进行筛选 - let filteredMembers = members - if (params.search) { - const keyword = params.search.toLowerCase() - filteredMembers = members.filter((m) => { - // 搜索群昵称 - if (m.groupNickname && m.groupNickname.toLowerCase().includes(keyword)) return true - // 搜索账号名称 - if (m.accountName && m.accountName.toLowerCase().includes(keyword)) return true - // 搜索 QQ 号 - if (m.platformId.includes(keyword)) return true - // 搜索别名 - if (m.aliases.some((alias) => alias.toLowerCase().includes(keyword))) return true - return false - }) - } - - // 如果有数量限制 - if (params.limit && params.limit > 0) { - filteredMembers = filteredMembers.slice(0, params.limit) - } - - // 格式化为简洁文本:id|QQ号|显示名(群昵称)|消息数 - const msgSuffix = isChineseLocale(locale) ? '条' : '' - const aliasLabel = t('alias', locale) as string - return { - totalMembers: members.length, - returnedMembers: filteredMembers.length, - members: filteredMembers.map((m) => { - const displayName = m.groupNickname || m.accountName || m.platformId - const aliasStr = m.aliases.length > 0 ? `|${aliasLabel}:${m.aliases.join(',')}` : '' - return `${m.id}|${m.platformId}|${displayName}|${m.messageCount}${msgSuffix}${aliasStr}` - }), - } -} - -/** - * 获取成员昵称变更历史工具 - * 查看成员的历史昵称变化记录 - */ -const getMemberNameHistoryTool: ToolDefinition = { - type: 'function', - function: { - name: 'get_member_name_history', - description: - '获取成员的昵称变更历史记录。适用于回答"某人以前叫什么名字"、"某人的昵称变化"、"某人曾用名"等问题。需要先通过 get_group_members 工具获取成员 ID。', - parameters: { - type: 'object', - properties: { - member_id: { - type: 'number', - description: '成员的数据库 ID,可以通过 get_group_members 工具获取', - }, - }, - required: ['member_id'], - }, - }, -} - -async function getMemberNameHistoryExecutor(params: { member_id: number }, context: ToolContext): Promise { - const { sessionId, locale } = context - - // 先获取成员基本信息 - const members = await workerManager.getMembers(sessionId) - const member = members.find((m) => m.id === params.member_id) - - if (!member) { - return { - error: t('memberNotFound', locale) as string, - member_id: params.member_id, - } - } - - // 获取昵称历史 - const history = await workerManager.getMemberNameHistory(sessionId, params.member_id) - - // 格式化历史记录为简洁文本 - const localeStr = isChineseLocale(locale) ? 'zh-CN' : 'en-US' - const untilNow = t('untilNow', locale) as string - const formatHistory = (h: { name: string; startTs: number; endTs: number | null }) => { - const start = new Date(h.startTs * 1000).toLocaleDateString(localeStr) - const end = h.endTs ? new Date(h.endTs * 1000).toLocaleDateString(localeStr) : untilNow - return `${h.name} (${start} ~ ${end})` - } - - const accountNames = history.filter((h: { nameType: string }) => h.nameType === 'account_name').map(formatHistory) - - const groupNicknames = history.filter((h: { nameType: string }) => h.nameType === 'group_nickname').map(formatHistory) - - const displayName = member.groupNickname || member.accountName || member.platformId - const aliasLabel = t('alias', locale) as string - const aliasStr = member.aliases.length > 0 ? `|${aliasLabel}:${member.aliases.join(',')}` : '' - const noChangeRecord = t('noChangeRecord', locale) as string - - return { - member: `${member.id}|${member.platformId}|${displayName}${aliasStr}`, - accountNameHistory: accountNames.length > 0 ? accountNames : noChangeRecord, - groupNicknameHistory: groupNicknames.length > 0 ? groupNicknames : noChangeRecord, - } -} - -/** - * 获取两个成员之间的对话工具 - */ -const getConversationBetweenTool: ToolDefinition = { - type: 'function', - function: { - name: 'get_conversation_between', - description: - '获取两个群成员之间的对话记录。适用于回答"A和B之间聊了什么"、"查看两人的对话"等问题。需要先通过 get_group_members 获取成员 ID。支持精确到分钟级别的时间查询。', - parameters: { - type: 'object', - properties: { - member_id_1: { - type: 'number', - description: '第一个成员的数据库 ID', - }, - member_id_2: { - type: 'number', - description: '第二个成员的数据库 ID', - }, - limit: { - type: 'number', - description: '返回消息数量限制,默认 100', - }, - year: { - type: 'number', - description: '筛选指定年份的消息', - }, - month: { - type: 'number', - description: '筛选指定月份的消息(1-12),需要配合 year 使用', - }, - day: { - type: 'number', - description: '筛选指定日期的消息(1-31),需要配合 year 和 month 使用', - }, - hour: { - type: 'number', - description: '筛选指定小时的消息(0-23),需要配合 year、month 和 day 使用', - }, - start_time: { - type: 'string', - description: - '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后会覆盖 year/month/day/hour 参数', - }, - end_time: { - type: 'string', - description: - '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后会覆盖 year/month/day/hour 参数', - }, - }, - required: ['member_id_1', 'member_id_2'], - }, - }, -} - -async function getConversationBetweenExecutor( - params: { - member_id_1: number - member_id_2: number - limit?: number - year?: number - month?: number - day?: number - hour?: number - start_time?: string - end_time?: string - }, - context: ToolContext -): Promise { - const { sessionId, timeFilter: contextTimeFilter, maxMessagesLimit, locale } = context - // 用户配置优先:如果用户设置了 maxMessagesLimit,使用它;否则使用 LLM 指定的值或默认值 100(节省 token) - const limit = maxMessagesLimit || params.limit || 100 - - // 使用扩展的时间参数解析 - const effectiveTimeFilter = parseExtendedTimeParams(params, contextTimeFilter) - - const result = await workerManager.getConversationBetween( - sessionId, - params.member_id_1, - params.member_id_2, - effectiveTimeFilter, - limit - ) - - if (result.messages.length === 0) { - return { - error: t('noConversation', locale) as string, - member1Id: params.member_id_1, - member2Id: params.member_id_2, - } - } - - return { - total: result.total, - returned: result.messages.length, - member1: result.member1Name, - member2: result.member2Name, - timeRange: formatTimeRange(effectiveTimeFilter, locale), - conversation: result.messages.map((m) => formatMessageCompact(m, locale)), - } -} - -/** - * 获取消息上下文工具 - * 根据消息 ID 获取前后的上下文消息 - */ -const getMessageContextTool: ToolDefinition = { - type: 'function', - function: { - name: 'get_message_context', - description: - '根据消息 ID 获取前后的上下文消息。适用于需要查看某条消息前后聊天内容的场景,比如"这条消息的前后在聊什么"、"查看某条消息的上下文"等。支持单个或批量消息 ID。', - parameters: { - type: 'object', - properties: { - message_ids: { - type: 'array', - description: - '要查询上下文的消息 ID 列表,可以是单个 ID 或多个 ID。消息 ID 可以从 search_messages 等工具的返回结果中获取', - items: { type: 'number' }, - }, - context_size: { - type: 'number', - description: '上下文大小,即获取前后各多少条消息,默认 20', - }, - }, - required: ['message_ids'], - }, - }, -} - -async function getMessageContextExecutor( - params: { message_ids: number[]; context_size?: number }, - context: ToolContext -): Promise { - const { sessionId, locale } = context - const contextSize = params.context_size || 20 - - const messages = await workerManager.getMessageContext(sessionId, params.message_ids, contextSize) - - if (messages.length === 0) { - return { - error: t('noMessageContext', locale) as string, - messageIds: params.message_ids, - } - } - - return { - totalMessages: messages.length, - contextSize: contextSize, - requestedMessageIds: params.message_ids, - messages: messages.map((m) => formatMessageCompact(m, locale)), - } -} - -// ==================== 注册工具 ==================== - -registerTool(searchMessagesTool, searchMessagesExecutor) -registerTool(getRecentMessagesTool, getRecentMessagesExecutor) -registerTool(getMemberStatsTool, getMemberStatsExecutor) -registerTool(getTimeStatsTool, getTimeStatsExecutor) -registerTool(getGroupMembersTool, getGroupMembersExecutor) -registerTool(getMemberNameHistoryTool, getMemberNameHistoryExecutor) -registerTool(getConversationBetweenTool, getConversationBetweenExecutor) -registerTool(getMessageContextTool, getMessageContextExecutor) diff --git a/electron/main/ai/tools/types.ts b/electron/main/ai/tools/types.ts deleted file mode 100644 index fc15d9a8d..000000000 --- a/electron/main/ai/tools/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * AI Tools 类型定义 - * 定义工具的接口和执行上下文 - */ - -import type { ToolDefinition } from '../llm/types' - -/** - * 工具执行上下文 - * 包含执行工具时需要的所有上下文信息 - */ -/** Owner 信息(当前用户在对话中的身份) */ -export interface OwnerInfo { - /** Owner 的 platformId */ - platformId: string - /** Owner 的显示名称 */ - displayName: string -} - -export interface ToolContext { - /** 当前会话 ID(数据库文件名) */ - sessionId: string - /** 时间过滤器 */ - timeFilter?: { - startTs: number - endTs: number - } - /** 用户配置的消息条数限制(工具获取消息时使用) */ - maxMessagesLimit?: number - /** Owner 信息(当前用户在对话中的身份) */ - ownerInfo?: OwnerInfo - /** 语言环境(用于工具返回结果的国际化) */ - locale?: string -} - -/** - * 工具执行函数类型 - * @param params 从 LLM 解析出的参数对象 - * @param context 执行上下文 - * @returns 执行结果(将被序列化为字符串传回 LLM) - */ -export type ToolExecutor> = ( - params: T, - context: ToolContext -) => Promise - -/** - * 注册的工具 - * 包含工具定义和执行函数 - */ -export interface RegisteredTool { - /** 工具定义(OpenAI 格式) */ - definition: ToolDefinition - /** 执行函数 */ - executor: ToolExecutor -} - -/** - * 工具注册表 - */ -export type ToolRegistry = Map - -/** - * 工具执行结果 - */ -export interface ToolExecutionResult { - /** 工具名称 */ - toolName: string - /** 执行是否成功 */ - success: boolean - /** 执行结果(成功时) */ - result?: unknown - /** 错误信息(失败时) */ - error?: string -} - diff --git a/electron/main/analytics.ts b/electron/main/analytics.ts deleted file mode 100644 index c1e4f340d..000000000 --- a/electron/main/analytics.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * 应用分析模块 - * 使用 Aptabase 进行匿名使用统计 - */ - -import { app, ipcMain } from 'electron' -import { initialize, trackEvent } from '@aptabase/electron/main' -import * as fs from 'fs' -import * as path from 'path' - -// 分析数据存储路径 -function getAnalyticsPath(): string { - return path.join(app.getPath('userData'), 'analytics.json') -} - -// 分析数据结构 -interface AnalyticsData { - lastReportDate: string | null - firstReportDate: string | null // 用于判断新老用户 - enabled: boolean // 是否启用统计 -} - -// 默认配置 -const defaultAnalyticsData: AnalyticsData = { - lastReportDate: null, - firstReportDate: null, - enabled: true, // 默认启用 -} - -// 读取分析数据 -function loadAnalyticsData(): AnalyticsData { - try { - const filePath = getAnalyticsPath() - if (fs.existsSync(filePath)) { - const data = fs.readFileSync(filePath, 'utf-8') - return { ...defaultAnalyticsData, ...JSON.parse(data) } - } - } catch (error) { - console.error('[Analytics] 读取分析数据失败:', error) - } - return { ...defaultAnalyticsData } -} - -// 保存分析数据 -function saveAnalyticsData(data: AnalyticsData): void { - try { - const filePath = getAnalyticsPath() - fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8') - } catch (error) { - console.error('[Analytics] 保存分析数据失败:', error) - } -} - -// 获取今天的日期字符串 (YYYY-MM-DD) -function getTodayString(): string { - const now = new Date() - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` -} - -/** - * 检查统计是否启用 - */ -export function isAnalyticsEnabled(): boolean { - return loadAnalyticsData().enabled -} - -/** - * 初始化分析模块 - * 必须在 app.whenReady() 之前调用 - */ -export function initAnalytics(): void { - const appKey = process.env.APTABASE_APP_KEY - - if (!appKey) { - return - } - - try { - initialize(appKey) - console.log('[Analytics] Aptabase 初始化成功') - } catch (error) { - console.error('[Analytics] Aptabase 初始化失败:', error) - } -} - -/** - * 注册 Analytics IPC 处理器 - */ -export function registerAnalyticsHandlers(): void { - // 获取统计启用状态 - ipcMain.handle('analytics:getEnabled', () => { - return loadAnalyticsData().enabled - }) - - // 设置统计启用状态 - ipcMain.handle('analytics:setEnabled', (_, enabled: boolean) => { - const data = loadAnalyticsData() - data.enabled = enabled - saveAnalyticsData(data) - return { success: true } - }) -} - -/** - * 上报每日活跃事件 - */ -export function trackDailyActive(): void { - const appKey = process.env.APTABASE_APP_KEY - if (!appKey) { - return - } - - try { - const data = loadAnalyticsData() - - // 检查是否启用统计 - if (!data.enabled) { - return - } - - const today = getTodayString() - const isNew = data.firstReportDate === null - - // 新用户记录首次使用日期 - if (isNew) { - data.firstReportDate = today - } - - // 检查今天是否已经上报过 - if (data.lastReportDate === today) { - if (isNew) { - saveAnalyticsData(data) - } - return - } - - // 上报每日活跃事件 - trackEvent(isNew ? 'app_active_new' : 'app_active') - - data.lastReportDate = today - saveAnalyticsData(data) - } catch (error) { - console.error('[Analytics] 上报每日活跃失败:', error) - } -} - -/** - * 事件上报 - */ -export function trackAppEvent(eventName: string, properties?: Record): void { - const appKey = process.env.APTABASE_APP_KEY - if (!appKey) { - return - } - - // 检查是否启用统计 - if (!isAnalyticsEnabled()) { - return - } - - try { - trackEvent(eventName, properties) - } catch (error) { - console.error(`[Analytics] 上报事件 ${eventName} 失败:`, error) - } -} diff --git a/electron/main/database/analysis.ts b/electron/main/database/analysis.ts deleted file mode 100644 index 3c030aeba..000000000 --- a/electron/main/database/analysis.ts +++ /dev/null @@ -1,1342 +0,0 @@ -/** - * 数据库分析模块 - * 负责各种数据分析查询 - */ - -import type { MessageType } from '../../../src/types/base' -import type { - MemberActivity, - HourlyActivity, - DailyActivity, - WeekdayActivity, - RepeatAnalysis, - RepeatStatItem, - RepeatRateItem, - ChainLengthDistribution, - HotRepeatContent, - CatchphraseAnalysis, - NightOwlAnalysis, - NightOwlRankItem, - NightOwlTitle, - TimeRankItem, - ConsecutiveNightRecord, - NightOwlChampion, - DragonKingAnalysis, - DragonKingRankItem, - DivingAnalysis, - DivingRankItem, - MonologueAnalysis, - MonologueRankItem, - MaxComboRecord, -} from '../../../src/types/analysis' -import { openDatabase } from './core' - -/** - * 时间过滤参数 - */ -export interface TimeFilter { - startTs?: number - endTs?: number -} - -/** - * 构建时间过滤 WHERE 子句 - */ -function buildTimeFilter(filter?: TimeFilter): { clause: string; params: number[] } { - const conditions: string[] = [] - const params: number[] = [] - - if (filter?.startTs !== undefined) { - conditions.push('ts >= ?') - params.push(filter.startTs) - } - if (filter?.endTs !== undefined) { - conditions.push('ts <= ?') - params.push(filter.endTs) - } - - return { - clause: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '', - params, - } -} - -/** - * 构建排除系统消息的过滤条件 - */ -function buildSystemMessageFilter(existingClause: string): string { - const systemFilter = "COALESCE(m.account_name, '') != '系统消息'" - - if (existingClause.includes('WHERE')) { - return existingClause + ' AND ' + systemFilter - } else { - return ' WHERE ' + systemFilter - } -} - -/** - * 获取可用的年份列表 - */ -export function getAvailableYears(sessionId: string): number[] { - const db = openDatabase(sessionId) - if (!db) return [] - - try { - const rows = db - .prepare( - ` - SELECT DISTINCT CAST(strftime('%Y', ts, 'unixepoch', 'localtime') AS INTEGER) as year - FROM message - ORDER BY year DESC - ` - ) - .all() as Array<{ year: number }> - - return rows.map((r) => r.year) - } finally { - db.close() - } -} - -/** - * 获取成员活跃度排行 - */ -export function getMemberActivity(sessionId: string, filter?: TimeFilter): MemberActivity[] { - const db = openDatabase(sessionId) - if (!db) return [] - - try { - const { clause, params } = buildTimeFilter(filter) - - const msgFilterBase = clause ? clause.replace('WHERE', 'AND') : '' - const msgFilterWithSystem = msgFilterBase + " AND COALESCE(m.account_name, '') != '系统消息'" - - const totalClauseWithSystem = buildSystemMessageFilter(clause) - const totalMessages = ( - db - .prepare( - `SELECT COUNT(*) as count - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${totalClauseWithSystem}` - ) - .get(...params) as { count: number } - ).count - - const rows = db - .prepare( - ` - SELECT - m.id as memberId, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - COUNT(msg.id) as messageCount - FROM member m - LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem} - WHERE COALESCE(m.account_name, '') != '系统消息' - GROUP BY m.id - HAVING messageCount > 0 - ORDER BY messageCount DESC - ` - ) - .all(...params) as Array<{ - memberId: number - platformId: string - name: string - messageCount: number - }> - - return rows.map((row) => ({ - memberId: row.memberId, - platformId: row.platformId, - name: row.name, - messageCount: row.messageCount, - percentage: totalMessages > 0 ? Math.round((row.messageCount / totalMessages) * 10000) / 100 : 0, - })) - } finally { - db.close() - } -} - -/** - * 获取每小时活跃度分布 - */ -export function getHourlyActivity(sessionId: string, filter?: TimeFilter): HourlyActivity[] { - const db = openDatabase(sessionId) - if (!db) return [] - - try { - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const rows = db - .prepare( - ` - SELECT - CAST(strftime('%H', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as hour, - COUNT(*) as messageCount - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY hour - ORDER BY hour - ` - ) - .all(...params) as Array<{ hour: number; messageCount: number }> - - const result: HourlyActivity[] = [] - for (let h = 0; h < 24; h++) { - const found = rows.find((r) => r.hour === h) - result.push({ - hour: h, - messageCount: found ? found.messageCount : 0, - }) - } - - return result - } finally { - db.close() - } -} - -/** - * 获取每日活跃度趋势 - */ -export function getDailyActivity(sessionId: string, filter?: TimeFilter): DailyActivity[] { - const db = openDatabase(sessionId) - if (!db) return [] - - try { - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const rows = db - .prepare( - ` - SELECT - strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date, - COUNT(*) as messageCount - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY date - ORDER BY date - ` - ) - .all(...params) as Array<{ date: string; messageCount: number }> - - return rows - } finally { - db.close() - } -} - -/** - * 获取消息类型分布 - */ -export function getMessageTypeDistribution( - sessionId: string, - filter?: TimeFilter -): Array<{ type: MessageType; count: number }> { - const db = openDatabase(sessionId) - if (!db) return [] - - try { - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const rows = db - .prepare( - ` - SELECT msg.type, COUNT(*) as count - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY msg.type - ORDER BY count DESC - ` - ) - .all(...params) as Array<{ type: number; count: number }> - - return rows.map((r) => ({ - type: r.type as MessageType, - count: r.count, - })) - } finally { - db.close() - } -} - -/** - * 获取时间范围 - */ -export function getTimeRange(sessionId: string): { start: number; end: number } | null { - const db = openDatabase(sessionId) - if (!db) return null - - try { - const row = db - .prepare( - ` - SELECT MIN(ts) as start, MAX(ts) as end FROM message - ` - ) - .get() as { start: number | null; end: number | null } - - if (row.start === null || row.end === null) return null - - return { start: row.start, end: row.end } - } finally { - db.close() - } -} - -/** - * 获取成员的历史昵称记录 - */ -export function getMemberNameHistory( - sessionId: string, - memberId: number -): Array<{ name: string; startTs: number; endTs: number | null }> { - const db = openDatabase(sessionId) - if (!db) return [] - - try { - const rows = db - .prepare( - ` - SELECT name, start_ts as startTs, end_ts as endTs - FROM member_name_history - WHERE member_id = ? - ORDER BY start_ts DESC - ` - ) - .all(memberId) as Array<{ name: string; startTs: number; endTs: number | null }> - - return rows - } finally { - db.close() - } -} - -/** - * 获取复读分析数据 - * 使用滑动窗口算法检测复读链: - * - 复读成立条件:至少 3 条连续的相同内容消息,且发送者不同 - * - 排除:系统消息、空消息、图片消息 - */ -export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): RepeatAnalysis { - const db = openDatabase(sessionId) - const emptyResult: RepeatAnalysis = { - originators: [], - initiators: [], - breakers: [], - originatorRates: [], - initiatorRates: [], - breakerRates: [], - chainLengthDistribution: [], - hotContents: [], - avgChainLength: 0, - totalRepeatChains: 0, - } - - if (!db) { - return emptyResult - } - - try { - const { clause, params } = buildTimeFilter(filter) - - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += - " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND TRIM(msg.content) != ''" - } else { - whereClause = - " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND TRIM(msg.content) != ''" - } - - const messages = db - .prepare( - ` - SELECT - msg.id, - msg.sender_id as senderId, - msg.content, - msg.ts, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - ORDER BY msg.ts ASC, msg.id ASC - ` - ) - .all(...params) as Array<{ - id: number - senderId: number - content: string - ts: number - platformId: string - name: string - }> - - const originatorCount = new Map() - const initiatorCount = new Map() - const breakerCount = new Map() - const memberMessageCount = new Map() - - const memberInfo = new Map() - - const chainLengthCount = new Map() - - const contentStats = new Map< - string, - { count: number; maxChainLength: number; originatorId: number; lastTs: number } - >() - - let currentContent: string | null = null - let repeatChain: Array<{ senderId: number; content: string; ts: number }> = [] - let totalRepeatChains = 0 - let totalChainLength = 0 - - const processRepeatChain = ( - chain: Array<{ senderId: number; content: string; ts: number }>, - breakerId?: number - ) => { - if (chain.length < 3) return - - totalRepeatChains++ - const chainLength = chain.length - totalChainLength += chainLength - - const originatorId = chain[0].senderId - originatorCount.set(originatorId, (originatorCount.get(originatorId) || 0) + 1) - - const initiatorId = chain[1].senderId - initiatorCount.set(initiatorId, (initiatorCount.get(initiatorId) || 0) + 1) - - if (breakerId !== undefined) { - breakerCount.set(breakerId, (breakerCount.get(breakerId) || 0) + 1) - } - - chainLengthCount.set(chainLength, (chainLengthCount.get(chainLength) || 0) + 1) - - const content = chain[0].content - const chainTs = chain[0].ts - const existing = contentStats.get(content) - if (existing) { - existing.count++ - existing.lastTs = Math.max(existing.lastTs, chainTs) - if (chainLength > existing.maxChainLength) { - existing.maxChainLength = chainLength - existing.originatorId = originatorId - } - } else { - contentStats.set(content, { count: 1, maxChainLength: chainLength, originatorId, lastTs: chainTs }) - } - } - - for (const msg of messages) { - if (!memberInfo.has(msg.senderId)) { - memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) - } - - memberMessageCount.set(msg.senderId, (memberMessageCount.get(msg.senderId) || 0) + 1) - - const content = msg.content.trim() - - if (content === currentContent) { - const lastSender = repeatChain[repeatChain.length - 1]?.senderId - if (lastSender !== msg.senderId) { - repeatChain.push({ senderId: msg.senderId, content, ts: msg.ts }) - } - } else { - processRepeatChain(repeatChain, msg.senderId) - - currentContent = content - repeatChain = [{ senderId: msg.senderId, content, ts: msg.ts }] - } - } - - processRepeatChain(repeatChain) - - const buildRankList = (countMap: Map, total: number): RepeatStatItem[] => { - const items: RepeatStatItem[] = [] - for (const [memberId, count] of countMap.entries()) { - const info = memberInfo.get(memberId) - if (info) { - items.push({ - memberId, - platformId: info.platformId, - name: info.name, - count, - percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0, - }) - } - } - return items.sort((a, b) => b.count - a.count) - } - - const buildRateList = (countMap: Map): RepeatRateItem[] => { - const items: RepeatRateItem[] = [] - for (const [memberId, count] of countMap.entries()) { - const info = memberInfo.get(memberId) - const totalMessages = memberMessageCount.get(memberId) || 0 - if (info && totalMessages > 0) { - items.push({ - memberId, - platformId: info.platformId, - name: info.name, - count, - totalMessages, - rate: Math.round((count / totalMessages) * 10000) / 100, - }) - } - } - return items.sort((a, b) => b.rate - a.rate) - } - - const chainLengthDistribution: ChainLengthDistribution[] = [] - for (const [length, count] of chainLengthCount.entries()) { - chainLengthDistribution.push({ length, count }) - } - chainLengthDistribution.sort((a, b) => a.length - b.length) - - const hotContents: HotRepeatContent[] = [] - for (const [content, stats] of contentStats.entries()) { - const originatorInfo = memberInfo.get(stats.originatorId) - hotContents.push({ - content, - count: stats.count, - maxChainLength: stats.maxChainLength, - originatorName: originatorInfo?.name || '未知', - lastTs: stats.lastTs, - }) - } - hotContents.sort((a, b) => b.maxChainLength - a.maxChainLength) - const top10HotContents = hotContents.slice(0, 10) - - return { - originators: buildRankList(originatorCount, totalRepeatChains), - initiators: buildRankList(initiatorCount, totalRepeatChains), - breakers: buildRankList(breakerCount, totalRepeatChains), - originatorRates: buildRateList(originatorCount), - initiatorRates: buildRateList(initiatorCount), - breakerRates: buildRateList(breakerCount), - chainLengthDistribution, - hotContents: top10HotContents, - avgChainLength: totalRepeatChains > 0 ? Math.round((totalChainLength / totalRepeatChains) * 100) / 100 : 0, - totalRepeatChains, - } - } finally { - db.close() - } -} - -/** - * 获取星期活跃度分布 - * 返回周一到周日的消息统计 - */ -export function getWeekdayActivity(sessionId: string, filter?: TimeFilter): WeekdayActivity[] { - const db = openDatabase(sessionId) - if (!db) return [] - - try { - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - // SQLite strftime('%w') 返回 0-6,0=周日 - // 我们需要转换为 1-7,1=周一,7=周日 - const rows = db - .prepare( - ` - SELECT - CASE - WHEN CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER) = 0 THEN 7 - ELSE CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER) - END as weekday, - COUNT(*) as messageCount - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY weekday - ORDER BY weekday - ` - ) - .all(...params) as Array<{ weekday: number; messageCount: number }> - - // 补全所有星期(1-7) - const result: WeekdayActivity[] = [] - for (let w = 1; w <= 7; w++) { - const found = rows.find((r) => r.weekday === w) - result.push({ - weekday: w, - messageCount: found ? found.messageCount : 0, - }) - } - - return result - } finally { - db.close() - } -} - -/** - * 获取口头禅分析数据 - * 统计每个成员最常说的内容(前5个) - * - 排除:系统消息、空消息、图片消息 - * - 排除:过短的内容(少于2个字符) - */ -export function getCatchphraseAnalysis(sessionId: string, filter?: TimeFilter): CatchphraseAnalysis { - const db = openDatabase(sessionId) - if (!db) { - return { members: [] } - } - - try { - const { clause, params } = buildTimeFilter(filter) - - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += - " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2" - } else { - whereClause = - " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2" - } - - const rows = db - .prepare( - ` - SELECT - m.id as memberId, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - TRIM(msg.content) as content, - COUNT(*) as count - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - GROUP BY m.id, TRIM(msg.content) - ORDER BY m.id, count DESC - ` - ) - .all(...params) as Array<{ - memberId: number - platformId: string - name: string - content: string - count: number - }> - - const memberMap = new Map< - number, - { - memberId: number - platformId: string - name: string - catchphrases: Array<{ content: string; count: number }> - } - >() - - for (const row of rows) { - if (!memberMap.has(row.memberId)) { - memberMap.set(row.memberId, { - memberId: row.memberId, - platformId: row.platformId, - name: row.name, - catchphrases: [], - }) - } - - const member = memberMap.get(row.memberId)! - if (member.catchphrases.length < 5) { - member.catchphrases.push({ - content: row.content, - count: row.count, - }) - } - } - - const members = Array.from(memberMap.values()) - members.sort((a, b) => { - const aTotal = a.catchphrases.reduce((sum, c) => sum + c.count, 0) - const bTotal = b.catchphrases.reduce((sum, c) => sum + c.count, 0) - return bTotal - aTotal - }) - - return { members } - } finally { - db.close() - } -} - -/** - * 根据深夜发言数获取称号 - */ -function getNightOwlTitleByCount(count: number): NightOwlTitle { - if (count === 0) return '养生达人' - if (count <= 20) return '偶尔失眠' - if (count <= 50) return '经常失眠' - if (count <= 100) return '夜猫子' - if (count <= 200) return '秃头预备役' - if (count <= 500) return '修仙练习生' - return '守夜冠军' -} - -/** - * 将时间戳转换为"调整后的日期"(以凌晨5点为界) - * 05:00 之前的消息算作前一天 - */ -function getAdjustedDate(ts: number): string { - const date = new Date(ts * 1000) - const hour = date.getHours() - - // 如果是凌晨 0-4 点,算作前一天 - if (hour < 5) { - date.setDate(date.getDate() - 1) - } - - return date.toISOString().split('T')[0] -} - -/** - * 格式化分钟数为 HH:MM - */ -function formatMinutes(minutes: number): string { - const h = Math.floor(minutes / 60) - const m = Math.round(minutes % 60) - return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}` -} - -/** - * 获取夜猫分析数据 - * 深夜时段定义:23:00-05:00 - * 一天定义:05:00 ~ 次日 04:59 - */ -export function getNightOwlAnalysis(sessionId: string, filter?: TimeFilter): NightOwlAnalysis { - const db = openDatabase(sessionId) - const emptyResult: NightOwlAnalysis = { - nightOwlRank: [], - lastSpeakerRank: [], - firstSpeakerRank: [], - consecutiveRecords: [], - champions: [], - totalDays: 0, - } - - if (!db) return emptyResult - - try { - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - // 1. 获取所有消息(用于多种分析) - const messages = db - .prepare( - ` - SELECT - msg.id, - msg.sender_id as senderId, - msg.ts, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - ORDER BY msg.ts ASC - ` - ) - .all(...params) as Array<{ - id: number - senderId: number - ts: number - platformId: string - name: string - }> - - if (messages.length === 0) return emptyResult - - // 成员信息映射 - const memberInfo = new Map() - - // ========== 分析 1: 修仙排行榜 ========== - const nightStats = new Map< - number, - { - total: number - h23: number - h0: number - h1: number - h2: number - h3to4: number - totalMessages: number - } - >() - - // ========== 分析 2 & 3: 最晚/最早发言 ========== - // 按调整后的日期分组消息 - const dailyMessages = new Map>() - - // ========== 分析 4: 连续修仙天数 ========== - const memberNightDays = new Map>() // 成员 -> 有深夜发言的日期集合 - - for (const msg of messages) { - // 记录成员信息 - if (!memberInfo.has(msg.senderId)) { - memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) - } - - const date = new Date(msg.ts * 1000) - const hour = date.getHours() - const minute = date.getMinutes() - const adjustedDate = getAdjustedDate(msg.ts) - - // 初始化成员夜猫统计 - if (!nightStats.has(msg.senderId)) { - nightStats.set(msg.senderId, { total: 0, h23: 0, h0: 0, h1: 0, h2: 0, h3to4: 0, totalMessages: 0 }) - } - const stats = nightStats.get(msg.senderId)! - stats.totalMessages++ - - // 统计深夜发言 (23:00-05:00) - if (hour === 23) { - stats.h23++ - stats.total++ - } else if (hour === 0) { - stats.h0++ - stats.total++ - } else if (hour === 1) { - stats.h1++ - stats.total++ - } else if (hour === 2) { - stats.h2++ - stats.total++ - } else if (hour >= 3 && hour < 5) { - stats.h3to4++ - stats.total++ - } - - // 记录深夜发言的日期(用于连续天数统计) - if (hour >= 23 || hour < 5) { - if (!memberNightDays.has(msg.senderId)) { - memberNightDays.set(msg.senderId, new Set()) - } - memberNightDays.get(msg.senderId)!.add(adjustedDate) - } - - // 按日期分组消息(用于最晚/最早发言统计) - if (!dailyMessages.has(adjustedDate)) { - dailyMessages.set(adjustedDate, []) - } - dailyMessages.get(adjustedDate)!.push({ senderId: msg.senderId, ts: msg.ts, hour, minute }) - } - - const totalDays = dailyMessages.size - - // ========== 构建修仙排行榜 ========== - const nightOwlRank: NightOwlRankItem[] = [] - for (const [memberId, stats] of nightStats.entries()) { - if (stats.total === 0) continue - const info = memberInfo.get(memberId)! - nightOwlRank.push({ - memberId, - platformId: info.platformId, - name: info.name, - totalNightMessages: stats.total, - title: getNightOwlTitleByCount(stats.total), - hourlyBreakdown: { - h23: stats.h23, - h0: stats.h0, - h1: stats.h1, - h2: stats.h2, - h3to4: stats.h3to4, - }, - percentage: stats.totalMessages > 0 ? Math.round((stats.total / stats.totalMessages) * 10000) / 100 : 0, - }) - } - nightOwlRank.sort((a, b) => b.totalNightMessages - a.totalNightMessages) - - // ========== 构建最晚/最早发言排行 ========== - const lastSpeakerStats = new Map() - const firstSpeakerStats = new Map() - - for (const [, dayMessages] of dailyMessages.entries()) { - if (dayMessages.length === 0) continue - - // 找到当天最后发言的人 - const lastMsg = dayMessages[dayMessages.length - 1] - if (!lastSpeakerStats.has(lastMsg.senderId)) { - lastSpeakerStats.set(lastMsg.senderId, { count: 0, times: [] }) - } - const lastStats = lastSpeakerStats.get(lastMsg.senderId)! - lastStats.count++ - lastStats.times.push(lastMsg.hour * 60 + lastMsg.minute) - - // 找到当天最早发言的人 - const firstMsg = dayMessages[0] - if (!firstSpeakerStats.has(firstMsg.senderId)) { - firstSpeakerStats.set(firstMsg.senderId, { count: 0, times: [] }) - } - const firstStats = firstSpeakerStats.get(firstMsg.senderId)! - firstStats.count++ - firstStats.times.push(firstMsg.hour * 60 + firstMsg.minute) - } - - // 构建最晚发言排行 - const lastSpeakerRank: TimeRankItem[] = [] - for (const [memberId, stats] of lastSpeakerStats.entries()) { - const info = memberInfo.get(memberId)! - const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length - const maxMinutes = Math.max(...stats.times) - lastSpeakerRank.push({ - memberId, - platformId: info.platformId, - name: info.name, - count: stats.count, - avgTime: formatMinutes(avgMinutes), - extremeTime: formatMinutes(maxMinutes), - percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0, - }) - } - lastSpeakerRank.sort((a, b) => b.count - a.count) - - // 构建最早发言排行 - const firstSpeakerRank: TimeRankItem[] = [] - for (const [memberId, stats] of firstSpeakerStats.entries()) { - const info = memberInfo.get(memberId)! - const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length - const minMinutes = Math.min(...stats.times) - firstSpeakerRank.push({ - memberId, - platformId: info.platformId, - name: info.name, - count: stats.count, - avgTime: formatMinutes(avgMinutes), - extremeTime: formatMinutes(minMinutes), - percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0, - }) - } - firstSpeakerRank.sort((a, b) => b.count - a.count) - - // ========== 构建连续修仙天数记录 ========== - const consecutiveRecords: ConsecutiveNightRecord[] = [] - - for (const [memberId, nightDaysSet] of memberNightDays.entries()) { - if (nightDaysSet.size === 0) continue - - const info = memberInfo.get(memberId)! - const sortedDays = Array.from(nightDaysSet).sort() - - let maxStreak = 1 - let currentStreak = 1 - - for (let i = 1; i < sortedDays.length; i++) { - const prevDate = new Date(sortedDays[i - 1]) - const currDate = new Date(sortedDays[i]) - const diffDays = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) - - if (diffDays === 1) { - currentStreak++ - maxStreak = Math.max(maxStreak, currentStreak) - } else { - currentStreak = 1 - } - } - - // 检查当前是否还在连续(最后一天是否是最近的) - const lastDay = sortedDays[sortedDays.length - 1] - const today = new Date().toISOString().split('T')[0] - const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0] - const isCurrentStreak = lastDay === today || lastDay === yesterday - - consecutiveRecords.push({ - memberId, - platformId: info.platformId, - name: info.name, - maxConsecutiveDays: maxStreak, - currentStreak: isCurrentStreak ? currentStreak : 0, - }) - } - consecutiveRecords.sort((a, b) => b.maxConsecutiveDays - a.maxConsecutiveDays) - - // ========== 构建修仙王者(综合排名) ========== - // 综合得分 = 深夜发言数 × 1 + 最晚下班次数 × 10 + 连续修仙天数 × 20 - const championScores = new Map< - number, - { nightMessages: number; lastSpeakerCount: number; consecutiveDays: number } - >() - - for (const item of nightOwlRank) { - if (!championScores.has(item.memberId)) { - championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) - } - championScores.get(item.memberId)!.nightMessages = item.totalNightMessages - } - - for (const item of lastSpeakerRank) { - if (!championScores.has(item.memberId)) { - championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) - } - championScores.get(item.memberId)!.lastSpeakerCount = item.count - } - - for (const item of consecutiveRecords) { - if (!championScores.has(item.memberId)) { - championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) - } - championScores.get(item.memberId)!.consecutiveDays = item.maxConsecutiveDays - } - - const champions: NightOwlChampion[] = [] - for (const [memberId, scores] of championScores.entries()) { - const info = memberInfo.get(memberId)! - const score = scores.nightMessages * 1 + scores.lastSpeakerCount * 10 + scores.consecutiveDays * 20 - if (score > 0) { - champions.push({ - memberId, - platformId: info.platformId, - name: info.name, - score, - nightMessages: scores.nightMessages, - lastSpeakerCount: scores.lastSpeakerCount, - consecutiveDays: scores.consecutiveDays, - }) - } - } - champions.sort((a, b) => b.score - a.score) - - return { - nightOwlRank, - lastSpeakerRank, - firstSpeakerRank, - consecutiveRecords, - champions, - totalDays, - } - } finally { - db.close() - } -} - -/** - * 获取龙王排名 - * 每天发言最多的人+1,统计所有天数 - */ -export function getDragonKingAnalysis(sessionId: string, filter?: TimeFilter): DragonKingAnalysis { - const db = openDatabase(sessionId) - const emptyResult: DragonKingAnalysis = { - rank: [], - totalDays: 0, - } - - if (!db) return emptyResult - - try { - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - // 查询每天每个人的发言数,找出每天的龙王 - const dailyTopSpeakers = db - .prepare( - ` - WITH daily_counts AS ( - SELECT - strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date, - msg.sender_id, - m.platform_id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - COUNT(*) as msg_count - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY date, msg.sender_id - ), - daily_max AS ( - SELECT date, MAX(msg_count) as max_count - FROM daily_counts - GROUP BY date - ) - SELECT dc.sender_id, dc.platform_id, dc.name, COUNT(*) as dragon_days - FROM daily_counts dc - JOIN daily_max dm ON dc.date = dm.date AND dc.msg_count = dm.max_count - GROUP BY dc.sender_id - ORDER BY dragon_days DESC - ` - ) - .all(...params) as Array<{ - sender_id: number - platform_id: string - name: string - dragon_days: number - }> - - // 获取总天数 - const totalDaysRow = db - .prepare( - ` - SELECT COUNT(DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime')) as total - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - ` - ) - .get(...params) as { total: number } - - const totalDays = totalDaysRow.total - - const rank: DragonKingRankItem[] = dailyTopSpeakers.map((item) => ({ - memberId: item.sender_id, - platformId: item.platform_id, - name: item.name, - count: item.dragon_days, - percentage: totalDays > 0 ? Math.round((item.dragon_days / totalDays) * 10000) / 100 : 0, - })) - - return { rank, totalDays } - } finally { - db.close() - } -} - -/** - * 获取潜水排名 - * 所有人的最后一次发言记录,按时间倒序(最久没发言的在前面) - */ -export function getDivingAnalysis(sessionId: string, filter?: TimeFilter): DivingAnalysis { - const db = openDatabase(sessionId) - const emptyResult: DivingAnalysis = { - rank: [], - } - - if (!db) return emptyResult - - try { - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - // 查询每个成员的最后发言时间 - const lastMessages = db - .prepare( - ` - SELECT - m.id as member_id, - m.platform_id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - MAX(msg.ts) as last_ts - FROM member m - JOIN message msg ON m.id = msg.sender_id - ${clauseWithSystem.replace('msg.', 'msg.')} - GROUP BY m.id - ORDER BY last_ts ASC - ` - ) - .all(...params) as Array<{ - member_id: number - platform_id: string - name: string - last_ts: number - }> - - const now = Math.floor(Date.now() / 1000) - - const rank: DivingRankItem[] = lastMessages.map((item) => ({ - memberId: item.member_id, - platformId: item.platform_id, - name: item.name, - lastMessageTs: item.last_ts, - daysSinceLastMessage: Math.floor((now - item.last_ts) / 86400), - })) - - return { rank } - } finally { - db.close() - } -} - -/** - * 获取自言自语分析 - * 定义:一个人连续发言超过三次(间隔<=5分钟) - * 分类:3-4句(加特林)、5-9句(小作文)、10+句(无人区广播) - */ -export function getMonologueAnalysis(sessionId: string, filter?: TimeFilter): MonologueAnalysis { - const db = openDatabase(sessionId) - const emptyResult: MonologueAnalysis = { - rank: [], - maxComboRecord: null, - } - - if (!db) return emptyResult - - try { - const { clause, params } = buildTimeFilter(filter) - - // 构建 WHERE 子句:只统计文本消息 - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0" - } else { - whereClause = " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0" - } - - // 获取所有文本消息,按时间排序 - const messages = db - .prepare( - ` - SELECT - msg.id, - msg.sender_id as senderId, - msg.ts, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - ORDER BY msg.ts ASC - ` - ) - .all(...params) as Array<{ - id: number - senderId: number - ts: number - platformId: string - name: string - }> - - if (messages.length === 0) return emptyResult - - // 成员信息映射 - const memberInfo = new Map() - - // 连击统计:memberId -> { totalStreaks, maxCombo, lowStreak, midStreak, highStreak } - const memberStats = new Map< - number, - { - totalStreaks: number - maxCombo: number - lowStreak: number - midStreak: number - highStreak: number - } - >() - - // 全群最高纪录 - let globalMaxCombo: { memberId: number; comboLength: number; startTs: number } | null = null - - // 连击间隔限制:5分钟 = 300秒 - const MAX_INTERVAL = 300 - - // 当前连击状态 - let currentStreak = { - senderId: -1, - count: 0, - startTs: 0, - lastTs: 0, - } - - // 处理一个连击结束 - const finishStreak = () => { - if (currentStreak.count >= 3) { - const memberId = currentStreak.senderId - - // 初始化成员统计 - if (!memberStats.has(memberId)) { - memberStats.set(memberId, { - totalStreaks: 0, - maxCombo: 0, - lowStreak: 0, - midStreak: 0, - highStreak: 0, - }) - } - - const stats = memberStats.get(memberId)! - stats.totalStreaks++ - stats.maxCombo = Math.max(stats.maxCombo, currentStreak.count) - - // 分类统计:3-4句、5-9句、10+句 - if (currentStreak.count >= 10) { - stats.highStreak++ - } else if (currentStreak.count >= 5) { - stats.midStreak++ - } else { - stats.lowStreak++ // 3-4句 - } - - // 更新全群最高纪录 - if (!globalMaxCombo || currentStreak.count > globalMaxCombo.comboLength) { - globalMaxCombo = { - memberId, - comboLength: currentStreak.count, - startTs: currentStreak.startTs, - } - } - } - } - - // 遍历所有消息 - for (const msg of messages) { - // 记录成员信息 - if (!memberInfo.has(msg.senderId)) { - memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) - } - - // 检查是否延续当前连击 - const isSameSender = msg.senderId === currentStreak.senderId - const isWithinInterval = msg.ts - currentStreak.lastTs <= MAX_INTERVAL - - if (isSameSender && isWithinInterval) { - // 延续连击 - currentStreak.count++ - currentStreak.lastTs = msg.ts - } else { - // 结束当前连击,开始新的 - finishStreak() - currentStreak = { - senderId: msg.senderId, - count: 1, - startTs: msg.ts, - lastTs: msg.ts, - } - } - } - - // 处理最后一个连击 - finishStreak() - - // 构建排行榜(按总连击次数排序) - const rank: MonologueRankItem[] = [] - for (const [memberId, stats] of memberStats.entries()) { - const info = memberInfo.get(memberId)! - rank.push({ - memberId, - platformId: info.platformId, - name: info.name, - totalStreaks: stats.totalStreaks, - maxCombo: stats.maxCombo, - lowStreak: stats.lowStreak, - midStreak: stats.midStreak, - highStreak: stats.highStreak, - }) - } - rank.sort((a, b) => b.totalStreaks - a.totalStreaks) - - // 构建全群最高纪录 - let maxComboRecord: MaxComboRecord | null = null - if (globalMaxCombo) { - const info = memberInfo.get(globalMaxCombo.memberId)! - maxComboRecord = { - memberId: globalMaxCombo.memberId, - platformId: info.platformId, - memberName: info.name, - comboLength: globalMaxCombo.comboLength, - startTs: globalMaxCombo.startTs, - } - } - - return { rank, maxComboRecord } - } finally { - db.close() - } -} diff --git a/electron/main/database/core.ts b/electron/main/database/core.ts deleted file mode 100644 index 4dbd198f4..000000000 --- a/electron/main/database/core.ts +++ /dev/null @@ -1,463 +0,0 @@ -/** - * 数据库核心模块 - * 负责数据库的创建、打开、关闭和数据导入 - */ - -import Database from 'better-sqlite3' -import * as fs from 'fs' -import * as path from 'path' -import type { DbMeta, ParseResult, AnalysisSession } from '../../../src/types/base' -import { migrateDatabase, needsMigration, CURRENT_SCHEMA_VERSION } from './migrations' -import { getDatabaseDir, ensureDir } from '../paths' - -/** - * 获取数据库目录 - */ -function getDbDir(): string { - return getDatabaseDir() -} - -/** - * 确保数据库目录存在 - */ -function ensureDbDir(): void { - ensureDir(getDbDir()) -} - -/** - * 生成唯一的会话ID - */ -function generateSessionId(): string { - const timestamp = Date.now() - const random = Math.random().toString(36).substring(2, 8) - return `chat_${timestamp}_${random}` -} - -/** - * 获取数据库文件路径 - */ -export function getDbPath(sessionId: string): string { - return path.join(getDbDir(), `${sessionId}.db`) -} - -/** - * 创建新数据库并初始化表结构 - */ -function createDatabase(sessionId: string): Database.Database { - ensureDbDir() - const dbPath = getDbPath(sessionId) - const db = new Database(dbPath) - - db.pragma('journal_mode = WAL') - - db.exec(` - CREATE TABLE IF NOT EXISTS meta ( - name TEXT NOT NULL, - platform TEXT NOT NULL, - type TEXT NOT NULL, - imported_at INTEGER NOT NULL, - group_id TEXT, - group_avatar TEXT, - owner_id TEXT, - schema_version INTEGER DEFAULT ${CURRENT_SCHEMA_VERSION} - ); - - CREATE TABLE IF NOT EXISTS member ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - platform_id TEXT NOT NULL UNIQUE, - account_name TEXT, - group_nickname TEXT, - aliases TEXT DEFAULT '[]', - avatar TEXT, - roles TEXT DEFAULT '[]' - ); - - CREATE TABLE IF NOT EXISTS member_name_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - member_id INTEGER NOT NULL, - name_type TEXT NOT NULL, - name TEXT NOT NULL, - start_ts INTEGER NOT NULL, - end_ts INTEGER, - FOREIGN KEY(member_id) REFERENCES member(id) - ); - - CREATE TABLE IF NOT EXISTS message ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - sender_id INTEGER NOT NULL, - sender_account_name TEXT, - sender_group_nickname TEXT, - ts INTEGER NOT NULL, - type INTEGER NOT NULL, - content TEXT, - reply_to_message_id TEXT DEFAULT NULL, - platform_message_id TEXT DEFAULT NULL, - FOREIGN KEY(sender_id) REFERENCES member(id) - ); - - CREATE INDEX IF NOT EXISTS idx_message_ts ON message(ts); - CREATE INDEX IF NOT EXISTS idx_message_sender ON message(sender_id); - CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id); - CREATE INDEX IF NOT EXISTS idx_member_name_history_member_id ON member_name_history(member_id); - `) - - return db -} - -/** - * 打开已存在的数据库 - * @param readonly 是否只读模式(默认 true) - */ -export function openDatabase(sessionId: string, readonly = true): Database.Database | null { - const dbPath = getDbPath(sessionId) - if (!fs.existsSync(dbPath)) { - return null - } - const db = new Database(dbPath, { readonly }) - db.pragma('journal_mode = WAL') - return db -} - -/** - * 打开数据库并执行迁移(如果需要) - * 用于需要写入的场景 - * @param sessionId 会话ID - * @param forceRepair 是否强制修复(即使版本号已是最新也重新执行迁移脚本) - */ -export function openDatabaseWithMigration(sessionId: string, forceRepair = false): Database.Database | null { - const dbPath = getDbPath(sessionId) - if (!fs.existsSync(dbPath)) { - return null - } - - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - - // 执行迁移 - migrateDatabase(db, forceRepair) - - return db -} - -/** - * 导入解析后的数据到数据库 - */ -export function importData(parseResult: ParseResult): string { - const sessionId = generateSessionId() - const dbPath = getDbPath(sessionId) - const db = createDatabase(sessionId) - - try { - const importTransaction = db.transaction(() => { - const insertMeta = db.prepare(` - INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar, owner_id) - VALUES (?, ?, ?, ?, ?, ?, ?) - `) - insertMeta.run( - parseResult.meta.name, - parseResult.meta.platform, - parseResult.meta.type, - Math.floor(Date.now() / 1000), - parseResult.meta.groupId || null, - parseResult.meta.groupAvatar || null, - parseResult.meta.ownerId || null - ) - - const insertMember = db.prepare(` - INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar, roles) VALUES (?, ?, ?, ?, ?) - `) - const getMemberId = db.prepare(` - SELECT id FROM member WHERE platform_id = ? - `) - - const memberIdMap = new Map() - - for (const member of parseResult.members) { - insertMember.run( - member.platformId, - member.accountName || null, - member.groupNickname || null, - member.avatar || null, - member.roles ? JSON.stringify(member.roles) : '[]' - ) - const row = getMemberId.get(member.platformId) as { id: number } - memberIdMap.set(member.platformId, row.id) - } - - const sortedMessages = [...parseResult.messages].sort((a, b) => a.timestamp - b.timestamp) - // 分别追踪 account_name 和 group_nickname 的变化 - const accountNameTracker = new Map() - const groupNicknameTracker = new Map() - - const insertMessage = db.prepare(` - INSERT INTO message (sender_id, sender_account_name, sender_group_nickname, ts, type, content, reply_to_message_id, platform_message_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `) - const insertNameHistory = db.prepare(` - INSERT INTO member_name_history (member_id, name_type, name, start_ts, end_ts) - VALUES (?, ?, ?, ?, ?) - `) - const updateMemberAccountName = db.prepare(` - UPDATE member SET account_name = ? WHERE platform_id = ? - `) - const updateMemberGroupNickname = db.prepare(` - UPDATE member SET group_nickname = ? WHERE platform_id = ? - `) - const updateNameHistoryEndTs = db.prepare(` - UPDATE member_name_history - SET end_ts = ? - WHERE member_id = ? AND name_type = ? AND end_ts IS NULL - `) - - for (const msg of sortedMessages) { - const senderId = memberIdMap.get(msg.senderPlatformId) - if (senderId === undefined) continue - - insertMessage.run( - senderId, - msg.senderAccountName || null, - msg.senderGroupNickname || null, - msg.timestamp, - msg.type, - msg.content, - msg.replyToMessageId || null, - msg.platformMessageId || null - ) - - // 追踪 account_name 变化 - const accountName = msg.senderAccountName - if (accountName) { - const tracker = accountNameTracker.get(msg.senderPlatformId) - if (!tracker) { - accountNameTracker.set(msg.senderPlatformId, { - currentName: accountName, - lastSeenTs: msg.timestamp, - }) - insertNameHistory.run(senderId, 'account_name', accountName, msg.timestamp, null) - } else if (tracker.currentName !== accountName) { - updateNameHistoryEndTs.run(msg.timestamp, senderId, 'account_name') - insertNameHistory.run(senderId, 'account_name', accountName, msg.timestamp, null) - tracker.currentName = accountName - tracker.lastSeenTs = msg.timestamp - } else { - tracker.lastSeenTs = msg.timestamp - } - } - - // 追踪 group_nickname 变化 - const groupNickname = msg.senderGroupNickname - if (groupNickname) { - const tracker = groupNicknameTracker.get(msg.senderPlatformId) - if (!tracker) { - groupNicknameTracker.set(msg.senderPlatformId, { - currentName: groupNickname, - lastSeenTs: msg.timestamp, - }) - insertNameHistory.run(senderId, 'group_nickname', groupNickname, msg.timestamp, null) - } else if (tracker.currentName !== groupNickname) { - updateNameHistoryEndTs.run(msg.timestamp, senderId, 'group_nickname') - insertNameHistory.run(senderId, 'group_nickname', groupNickname, msg.timestamp, null) - tracker.currentName = groupNickname - tracker.lastSeenTs = msg.timestamp - } else { - tracker.lastSeenTs = msg.timestamp - } - } - } - - // 更新成员最新的 account_name 和 group_nickname - for (const [platformId, tracker] of accountNameTracker.entries()) { - updateMemberAccountName.run(tracker.currentName, platformId) - } - for (const [platformId, tracker] of groupNicknameTracker.entries()) { - updateMemberGroupNickname.run(tracker.currentName, platformId) - } - }) - - importTransaction() - - const fileExists = fs.existsSync(dbPath) - - return sessionId - } catch (error) { - console.error('[Database] Error in importData:', error) - throw error - } finally { - db.close() - - const fileExists = fs.existsSync(dbPath) - } -} - -/** - * 更新会话的 ownerId - */ -export function updateSessionOwnerId(sessionId: string, ownerId: string | null): boolean { - // 使用带迁移的打开方式,确保 owner_id 列存在 - const db = openDatabaseWithMigration(sessionId) - if (!db) { - return false - } - - try { - const stmt = db.prepare('UPDATE meta SET owner_id = ?') - stmt.run(ownerId) - return true - } catch (error) { - console.error('[Database] Failed to update session ownerId:', error) - return false - } finally { - db.close() - } -} - -/** - * 删除会话 - */ -export function deleteSession(sessionId: string): boolean { - const dbPath = getDbPath(sessionId) - const walPath = dbPath + '-wal' - const shmPath = dbPath + '-shm' - - try { - if (fs.existsSync(dbPath)) { - fs.unlinkSync(dbPath) - } - if (fs.existsSync(walPath)) { - fs.unlinkSync(walPath) - } - if (fs.existsSync(shmPath)) { - fs.unlinkSync(shmPath) - } - return true - } catch (error) { - console.error('[Database] Failed to delete session:', error) - return false - } -} - -/** - * 重命名会话 - */ -export function renameSession(sessionId: string, newName: string): boolean { - const dbPath = getDbPath(sessionId) - if (!fs.existsSync(dbPath)) { - return false - } - - try { - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - - const stmt = db.prepare('UPDATE meta SET name = ?') - stmt.run(newName) - - db.close() - return true - } catch (error) { - console.error('[Database] Failed to rename session:', error) - return false - } -} - -/** - * 获取数据库存储目录 - */ -export function getDbDirectory(): string { - ensureDbDir() - return getDbDir() -} - -/** - * 检查是否有数据库需要迁移 - * @returns 需要迁移的数据库数量、列表、最低版本和需要强制修复的列表 - */ -export function checkMigrationNeeded(): { - count: number - sessionIds: string[] - lowestVersion: number - forceRepairIds: string[] -} { - ensureDbDir() - const dbDir = getDbDir() - const files = fs.readdirSync(dbDir).filter((f) => f.endsWith('.db')) - const needsMigrationList: string[] = [] - const forceRepairList: string[] = [] - let lowestVersion = CURRENT_SCHEMA_VERSION - - for (const file of files) { - const sessionId = file.replace('.db', '') - const dbPath = getDbPath(sessionId) - - try { - const db = new Database(dbPath, { readonly: true }) - db.pragma('journal_mode = WAL') - - // 获取当前 schema_version - const metaTableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> - const hasVersionColumn = metaTableInfo.some((col) => col.name === 'schema_version') - let dbVersion = 0 - if (hasVersionColumn) { - const result = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as - | { schema_version: number | null } - | undefined - dbVersion = result?.schema_version ?? 0 - } - - // 检查 message 表是否有 reply_to_message_id 列 - const messageTableInfo = db.prepare('PRAGMA table_info(message)').all() as Array<{ name: string }> - const hasReplyColumn = messageTableInfo.some((col) => col.name === 'reply_to_message_id') - - if (needsMigration(db)) { - needsMigrationList.push(sessionId) - lowestVersion = Math.min(lowestVersion, dbVersion) - } else if (!hasReplyColumn) { - // 特殊情况:版本号已更新但列不存在,需要强制修复 - needsMigrationList.push(sessionId) - forceRepairList.push(sessionId) - lowestVersion = Math.min(lowestVersion, dbVersion) - } - - db.close() - } catch (error) { - console.error(`[Database] Failed to check migration for ${file}:`, error) - } - } - - return { count: needsMigrationList.length, sessionIds: needsMigrationList, lowestVersion, forceRepairIds: forceRepairList } -} - -/** - * 执行所有数据库的迁移 - * @returns 迁移结果 - */ -export function migrateAllDatabases(): { success: boolean; migratedCount: number; error?: string } { - const { sessionIds, forceRepairIds } = checkMigrationNeeded() - const forceRepairSet = new Set(forceRepairIds) - - if (sessionIds.length === 0) { - return { success: true, migratedCount: 0 } - } - - let migratedCount = 0 - - for (const sessionId of sessionIds) { - try { - const needsForceRepair = forceRepairSet.has(sessionId) - const db = openDatabaseWithMigration(sessionId, needsForceRepair) - if (db) { - db.close() - migratedCount++ - } - } catch (error) { - console.error(`[Database] Failed to migrate ${sessionId}:`, error) - return { - success: false, - migratedCount, - error: `迁移 ${sessionId} 失败: ${error instanceof Error ? error.message : String(error)}`, - } - } - } - - return { success: true, migratedCount } -} diff --git a/electron/main/database/migrations.ts b/electron/main/database/migrations.ts deleted file mode 100644 index 1cba38a69..000000000 --- a/electron/main/database/migrations.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * 数据库迁移系统 - * - * 用于管理数据库 schema 的版本升级。 - * 每次添加新字段或修改表结构时,创建一个新的迁移脚本。 - * - * 使用方式: - * 1. 在 migrations 数组中添加新的迁移对象 - * 2. version 必须递增 - * 3. up 函数执行迁移逻辑 - */ - -import type Database from 'better-sqlite3' - -/** 迁移脚本接口 */ -interface Migration { - /** 版本号(必须递增) */ - version: number - /** 迁移描述(技术说明) */ - description: string - /** 用户可读的升级原因(显示在弹窗中) */ - userMessage: string - /** 迁移执行函数 */ - up: (db: Database.Database) => void -} - -/** 导出给前端使用的迁移信息 */ -export interface MigrationInfo { - version: number - /** 技术描述(面向开发者) */ - description: string - /** 用户可读的升级原因(显示在弹窗中) */ - userMessage: string -} - -/** 当前 schema 版本(最新迁移的版本号) */ -export const CURRENT_SCHEMA_VERSION = 2 - -/** - * 迁移脚本列表 - * 注意:版本号必须递增,每个迁移只执行一次 - */ -const migrations: Migration[] = [ - { - version: 1, - description: '添加 owner_id 字段到 meta 表', - userMessage: '支持「Owner」功能,可在成员列表中设置自己的身份', - up: (db) => { - // 检查 owner_id 列是否已存在(防止重复执行) - const tableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> - const hasOwnerIdColumn = tableInfo.some((col) => col.name === 'owner_id') - if (!hasOwnerIdColumn) { - db.exec('ALTER TABLE meta ADD COLUMN owner_id TEXT') - } - }, - }, - { - version: 2, - description: '添加 roles、reply_to_message_id、platform_message_id 字段', - userMessage: '支持成员角色、消息回复关系和回复内容预览', - up: (db) => { - // 检查 roles 列是否已存在(防止重复执行) - const memberTableInfo = db.prepare('PRAGMA table_info(member)').all() as Array<{ name: string }> - const hasRolesColumn = memberTableInfo.some((col) => col.name === 'roles') - if (!hasRolesColumn) { - db.exec("ALTER TABLE member ADD COLUMN roles TEXT DEFAULT '[]'") - } - - // 检查 message 表的列 - const messageTableInfo = db.prepare('PRAGMA table_info(message)').all() as Array<{ name: string }> - - // 检查 reply_to_message_id 列是否已存在 - const hasReplyColumn = messageTableInfo.some((col) => col.name === 'reply_to_message_id') - if (!hasReplyColumn) { - db.exec('ALTER TABLE message ADD COLUMN reply_to_message_id TEXT DEFAULT NULL') - } - - // 添加 platform_message_id 列(存储平台原始消息 ID,用于回复关联查询) - const hasPlatformMsgIdColumn = messageTableInfo.some((col) => col.name === 'platform_message_id') - if (!hasPlatformMsgIdColumn) { - db.exec('ALTER TABLE message ADD COLUMN platform_message_id TEXT DEFAULT NULL') - } - - // 创建索引以加速回复查询 - try { - db.exec('CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id)') - } catch { - // 索引可能已存在 - } - }, - }, -] - -/** - * 获取数据库的 schema 版本 - * 如果没有版本信息,返回 0 - */ -function getSchemaVersion(db: Database.Database): number { - try { - // 检查 meta 表是否有 schema_version 列 - const tableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> - const hasVersionColumn = tableInfo.some((col) => col.name === 'schema_version') - - if (!hasVersionColumn) { - return 0 - } - - const result = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as - | { schema_version: number | null } - | undefined - return result?.schema_version ?? 0 - } catch { - return 0 - } -} - -/** - * 设置数据库的 schema 版本 - */ -function setSchemaVersion(db: Database.Database, version: number): void { - // 检查 schema_version 列是否存在 - const tableInfo = db.prepare('PRAGMA table_info(meta)').all() as Array<{ name: string }> - const hasVersionColumn = tableInfo.some((col) => col.name === 'schema_version') - - if (!hasVersionColumn) { - // 添加 schema_version 列 - db.exec('ALTER TABLE meta ADD COLUMN schema_version INTEGER DEFAULT 0') - } - - // 更新版本号 - db.prepare('UPDATE meta SET schema_version = ?').run(version) -} - -/** - * 执行数据库迁移 - * 自动检测当前版本并执行所有需要的迁移 - * - * @param db 数据库连接 - * @param forceRepair 是否强制修复(即使版本号已是最新也重新执行迁移脚本) - * @returns 是否执行了迁移 - */ -export function migrateDatabase(db: Database.Database, forceRepair = false): boolean { - const currentVersion = getSchemaVersion(db) - - // 如果不是强制修复模式,检查版本号 - if (!forceRepair && currentVersion >= CURRENT_SCHEMA_VERSION) { - return false - } - - // 获取需要执行的迁移 - // 如果是强制修复,从 version 0 开始执行所有迁移 - const pendingMigrations = forceRepair - ? migrations - : migrations.filter((m) => m.version > currentVersion) - - if (pendingMigrations.length === 0) { - return false - } - - // 在事务中执行所有迁移 - const migrate = db.transaction(() => { - for (const migration of pendingMigrations) { - migration.up(db) - setSchemaVersion(db, migration.version) - } - }) - - migrate() - return true -} - -/** - * 检查数据库是否需要迁移 - */ -export function needsMigration(db: Database.Database): boolean { - const currentVersion = getSchemaVersion(db) - return currentVersion < CURRENT_SCHEMA_VERSION -} - -/** - * 获取待执行的迁移信息(用户可读) - * @param fromVersion 起始版本(不含) - */ -export function getPendingMigrationInfos(fromVersion = 0): MigrationInfo[] { - return migrations - .filter((m) => m.version > fromVersion) - .map((m) => ({ - version: m.version, - description: m.description, - userMessage: m.userMessage, - })) -} diff --git a/electron/main/index.ts b/electron/main/index.ts deleted file mode 100644 index 804632539..000000000 --- a/electron/main/index.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { app, shell, BrowserWindow, protocol, nativeTheme } from 'electron' -import { join } from 'path' -import { optimizer, is, platform } from '@electron-toolkit/utils' -import { checkUpdate } from './update' -import mainIpcMain from './ipcMain' -import { initAnalytics, trackDailyActive } from './analytics' -import { initProxy } from './network/proxy' -import { needsLegacyMigration, migrateFromLegacyDir, ensureAppDirs } from './paths' -import { migrateAllDatabases, checkMigrationNeeded } from './database/core' - -class MainProcess { - mainWindow: BrowserWindow | null - constructor() { - // 主窗口 - this.mainWindow = null - - // 设置应用程序名称 - if (process.platform === 'win32') app.setAppUserModelId(app.getName()) - // 初始化 - this.checkApp().then(async (lockObtained) => { - if (lockObtained) { - await this.init() - } - }) - } - - // 单例锁 - async checkApp() { - if (!app.requestSingleInstanceLock()) { - app.quit() - // 未获得锁 - return false - } - // 聚焦到当前程序 - else { - app.on('second-instance', () => { - if (this.mainWindow) { - this.mainWindow.show() - if (this.mainWindow.isMinimized()) this.mainWindow.restore() - this.mainWindow.focus() - } - }) - // 获得锁 - return true - } - } - - // 初始化程序 - async init() { - initAnalytics() - - // 执行数据目录迁移(从 Documents/ChatLab 迁移到 userData) - this.migrateDataIfNeeded() - - // 确保应用目录存在 - ensureAppDirs() - - // 执行数据库 schema 迁移(确保所有数据库在 Worker 查询前已是最新 schema) - this.migrateDatabasesIfNeeded() - - initProxy() // 初始化代理配置 - - // 注册应用协议 - app.setAsDefaultProtocolClient('chatlab') - - // 应用程序准备好之前注册 - protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: { secure: true, standard: true } }]) - - // 主应用程序事件 - this.mainAppEvents() - } - - // 从旧目录迁移数据(静默迁移) - migrateDataIfNeeded() { - if (needsLegacyMigration()) { - console.log('[Main] Legacy data migration needed, starting migration...') - const result = migrateFromLegacyDir() - if (result.success) { - console.log(`[Main] Migration completed. Migrated: ${result.migratedDirs.join(', ')}`) - } else { - console.error('[Main] Migration failed:', result.error) - } - } else { - console.log('[Main] No legacy data migration needed') - } - } - - // 执行数据库 schema 迁移(静默迁移) - migrateDatabasesIfNeeded() { - try { - const { count } = checkMigrationNeeded() - if (count > 0) { - const result = migrateAllDatabases() - if (!result.success) { - console.error('[Main] Database schema migration failed:', result.error) - } - } - } catch (error) { - console.error('[Main] Error in migrateDatabasesIfNeeded:', error) - } - } - - // 创建主窗口 - async createWindow() { - this.mainWindow = new BrowserWindow({ - width: 1180, - height: 752, - minWidth: 1180, - minHeight: 752, - show: false, - autoHideMenuBar: true, - webPreferences: { - preload: join(__dirname, '../preload/index.js'), - sandbox: false, - devTools: true, - }, - }) - - // 设置默认日间模式 - nativeTheme.themeSource = 'light' - - this.mainWindow.once('ready-to-show', () => { - this.mainWindow?.show() - }) - - // 主窗口事件 - this.mainWindowEvents() - - this.mainWindow.webContents.setWindowOpenHandler((details) => { - shell.openExternal(details.url) - return { action: 'deny' } - }) - - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. - if (is.dev && process.env['ELECTRON_RENDERER_URL']) { - this.mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) - } else { - this.mainWindow.loadFile(join(__dirname, '../../out/renderer/index.html')) - } - } - - // 主应用程序事件 - mainAppEvents() { - app.whenReady().then(async () => { - console.log('[Main] App is ready') - // 设置Windows应用程序用户模型id - if (process.platform === 'win32') app.setAppUserModelId(app.getName()) - - // 记录日活(用于统计操作系统版本、客户端版本,便于更好的适配客户端) - trackDailyActive() - - // 创建主窗口 - console.log('[Main] Creating window...') - await this.createWindow() - console.log('[Main] Window created') - - // 检查更新逻辑 - checkUpdate(this.mainWindow) - - // 引入主进程ipcMain - if (this.mainWindow) { - console.log('[Main] Registering IPC handlers...') - mainIpcMain(this.mainWindow) - console.log('[Main] IPC handlers registered') - } - - // 开发环境下 F12 打开控制台 - app.on('browser-window-created', (_, window) => { - optimizer.watchWindowShortcuts(window) - }) - - app.on('activate', () => { - // 在 macOS 上,当单击 Dock 图标且没有其他窗口时,通常会重新创建窗口 - if (BrowserWindow.getAllWindows().length === 0) { - this.createWindow() - return - } - - if (platform.isMacOS) { - this.mainWindow?.show() - } - }) - - // 监听渲染进程崩溃 - app.on('render-process-gone', (e, w, d) => { - if (d.reason == 'crashed') { - w.reload() - } - // fs.appendFile(`./error-log-${+new Date()}.txt`, `${new Date()}渲染进程被杀死${d.reason}\n`) - }) - - // 自定义协议 - app.on('open-url', (_, url) => { - console.log('Received custom protocol URL:', url) - }) - - // 当所有窗口都关闭时退出应用,macOS 除外 - app.on('window-all-closed', () => { - if (!platform.isMacOS) { - app.quit() - } - }) - - // 只有显式调用quit才退出系统,区分MAC系统程序坞退出和点击X隐藏 - app.on('before-quit', () => { - // @ts-ignore - app.isQuiting = true - }) - }) - } - - // 主窗口事件 - mainWindowEvents() { - if (!this.mainWindow) { - return - } - this.mainWindow.webContents.on('did-finish-load', () => { - setTimeout(() => { - this.mainWindow && this.mainWindow.webContents.send('app-started') - }, 500) - }) - - this.mainWindow.on('maximize', () => { - this.mainWindow?.webContents.send('windowState', true) - }) - - this.mainWindow.on('unmaximize', () => { - this.mainWindow?.webContents.send('windowState', false) - }) - - // 窗口关闭 - this.mainWindow.on('close', (event) => { - event.preventDefault() - // @ts-ignore - if (!app.isQuiting) { - this.mainWindow?.hide() - } else { - app.exit() - } - }) - } -} - -// 捕获未捕获的异常 -process.on('uncaughtException', (error) => { - console.error('Uncaught Exception:', error) -}) - -new MainProcess() diff --git a/electron/main/ipc/ai.ts b/electron/main/ipc/ai.ts deleted file mode 100644 index 76c41c54c..000000000 --- a/electron/main/ipc/ai.ts +++ /dev/null @@ -1,515 +0,0 @@ -// electron/main/ipc/ai.ts -import { ipcMain, BrowserWindow } from 'electron' -import * as aiConversations from '../ai/conversations' -import * as llm from '../ai/llm' -import { aiLogger } from '../ai/logger' -import { Agent, type AgentStreamChunk, type PromptConfig } from '../ai/agent' -import type { ToolContext } from '../ai/tools/types' -import type { IpcContext } from './types' - -// ==================== AI Agent 请求追踪 ==================== -// 用于跟踪活跃的 Agent 请求,支持中止操作 -const activeAgentRequests = new Map() - -export function registerAIHandlers({ win }: IpcContext): void { - console.log('[IPC] Registering AI handlers...') - - // ==================== AI 对话管理 ==================== - - /** - * 创建新的 AI 对话 - */ - ipcMain.handle( - 'ai:createConversation', - async ( - _, - title: string, - sessionId?: string, - dataSource?: { type: 'chat' | 'member'; id: string; name?: string } - ) => { - try { - return aiConversations.createConversation(title, sessionId, dataSource) - } catch (error) { - console.error('创建 AI 对话失败:', error) - throw error - } - } - ) - - /** - * 获取所有 AI 对话列表 - */ - ipcMain.handle('ai:getConversations', async (_, sessionId?: string) => { - try { - return aiConversations.getConversations(sessionId) - } catch (error) { - console.error('获取 AI 对话列表失败:', error) - return [] - } - }) - - /** - * 获取单个对话详情 - */ - ipcMain.handle('ai:getConversation', async (_, conversationId: string) => { - try { - return aiConversations.getConversation(conversationId) - } catch (error) { - console.error('获取 AI 对话详情失败:', error) - return null - } - }) - - /** - * 更新 AI 对话标题 - */ - ipcMain.handle('ai:updateConversationTitle', async (_, conversationId: string, title: string) => { - try { - return aiConversations.updateConversationTitle(conversationId, title) - } catch (error) { - console.error('更新 AI 对话标题失败:', error) - return false - } - }) - - /** - * 删除 AI 对话 - */ - ipcMain.handle('ai:deleteConversation', async (_, conversationId: string) => { - try { - return aiConversations.deleteConversation(conversationId) - } catch (error) { - console.error('删除 AI 对话失败:', error) - return false - } - }) - - /** - * 添加 AI 消息 - */ - ipcMain.handle( - 'ai:addMessage', - async ( - _, - conversationId: string, - role: 'user' | 'assistant', - content: string, - dataKeywords?: string[], - dataMessageCount?: number, - contentBlocks?: aiConversations.ContentBlock[] - ) => { - try { - return aiConversations.addMessage(conversationId, role, content, dataKeywords, dataMessageCount, contentBlocks) - } catch (error) { - console.error('添加 AI 消息失败:', error) - throw error - } - } - ) - - /** - * 获取 AI 对话的所有消息 - */ - ipcMain.handle('ai:getMessages', async (_, conversationId: string) => { - try { - return aiConversations.getMessages(conversationId) - } catch (error) { - console.error('获取 AI 消息失败:', error) - return [] - } - }) - - /** - * 删除 AI 消息 - */ - ipcMain.handle('ai:deleteMessage', async (_, messageId: string) => { - try { - return aiConversations.deleteMessage(messageId) - } catch (error) { - console.error('删除 AI 消息失败:', error) - return false - } - }) - - // ==================== LLM 服务(多配置管理)==================== - - /** - * 获取所有支持的 LLM 提供商 - */ - ipcMain.handle('llm:getProviders', async () => { - return llm.PROVIDERS - }) - - /** - * 获取所有配置列表 - */ - ipcMain.handle('llm:getAllConfigs', async () => { - const configs = llm.getAllConfigs() - // 脱敏 API Key - return configs.map((c) => ({ - ...c, - apiKey: c.apiKey ? `${c.apiKey.slice(0, 4)}****${c.apiKey.slice(-4)}` : '', - apiKeySet: !!c.apiKey, - })) - }) - - /** - * 获取当前激活的配置 ID - */ - ipcMain.handle('llm:getActiveConfigId', async () => { - const config = llm.getActiveConfig() - return config?.id || null - }) - - /** - * 添加新配置 - */ - ipcMain.handle( - 'llm:addConfig', - async ( - _, - config: { - name: string - provider: llm.LLMProvider - apiKey: string - model?: string - baseUrl?: string - maxTokens?: number - } - ) => { - try { - const result = llm.addConfig(config) - if (result.success && result.config) { - return { - success: true, - config: { - ...result.config, - apiKey: result.config.apiKey - ? `${result.config.apiKey.slice(0, 4)}****${result.config.apiKey.slice(-4)}` - : '', - apiKeySet: !!result.config.apiKey, - }, - } - } - return result - } catch (error) { - console.error('添加 LLM 配置失败:', error) - return { success: false, error: String(error) } - } - } - ) - - /** - * 更新配置 - */ - ipcMain.handle( - 'llm:updateConfig', - async ( - _, - id: string, - updates: { - name?: string - provider?: llm.LLMProvider - apiKey?: string - model?: string - baseUrl?: string - maxTokens?: number - } - ) => { - try { - // 如果 apiKey 为空字符串,表示不更新 API Key - const cleanUpdates = { ...updates } - if (cleanUpdates.apiKey === '') { - delete cleanUpdates.apiKey - } - - return llm.updateConfig(id, cleanUpdates) - } catch (error) { - console.error('更新 LLM 配置失败:', error) - return { success: false, error: String(error) } - } - } - ) - - /** - * 删除配置 - */ - ipcMain.handle('llm:deleteConfig', async (_, id?: string) => { - try { - // 如果没有传 id,删除当前激活的配置 - if (!id) { - const activeConfig = llm.getActiveConfig() - if (activeConfig) { - return llm.deleteConfig(activeConfig.id) - } - return { success: false, error: '没有激活的配置' } - } - return llm.deleteConfig(id) - } catch (error) { - console.error('删除 LLM 配置失败:', error) - return { success: false, error: String(error) } - } - }) - - /** - * 设置激活的配置 - */ - ipcMain.handle('llm:setActiveConfig', async (_, id: string) => { - try { - return llm.setActiveConfig(id) - } catch (error) { - console.error('设置激活配置失败:', error) - return { success: false, error: String(error) } - } - }) - - /** - * 验证 API Key(支持自定义 baseUrl 和 model) - * 返回对象格式:{ success: boolean, error?: string } - */ - ipcMain.handle( - 'llm:validateApiKey', - async (_, provider: llm.LLMProvider, apiKey: string, baseUrl?: string, model?: string) => { - console.log('[LLM:validateApiKey] 开始验证:', { provider, baseUrl, model, apiKeyLength: apiKey?.length }) - try { - const service = llm.createLLMService({ provider, apiKey, baseUrl, model }) - const result = await service.validateApiKey() - console.log('[LLM:validateApiKey] 验证结果:', result) - return { success: result.success, error: result.error } - } catch (error) { - console.error('[LLM:validateApiKey] 验证失败:', error) - // 提取有意义的错误信息 - const errorMessage = error instanceof Error ? error.message : String(error) - return { success: false, error: errorMessage } - } - } - ) - - /** - * 检查是否已配置 LLM(是否有激活的配置) - */ - ipcMain.handle('llm:hasConfig', async () => { - return llm.hasActiveConfig() - }) - - /** - * 发送 LLM 聊天请求(非流式) - */ - ipcMain.handle('llm:chat', async (_, messages: llm.ChatMessage[], options?: llm.ChatOptions) => { - aiLogger.info('IPC', '收到非流式 LLM 请求', { - messagesCount: messages.length, - firstMsgRole: messages[0]?.role, - firstMsgContentLen: messages[0]?.content?.length, - options, - }) - try { - const response = await llm.chat(messages, options) - aiLogger.info('IPC', '非流式 LLM 请求成功', { responseLength: response.length }) - return { success: true, content: response } - } catch (error) { - aiLogger.error('IPC', '非流式 LLM 请求失败', { error: String(error) }) - console.error('LLM 聊天失败:', error) - return { success: false, error: String(error) } - } - }) - - /** - * 发送 LLM 聊天请求(流式) - * 使用 IPC 事件发送流式数据 - */ - ipcMain.handle( - 'llm:chatStream', - async (_, requestId: string, messages: llm.ChatMessage[], options?: llm.ChatOptions) => { - aiLogger.info('IPC', `收到流式聊天请求: ${requestId}`, { - messagesCount: messages.length, - options, - }) - - try { - const generator = llm.chatStream(messages, options) - aiLogger.info('IPC', `创建流式生成器: ${requestId}`) - - // 异步处理流式响应 - ;(async () => { - let chunkIndex = 0 - try { - aiLogger.info('IPC', `开始迭代流式响应: ${requestId}`) - for await (const chunk of generator) { - chunkIndex++ - aiLogger.debug('IPC', `发送 chunk #${chunkIndex}: ${requestId}`, { - contentLength: chunk.content?.length, - isFinished: chunk.isFinished, - finishReason: chunk.finishReason, - }) - win.webContents.send('llm:streamChunk', { requestId, chunk }) - } - aiLogger.info('IPC', `流式响应完成: ${requestId}`, { totalChunks: chunkIndex }) - } catch (error) { - aiLogger.error('IPC', `流式响应出错: ${requestId}`, { - error: String(error), - chunkIndex, - }) - win.webContents.send('llm:streamChunk', { - requestId, - chunk: { content: '', isFinished: true, finishReason: 'error' }, - error: String(error), - }) - } - })() - - return { success: true } - } catch (error) { - aiLogger.error('IPC', `创建流式请求失败: ${requestId}`, { error: String(error) }) - console.error('LLM 流式聊天失败:', error) - return { success: false, error: String(error) } - } - } - ) - - // ==================== AI Agent API ==================== - - /** - * 执行 Agent 对话(流式) - * Agent 会自动调用工具并返回最终结果 - * @param historyMessages 对话历史(可选,用于上下文关联) - * @param chatType 聊天类型('group' | 'private') - * @param promptConfig 用户自定义提示词配置(可选) - * @param locale 语言设置(可选,默认 'zh-CN') - */ - ipcMain.handle( - 'agent:runStream', - async ( - _, - requestId: string, - userMessage: string, - context: ToolContext, - historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, - chatType?: 'group' | 'private', - promptConfig?: PromptConfig, - locale?: string - ) => { - aiLogger.info('IPC', `收到 Agent 流式请求: ${requestId}`, { - userMessage: userMessage.slice(0, 100), - sessionId: context.sessionId, - historyLength: historyMessages?.length ?? 0, - chatType: chatType ?? 'group', - hasPromptConfig: !!promptConfig, - }) - - try { - // 创建 AbortController 并存储 - const abortController = new AbortController() - activeAgentRequests.set(requestId, abortController) - - // 转换历史消息格式 - const formattedHistory = - historyMessages?.map((msg) => ({ - role: msg.role as 'user' | 'assistant', - content: msg.content, - })) ?? [] - - const agent = new Agent( - context, - { abortSignal: abortController.signal }, - formattedHistory, - chatType ?? 'group', - promptConfig, - locale ?? 'zh-CN' - ) - - // 异步执行,通过事件发送流式数据 - ;(async () => { - try { - const result = await agent.executeStream(userMessage, (chunk: AgentStreamChunk) => { - // 如果已中止,不再发送 - if (abortController.signal.aborted) { - return - } - // 减少日志噪音:只在工具调用时记录 - if (chunk.type === 'tool_start' || chunk.type === 'tool_result') { - aiLogger.debug('IPC', `Agent chunk: ${requestId}`, { - type: chunk.type, - toolName: chunk.toolName, - }) - } - win.webContents.send('agent:streamChunk', { requestId, chunk }) - }) - - // 如果已中止,不发送完成信息 - if (abortController.signal.aborted) { - aiLogger.info('IPC', `Agent 已中止,跳过完成信息: ${requestId}`) - return - } - - // 发送完成信息 - win.webContents.send('agent:complete', { - requestId, - result: { - content: result.content, - toolsUsed: result.toolsUsed, - toolRounds: result.toolRounds, - totalUsage: result.totalUsage, - }, - }) - - aiLogger.info('IPC', `Agent 执行完成: ${requestId}`, { - toolsUsed: result.toolsUsed, - toolRounds: result.toolRounds, - contentLength: result.content.length, - totalUsage: result.totalUsage, - }) - } catch (error) { - // 如果是中止错误,不报告为错误 - if (error instanceof Error && error.name === 'AbortError') { - aiLogger.info('IPC', `Agent 请求已中止: ${requestId}`) - return - } - aiLogger.error('IPC', `Agent 执行出错: ${requestId}`, { error: String(error) }) - // 发送错误 chunk - win.webContents.send('agent:streamChunk', { - requestId, - chunk: { type: 'error', error: String(error), isFinished: true }, - }) - // 发送完成事件(带错误信息),确保前端 promise 能 resolve - win.webContents.send('agent:complete', { - requestId, - result: { - content: '', - toolsUsed: [], - toolRounds: 0, - totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, - error: String(error), - }, - }) - } finally { - // 清理请求追踪 - activeAgentRequests.delete(requestId) - } - })() - - return { success: true } - } catch (error) { - aiLogger.error('IPC', `创建 Agent 请求失败: ${requestId}`, { error: String(error) }) - return { success: false, error: String(error) } - } - } - ) - - /** - * 中止 Agent 请求 - */ - ipcMain.handle('agent:abort', async (_, requestId: string) => { - aiLogger.info('IPC', `收到中止请求: ${requestId}`) - - const abortController = activeAgentRequests.get(requestId) - if (abortController) { - abortController.abort() - activeAgentRequests.delete(requestId) - aiLogger.info('IPC', `已中止 Agent 请求: ${requestId}`) - return { success: true } - } else { - aiLogger.warn('IPC', `未找到 Agent 请求: ${requestId}`) - return { success: false, error: '未找到该请求' } - } - }) -} diff --git a/electron/main/ipc/cache.ts b/electron/main/ipc/cache.ts deleted file mode 100644 index bf72d9047..000000000 --- a/electron/main/ipc/cache.ts +++ /dev/null @@ -1,293 +0,0 @@ -// electron/main/ipc/cache.ts -import { ipcMain, app, shell } from 'electron' -import * as fs from 'fs/promises' -import * as fsSync from 'fs' -import * as path from 'path' -import type { IpcContext } from './types' -import { - getAppDataDir, - getDatabaseDir, - getAiDataDir, - getLogsDir, - getDownloadsDir, - ensureDir, -} from '../paths' - -/** - * 递归计算目录大小 - */ -async function getDirSize(dirPath: string): Promise { - let totalSize = 0 - try { - const exists = fsSync.existsSync(dirPath) - if (!exists) return 0 - - const files = await fs.readdir(dirPath, { withFileTypes: true }) - for (const file of files) { - const filePath = path.join(dirPath, file.name) - if (file.isDirectory()) { - totalSize += await getDirSize(filePath) - } else { - const stat = await fs.stat(filePath) - totalSize += stat.size - } - } - } catch (error) { - console.error('[Cache] Error getting dir size:', dirPath, error) - } - return totalSize -} - -/** - * 获取目录中的文件数量 - */ -async function getFileCount(dirPath: string): Promise { - let count = 0 - try { - const exists = fsSync.existsSync(dirPath) - if (!exists) return 0 - - const files = await fs.readdir(dirPath, { withFileTypes: true }) - for (const file of files) { - const filePath = path.join(dirPath, file.name) - if (file.isDirectory()) { - count += await getFileCount(filePath) - } else { - count++ - } - } - } catch (error) { - console.error('[Cache] Error getting file count:', dirPath, error) - } - return count -} - -export function registerCacheHandlers(_context: IpcContext): void { - console.log('[IPC] Registering cache handlers...') - - /** - * 获取所有缓存目录信息 - */ - ipcMain.handle('cache:getInfo', async () => { - const appDataDir = getAppDataDir() - - // 定义缓存目录(应用数据目录下的子目录) - const cacheDirectories = [ - { - id: 'databases', - name: 'settings.storage.cache.databases.name', - description: 'settings.storage.cache.databases.description', - path: getDatabaseDir(), - icon: 'i-heroicons-circle-stack', - canClear: false, // 不允许一键清理,因为是重要数据 - }, - { - id: 'ai', - name: 'settings.storage.cache.ai.name', - description: 'settings.storage.cache.ai.description', - path: getAiDataDir(), - icon: 'i-heroicons-sparkles', - canClear: false, // 不允许一键清理 - }, - // 临时文件已有自动清理机制(应用启动时、合并完成后),无需暴露给用户 - { - id: 'logs', - name: 'settings.storage.cache.logs.name', - description: 'settings.storage.cache.logs.description', - path: getLogsDir(), - icon: 'i-heroicons-document-text', - canClear: true, // 可以清理 - }, - ] - - // 获取每个目录的信息 - const results = await Promise.all( - cacheDirectories.map(async (dir) => { - const size = await getDirSize(dir.path) - const fileCount = await getFileCount(dir.path) - const exists = fsSync.existsSync(dir.path) - - return { - ...dir, - size, - fileCount, - exists, - } - }) - ) - - return { - baseDir: appDataDir, - directories: results, - totalSize: results.reduce((sum, dir) => sum + dir.size, 0), - } - }) - - /** - * 清理指定缓存目录 - */ - ipcMain.handle('cache:clear', async (_, cacheId: string) => { - // 只允许清理 logs(temp 由系统自动清理,downloads 已改为系统下载目录) - const allowedDirs: Record = { - logs: getLogsDir(), - } - - const dirPath = allowedDirs[cacheId] - if (!dirPath) { - return { success: false, error: '不允许清理此目录' } - } - - try { - const exists = fsSync.existsSync(dirPath) - if (!exists) { - return { success: true, message: '目录不存在,无需清理' } - } - - // 删除目录下的所有文件 - const files = await fs.readdir(dirPath) - for (const file of files) { - const filePath = path.join(dirPath, file) - const stat = await fs.stat(filePath) - if (stat.isDirectory()) { - await fs.rm(filePath, { recursive: true }) - } else { - await fs.unlink(filePath) - } - } - - console.log(`[Cache] Cleared directory: ${dirPath}`) - return { success: true } - } catch (error) { - console.error('[Cache] Error clearing cache:', error) - return { success: false, error: String(error) } - } - }) - - /** - * 保存文件到系统下载目录 - * 支持两种 data URL 格式: - * 1. base64: data:image/png;base64,xxx - * 2. URL 编码: data:text/plain;charset=utf-8,xxx - */ - ipcMain.handle('cache:saveToDownloads', async (_, filename: string, dataUrl: string) => { - const downloadsDir = getDownloadsDir() - - try { - // 系统下载目录应该已存在,但以防万一还是确保一下 - ensureDir(downloadsDir) - - let buffer: Buffer - - // 解析 data URL - if (dataUrl.includes(';base64,')) { - // Base64 编码格式(图片等二进制数据) - const base64Data = dataUrl.split(';base64,')[1] - buffer = Buffer.from(base64Data, 'base64') - } else if (dataUrl.includes('charset=utf-8,')) { - // URL 编码格式(文本数据) - const textData = dataUrl.split('charset=utf-8,')[1] - const decodedText = decodeURIComponent(textData) - buffer = Buffer.from(decodedText, 'utf-8') - } else { - // 默认尝试作为 base64 处理 - const base64Data = dataUrl.replace(/^data:[^,]+,/, '') - buffer = Buffer.from(base64Data, 'base64') - } - - // 写入文件 - const filePath = path.join(downloadsDir, filename) - await fs.writeFile(filePath, buffer) - - console.log(`[Cache] Saved file to downloads: ${filePath}`) - return { success: true, filePath } - } catch (error) { - console.error('[Cache] Error saving to downloads:', error) - return { success: false, error: String(error) } - } - }) - - /** - * 在文件管理器中打开缓存目录 - */ - ipcMain.handle('cache:openDir', async (_, cacheId: string) => { - const dirPaths: Record = { - base: getAppDataDir(), - databases: getDatabaseDir(), - ai: getAiDataDir(), - logs: getLogsDir(), - downloads: getDownloadsDir(), // 系统下载目录 - } - - const dirPath = dirPaths[cacheId] - if (!dirPath) { - return { success: false, error: '未知的目录' } - } - - try { - // 确保目录存在(系统下载目录应该已存在) - if (!fsSync.existsSync(dirPath)) { - await fs.mkdir(dirPath, { recursive: true }) - } - - await shell.openPath(dirPath) - return { success: true } - } catch (error) { - console.error('[Cache] Error opening directory:', error) - return { success: false, error: String(error) } - } - }) - - /** - * 获取最新的导入日志文件路径 - */ - ipcMain.handle('cache:getLatestImportLog', async () => { - const importLogDir = path.join(getLogsDir(), 'import') - - try { - if (!fsSync.existsSync(importLogDir)) { - return { success: false, error: '日志目录不存在' } - } - - const files = await fs.readdir(importLogDir) - const logFiles = files.filter((f) => f.startsWith('import_') && f.endsWith('.log')) - - if (logFiles.length === 0) { - return { success: false, error: '没有找到导入日志' } - } - - // 按修改时间排序,获取最新的 - const fileStats = await Promise.all( - logFiles.map(async (f) => { - const filePath = path.join(importLogDir, f) - const stat = await fs.stat(filePath) - return { name: f, path: filePath, mtime: stat.mtime.getTime() } - }) - ) - - fileStats.sort((a, b) => b.mtime - a.mtime) - const latestLog = fileStats[0] - - return { success: true, path: latestLog.path, name: latestLog.name } - } catch (error) { - console.error('[Cache] Error getting latest import log:', error) - return { success: false, error: String(error) } - } - }) - - /** - * 在文件管理器中显示并高亮文件 - */ - ipcMain.handle('cache:showInFolder', async (_, filePath: string) => { - try { - if (!fsSync.existsSync(filePath)) { - return { success: false, error: '文件不存在' } - } - - shell.showItemInFolder(filePath) - return { success: true } - } catch (error) { - console.error('[Cache] Error showing file in folder:', error) - return { success: false, error: String(error) } - } - }) -} diff --git a/electron/main/ipc/chat.ts b/electron/main/ipc/chat.ts deleted file mode 100644 index 8fd31a9b8..000000000 --- a/electron/main/ipc/chat.ts +++ /dev/null @@ -1,626 +0,0 @@ -/** - * 聊天记录导入与分析 IPC 处理器 - */ - -import { ipcMain, app, dialog } from 'electron' -import * as databaseCore from '../database/core' -import * as worker from '../worker/workerManager' -import * as parser from '../parser' -import { detectFormat, diagnoseFormat, type ParseProgress } from '../parser' -import type { IpcContext } from './types' -import { CURRENT_SCHEMA_VERSION, getPendingMigrationInfos, type MigrationInfo } from '../database/migrations' - -/** - * 注册聊天记录相关 IPC 处理器 - */ -export function registerChatHandlers(ctx: IpcContext): void { - const { win } = ctx - - // ==================== 数据库迁移 ==================== - - /** - * 检查是否需要数据库迁移 - */ - ipcMain.handle('chat:checkMigration', async () => { - try { - const result = databaseCore.checkMigrationNeeded() - // 获取待执行的迁移信息(从最低版本开始) - const pendingMigrations = getPendingMigrationInfos(result.lowestVersion) - return { - needsMigration: result.count > 0, - count: result.count, - currentVersion: CURRENT_SCHEMA_VERSION, - pendingMigrations, - } - } catch (error) { - console.error('[IpcMain] 检查迁移失败:', error) - return { needsMigration: false, count: 0, currentVersion: CURRENT_SCHEMA_VERSION, pendingMigrations: [] } - } - }) - - /** - * 执行数据库迁移 - */ - ipcMain.handle('chat:runMigration', async () => { - try { - const result = databaseCore.migrateAllDatabases() - return result - } catch (error) { - console.error('[IpcMain] 执行迁移失败:', error) - return { success: false, migratedCount: 0, error: String(error) } - } - }) - - // ==================== 聊天记录导入与分析 ==================== - - /** - * 选择聊天记录文件 - */ - ipcMain.handle('chat:selectFile', async () => { - try { - const { canceled, filePaths } = await dialog.showOpenDialog({ - title: '选择聊天记录文件', - defaultPath: app.getPath('documents'), - properties: ['openFile'], - filters: [ - { name: '聊天记录', extensions: ['json', 'jsonl', 'txt'] }, - { name: '所有文件', extensions: ['*'] }, - ], - buttonLabel: '导入', - }) - - if (canceled || filePaths.length === 0) { - return null - } - - const filePath = filePaths[0] - - // 检测文件格式(使用流式检测,只读取文件开头) - const formatFeature = detectFormat(filePath) - const format = formatFeature?.name || null - if (!format) { - // 使用诊断功能获取详细的错误信息 - const diagnosis = diagnoseFormat(filePath) - // 返回详细的错误信息 - return { - error: 'error.unrecognized_format', - diagnosis: { - suggestion: diagnosis.suggestion, - partialMatches: diagnosis.partialMatches.map((m) => ({ - formatName: m.formatName, - missingFields: m.missingFields, - })), - }, - } - } - - return { filePath, format } - } catch (error) { - console.error('[IpcMain] Error selecting file:', error) - return { error: String(error) } - } - }) - - /** - * 导入聊天记录(流式版本) - */ - ipcMain.handle('chat:import', async (_, filePath: string) => { - try { - // Send progress: detecting format (message not used by frontend, stage-based translation) - win.webContents.send('chat:importProgress', { - stage: 'detecting', - progress: 5, - message: '', // Frontend translates based on stage - }) - - // 使用流式导入(在 Worker 线程中执行) - const result = await worker.streamImport(filePath, (progress: ParseProgress) => { - // 转发进度到渲染进程 - win.webContents.send('chat:importProgress', { - stage: progress.stage, - progress: progress.percentage, - message: progress.message, - bytesRead: progress.bytesRead, - totalBytes: progress.totalBytes, - messagesProcessed: progress.messagesProcessed, - }) - }) - - if (result.success) { - console.log('[IpcMain] Stream import successful, sessionId:', result.sessionId) - return { success: true, sessionId: result.sessionId } - } else { - console.error('[IpcMain] Stream import failed:', result.error) - win.webContents.send('chat:importProgress', { - stage: 'error', - progress: 0, - message: result.error, - }) - - // 如果是格式不识别错误,提供诊断信息 - if (result.error === 'error.unrecognized_format') { - const diagnosis = diagnoseFormat(filePath) - return { - success: false, - error: result.error, - diagnosis: { - suggestion: diagnosis.suggestion, - partialMatches: diagnosis.partialMatches.map((m) => ({ - formatName: m.formatName, - missingFields: m.missingFields, - })), - }, - } - } - - return { success: false, error: result.error } - } - } catch (error) { - console.error('[IpcMain] Import failed:', error) - - win.webContents.send('chat:importProgress', { - stage: 'error', - progress: 0, - message: String(error), - }) - - return { success: false, error: String(error) } - } - }) - - /** - * 获取所有分析会话列表 - */ - ipcMain.handle('chat:getSessions', async () => { - try { - const sessions = await worker.getAllSessions() - return sessions - } catch (error) { - console.error('[IpcMain] Error getting sessions:', error) - return [] - } - }) - - /** - * 获取单个会话信息 - */ - ipcMain.handle('chat:getSession', async (_, sessionId: string) => { - try { - return await worker.getSession(sessionId) - } catch (error) { - console.error('获取会话信息失败:', error) - return null - } - }) - - /** - * 删除会话 - */ - ipcMain.handle('chat:deleteSession', async (_, sessionId: string) => { - try { - // 先关闭 Worker 中的数据库连接 - await worker.closeDatabase(sessionId) - // 然后删除文件(使用核心模块) - const result = databaseCore.deleteSession(sessionId) - return result - } catch (error) { - console.error('删除会话失败:', error) - return false - } - }) - - /** - * 重命名会话 - */ - ipcMain.handle('chat:renameSession', async (_, sessionId: string, newName: string) => { - try { - // 先关闭 Worker 中的数据库连接(确保没有其他进程占用) - await worker.closeDatabase(sessionId) - // 执行重命名 - return databaseCore.renameSession(sessionId, newName) - } catch (error) { - console.error('重命名会话失败:', error) - return false - } - }) - - /** - * 获取可用年份列表 - */ - ipcMain.handle('chat:getAvailableYears', async (_, sessionId: string) => { - try { - return await worker.getAvailableYears(sessionId) - } catch (error) { - console.error('获取可用年份失败:', error) - return [] - } - }) - - /** - * 获取成员活跃度排行 - */ - ipcMain.handle( - 'chat:getMemberActivity', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getMemberActivity(sessionId, filter) - } catch (error) { - console.error('获取成员活跃度失败:', error) - return [] - } - } - ) - - /** - * 获取成员历史昵称 - */ - ipcMain.handle('chat:getMemberNameHistory', async (_, sessionId: string, memberId: number) => { - try { - return await worker.getMemberNameHistory(sessionId, memberId) - } catch (error) { - console.error('获取成员历史昵称失败:', error) - return [] - } - }) - - /** - * 获取每小时活跃度分布 - */ - ipcMain.handle( - 'chat:getHourlyActivity', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getHourlyActivity(sessionId, filter) - } catch (error) { - console.error('获取小时活跃度失败:', error) - return [] - } - } - ) - - /** - * 获取每日活跃度趋势 - */ - ipcMain.handle( - 'chat:getDailyActivity', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getDailyActivity(sessionId, filter) - } catch (error) { - console.error('获取日活跃度失败:', error) - return [] - } - } - ) - - /** - * 获取星期活跃度分布 - */ - ipcMain.handle( - 'chat:getWeekdayActivity', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getWeekdayActivity(sessionId, filter) - } catch (error) { - console.error('获取星期活跃度失败:', error) - return [] - } - } - ) - - /** - * 获取月份活跃度分布 - */ - ipcMain.handle( - 'chat:getMonthlyActivity', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getMonthlyActivity(sessionId, filter) - } catch (error) { - console.error('获取月份活跃度失败:', error) - return [] - } - } - ) - - /** - * 获取消息类型分布 - */ - ipcMain.handle( - 'chat:getMessageTypeDistribution', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getMessageTypeDistribution(sessionId, filter) - } catch (error) { - console.error('获取消息类型分布失败:', error) - return [] - } - } - ) - - /** - * 获取时间范围 - */ - ipcMain.handle('chat:getTimeRange', async (_, sessionId: string) => { - try { - return await worker.getTimeRange(sessionId) - } catch (error) { - console.error('获取时间范围失败:', error) - return null - } - }) - - /** - * 获取数据库存储目录 - */ - ipcMain.handle('chat:getDbDirectory', async () => { - try { - return worker.getDbDirectory() - } catch (error) { - console.error('获取数据库目录失败:', error) - return null - } - }) - - /** - * 获取支持的格式列表 - */ - ipcMain.handle('chat:getSupportedFormats', async () => { - return parser.getSupportedFormats() - }) - - /** - * 获取复读分析数据 - */ - ipcMain.handle( - 'chat:getRepeatAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getRepeatAnalysis(sessionId, filter) - } catch (error) { - console.error('获取复读分析失败:', error) - return { originators: [], initiators: [], breakers: [], totalRepeatChains: 0 } - } - } - ) - - /** - * 获取口头禅分析数据 - */ - ipcMain.handle( - 'chat:getCatchphraseAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getCatchphraseAnalysis(sessionId, filter) - } catch (error) { - console.error('获取口头禅分析失败:', error) - return { members: [] } - } - } - ) - - /** - * 获取夜猫分析数据 - */ - ipcMain.handle( - 'chat:getNightOwlAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getNightOwlAnalysis(sessionId, filter) - } catch (error) { - console.error('获取夜猫分析失败:', error) - return { - nightOwlRank: [], - lastSpeakerRank: [], - firstSpeakerRank: [], - consecutiveRecords: [], - champions: [], - totalDays: 0, - } - } - } - ) - - /** - * 获取龙王分析数据 - */ - ipcMain.handle( - 'chat:getDragonKingAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getDragonKingAnalysis(sessionId, filter) - } catch (error) { - console.error('获取龙王分析失败:', error) - return { rank: [], totalDays: 0 } - } - } - ) - - /** - * 获取潜水分析数据 - */ - ipcMain.handle( - 'chat:getDivingAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getDivingAnalysis(sessionId, filter) - } catch (error) { - console.error('获取潜水分析失败:', error) - return { rank: [] } - } - } - ) - - /** - * 获取自言自语分析数据 - */ - ipcMain.handle( - 'chat:getMonologueAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getMonologueAnalysis(sessionId, filter) - } catch (error) { - console.error('获取自言自语分析失败:', error) - return { rank: [], maxComboRecord: null } - } - } - ) - - /** - * 获取 @ 互动分析数据 - */ - ipcMain.handle( - 'chat:getMentionAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getMentionAnalysis(sessionId, filter) - } catch (error) { - console.error('获取 @ 互动分析失败:', error) - return { topMentioners: [], topMentioned: [], oneWay: [], twoWay: [], totalMentions: 0, memberDetails: [] } - } - } - ) - - /** - * 获取含笑量分析数据 - */ - ipcMain.handle( - 'chat:getLaughAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }, keywords?: string[]) => { - try { - return await worker.getLaughAnalysis(sessionId, filter, keywords) - } catch (error) { - console.error('获取含笑量分析失败:', error) - return { - rankByRate: [], - rankByCount: [], - typeDistribution: [], - totalLaughs: 0, - totalMessages: 0, - groupLaughRate: 0, - } - } - } - ) - - /** - * 获取斗图分析数据 - */ - ipcMain.handle( - 'chat:getMemeBattleAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getMemeBattleAnalysis(sessionId, filter) - } catch (error) { - console.error('获取斗图分析失败:', error) - return { - longestBattle: null, - rankByCount: [], - rankByImageCount: [], - totalBattles: 0, - } - } - } - ) - - /** - * 获取打卡分析数据(火花榜 + 忠臣榜) - */ - ipcMain.handle( - 'chat:getCheckInAnalysis', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }) => { - try { - return await worker.getCheckInAnalysis(sessionId, filter) - } catch (error) { - console.error('获取打卡分析失败:', error) - return { - streakRank: [], - loyaltyRank: [], - totalDays: 0, - } - } - } - ) - - // ==================== 成员管理 ==================== - - /** - * 获取所有成员列表(含消息数和别名) - */ - ipcMain.handle('chat:getMembers', async (_, sessionId: string) => { - try { - return await worker.getMembers(sessionId) - } catch (error) { - console.error('获取成员列表失败:', error) - return [] - } - }) - - /** - * 更新成员别名 - */ - ipcMain.handle('chat:updateMemberAliases', async (_, sessionId: string, memberId: number, aliases: string[]) => { - try { - return await worker.updateMemberAliases(sessionId, memberId, aliases) - } catch (error) { - console.error('更新成员别名失败:', error) - return false - } - }) - - /** - * 删除成员及其所有消息 - */ - ipcMain.handle('chat:deleteMember', async (_, sessionId: string, memberId: number) => { - try { - // 先关闭数据库连接 - await worker.closeDatabase(sessionId) - // 执行删除 - return await worker.deleteMember(sessionId, memberId) - } catch (error) { - console.error('删除成员失败:', error) - return false - } - }) - - /** - * 更新会话的所有者(ownerId) - */ - ipcMain.handle('chat:updateSessionOwnerId', async (_, sessionId: string, ownerId: string | null) => { - try { - // 先关闭数据库连接 - await worker.closeDatabase(sessionId) - // 执行更新 - return databaseCore.updateSessionOwnerId(sessionId, ownerId) - } catch (error) { - console.error('更新会话所有者失败:', error) - return false - } - }) - - // ==================== SQL 实验室 ==================== - - /** - * 执行用户 SQL 查询 - */ - ipcMain.handle('chat:executeSQL', async (_, sessionId: string, sql: string) => { - try { - return await worker.executeRawSQL(sessionId, sql) - } catch (error) { - console.error('执行 SQL 失败:', error) - throw error - } - }) - - /** - * 获取数据库 Schema - */ - ipcMain.handle('chat:getSchema', async (_, sessionId: string) => { - try { - return await worker.getSchema(sessionId) - } catch (error) { - console.error('获取 Schema 失败:', error) - return [] - } - }) -} diff --git a/electron/main/ipc/merge.ts b/electron/main/ipc/merge.ts deleted file mode 100644 index b66853e7f..000000000 --- a/electron/main/ipc/merge.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * 合并功能 IPC 处理器 - */ - -import { ipcMain } from 'electron' -import * as worker from '../worker/workerManager' -import * as merger from '../merger' -import { deleteTempDatabase, cleanupAllTempDatabases } from '../merger/tempCache' -import type { ParseProgress } from '../parser' -import type { MergeParams } from '../../../src/types/format' -import type { IpcContext } from './types' - -// ==================== 临时数据库缓存 ==================== -// 用于合并功能:缓存文件对应的临时数据库路径 -// 这样用户删除本地文件后仍然可以进行合并(数据已存入临时数据库) -const tempDbCache = new Map() - -/** - * 清理指定文件的缓存(删除临时数据库) - */ -function clearTempDbCache(filePath: string): void { - const tempDbPath = tempDbCache.get(filePath) - if (tempDbPath) { - deleteTempDatabase(tempDbPath) - tempDbCache.delete(filePath) - } -} - -/** - * 清理所有缓存(删除所有临时数据库) - */ -function clearAllTempDbCache(): void { - for (const tempDbPath of tempDbCache.values()) { - deleteTempDatabase(tempDbPath) - } - tempDbCache.clear() - console.log('[IpcMain] 已清理所有临时数据库缓存') -} - -/** - * 初始化合并模块(清理残留临时数据库) - */ -export function initMergeModule(): void { - cleanupAllTempDatabases() -} - -/** - * 注册合并功能 IPC 处理器 - */ -export function registerMergeHandlers(ctx: IpcContext): void { - const { win } = ctx - - /** - * 解析文件获取基本信息(用于合并预览) - * 使用流式解析,数据写入临时数据库,避免内存溢出 - */ - ipcMain.handle('merge:parseFileInfo', async (_, filePath: string) => { - try { - // 使用流式解析,写入临时数据库 - const result = await worker.streamParseFileInfo(filePath, (progress: ParseProgress) => { - // 可选:发送进度到渲染进程 - win.webContents.send('merge:parseProgress', { - filePath, - progress, - }) - }) - - // 缓存临时数据库路径(用于后续合并) - // 这样即使用户删除本地文件,也能继续合并(数据已在临时数据库中) - if (result.tempDbPath) { - tempDbCache.set(filePath, result.tempDbPath) - console.log(`[IpcMain] 已缓存临时数据库: ${filePath} -> ${result.tempDbPath}`) - } - - // 返回基本信息 - return { - name: result.name, - format: result.format, - platform: result.platform, - messageCount: result.messageCount, - memberCount: result.memberCount, - fileSize: result.fileSize, - } - } catch (error) { - console.error('解析文件信息失败:', error) - throw error - } - }) - - /** - * 检测合并冲突(使用临时数据库) - */ - ipcMain.handle('merge:checkConflicts', async (_, filePaths: string[]) => { - try { - return merger.checkConflictsWithTempDb(filePaths, tempDbCache) - } catch (error) { - console.error('检测冲突失败:', error) - throw error - } - }) - - /** - * 执行合并(使用临时数据库) - */ - ipcMain.handle('merge:mergeFiles', async (_, params: MergeParams) => { - try { - const result = await merger.mergeFilesWithTempDb(params, tempDbCache) - // 合并完成后清理缓存 - if (result.success) { - for (const filePath of params.filePaths) { - clearTempDbCache(filePath) - } - } - return result - } catch (error) { - console.error('合并失败:', error) - return { success: false, error: String(error) } - } - }) - - /** - * 清理合并缓存(用于用户移除文件时) - */ - ipcMain.handle('merge:clearCache', async (_, filePath?: string) => { - if (filePath) { - clearTempDbCache(filePath) - } else { - clearAllTempDbCache() - } - return true - }) -} - diff --git a/electron/main/ipc/messages.ts b/electron/main/ipc/messages.ts deleted file mode 100644 index 8a972e57d..000000000 --- a/electron/main/ipc/messages.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 聊天记录查询 IPC 处理器 - * 提供通用的消息查询功能:搜索、筛选、上下文、无限滚动等 - */ - -import { ipcMain } from 'electron' -import type { IpcContext } from './types' -import * as worker from '../worker/workerManager' - -export function registerMessagesHandlers({ win }: IpcContext): void { - console.log('[IPC] Registering Messages handlers...') - - /** - * 关键词搜索消息 - */ - ipcMain.handle( - 'ai:searchMessages', - async ( - _, - sessionId: string, - keywords: string[], - filter?: { startTs?: number; endTs?: number }, - limit?: number, - offset?: number, - senderId?: number - ) => { - try { - return await worker.searchMessages(sessionId, keywords, filter, limit, offset, senderId) - } catch (error) { - console.error('搜索消息失败:', error) - return { messages: [], total: 0 } - } - } - ) - - /** - * 获取消息上下文 - */ - ipcMain.handle( - 'ai:getMessageContext', - async (_, sessionId: string, messageIds: number | number[], contextSize?: number) => { - try { - return await worker.getMessageContext(sessionId, messageIds, contextSize) - } catch (error) { - console.error('获取消息上下文失败:', error) - return [] - } - } - ) - - /** - * 获取最近消息(AI Agent 专用) - */ - ipcMain.handle( - 'ai:getRecentMessages', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) => { - try { - return await worker.getRecentMessages(sessionId, filter, limit) - } catch (error) { - console.error('获取最近消息失败:', error) - return { messages: [], total: 0 } - } - } - ) - - /** - * 获取所有最近消息(消息查看器专用) - */ - ipcMain.handle( - 'ai:getAllRecentMessages', - async (_, sessionId: string, filter?: { startTs?: number; endTs?: number }, limit?: number) => { - try { - return await worker.getAllRecentMessages(sessionId, filter, limit) - } catch (error) { - console.error('获取所有最近消息失败:', error) - return { messages: [], total: 0 } - } - } - ) - - /** - * 获取两人之间的对话 - */ - ipcMain.handle( - 'ai:getConversationBetween', - async ( - _, - sessionId: string, - memberId1: number, - memberId2: number, - filter?: { startTs?: number; endTs?: number }, - limit?: number - ) => { - try { - return await worker.getConversationBetween(sessionId, memberId1, memberId2, filter, limit) - } catch (error) { - console.error('获取对话失败:', error) - return { messages: [], total: 0, member1Name: '', member2Name: '' } - } - } - ) - - /** - * 获取指定消息之前的 N 条(用于向上无限滚动) - */ - ipcMain.handle( - 'ai:getMessagesBefore', - async ( - _, - sessionId: string, - beforeId: number, - limit?: number, - filter?: { startTs?: number; endTs?: number }, - senderId?: number, - keywords?: string[] - ) => { - try { - return await worker.getMessagesBefore(sessionId, beforeId, limit, filter, senderId, keywords) - } catch (error) { - console.error('获取之前消息失败:', error) - return { messages: [], hasMore: false } - } - } - ) - - /** - * 获取指定消息之后的 N 条(用于向下无限滚动) - */ - ipcMain.handle( - 'ai:getMessagesAfter', - async ( - _, - sessionId: string, - afterId: number, - limit?: number, - filter?: { startTs?: number; endTs?: number }, - senderId?: number, - keywords?: string[] - ) => { - try { - return await worker.getMessagesAfter(sessionId, afterId, limit, filter, senderId, keywords) - } catch (error) { - console.error('获取之后消息失败:', error) - return { messages: [], hasMore: false } - } - } - ) -} - diff --git a/electron/main/ipc/window.ts b/electron/main/ipc/window.ts deleted file mode 100644 index 99a3f202e..000000000 --- a/electron/main/ipc/window.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * 窗口和文件系统操作 IPC 处理器 - */ - -import { ipcMain, app, dialog, clipboard, shell } from 'electron' -import * as fs from 'fs/promises' -import type { IpcContext } from './types' -import { simulateUpdateDialog, manualCheckForUpdates } from '../update' - -/** - * 注册窗口和文件系统操作 IPC 处理器 - */ -export function registerWindowHandlers(ctx: IpcContext): void { - const { win } = ctx - - // ==================== 窗口操作 ==================== - ipcMain.on('window-min', (ev) => { - ev.preventDefault() - win.minimize() - }) - - ipcMain.on('window-maxOrRestore', (ev) => { - const winSizeState = win.isMaximized() - winSizeState ? win.restore() : win.maximize() - ev.reply('windowState', win.isMaximized()) - }) - - ipcMain.on('window-restore', () => { - win.restore() - }) - - ipcMain.on('window-hide', () => { - win.hide() - }) - - ipcMain.on('window-close', () => { - win.close() - // @ts-ignore - app.isQuitting = true - app.quit() - }) - - ipcMain.on('window-resize', (_, data) => { - if (data.resize) { - win.setResizable(true) - } else { - win.setSize(1180, 752) - win.setResizable(false) - } - }) - - ipcMain.on('open-devtools', () => { - win.webContents.openDevTools() - }) - - // ==================== 应用信息 ==================== - ipcMain.handle('app:getVersion', () => { - return app.getVersion() - }) - - // 获取动态文案配置 - ipcMain.handle('app:fetchRemoteConfig', async (_, url: string) => { - try { - const response = await fetch(url) - const data = await response.json() - return { success: true, data } - } catch (error) { - return { success: false, error: String(error) } - } - }) - - // ==================== 更新检查 ==================== - ipcMain.on('check-update', () => { - // 手动检查更新(即使是预发布版本也会提示) - manualCheckForUpdates() - }) - - // 模拟更新弹窗(仅开发模式使用) - ipcMain.on('simulate-update', () => { - if (!app.isPackaged) { - simulateUpdateDialog(win) - } - }) - - // ==================== 通用工具 ==================== - ipcMain.handle('show-message', (event, args) => { - event.sender.send('show-message', args) - }) - - // 复制到剪贴板(文本) - ipcMain.handle('copyData', async (_, data) => { - try { - clipboard.writeText(data) - return true - } catch (error) { - console.error('复制操作出错:', error) - return false - } - }) - - // 复制图片到剪贴板(base64 data URL) - ipcMain.handle('copyImage', async (_, dataUrl: string) => { - try { - // 从 data URL 中提取 base64 数据 - const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '') - const imageBuffer = Buffer.from(base64Data, 'base64') - // 使用 nativeImage 创建图片并写入剪贴板 - const { nativeImage } = await import('electron') - const image = nativeImage.createFromBuffer(imageBuffer) - clipboard.writeImage(image) - return { success: true } - } catch (error) { - console.error('复制图片操作出错:', error) - return { success: false, error: String(error) } - } - }) - - // ==================== 文件系统操作 ==================== - // 选择文件夹 - ipcMain.handle('selectDir', async (_, defaultPath = '') => { - try { - const { canceled, filePaths } = await dialog.showOpenDialog({ - title: '选择目录', - defaultPath: defaultPath || app.getPath('documents'), - properties: ['openDirectory', 'createDirectory'], - buttonLabel: '选择文件夹', - }) - if (!canceled) { - return filePaths[0] - } - return null - } catch (err) { - console.error('选择文件夹时发生错误:', err) - throw err - } - }) - - // 检查文件是否存在 - ipcMain.handle('checkFileExist', async (_, filePath) => { - try { - await fs.access(filePath) - return true - } catch { - return false - } - }) - - // 在文件管理器中打开 - ipcMain.handle('openInFolder', async (_, path) => { - try { - await fs.access(path) - await shell.showItemInFolder(path) - return true - } catch (error) { - console.error('打开目录时出错:', error) - return false - } - }) - - // 显示打开对话框(通用) - ipcMain.handle('dialog:showOpenDialog', async (_, options) => { - try { - return await dialog.showOpenDialog(options) - } catch (error) { - console.error('显示对话框失败:', error) - throw error - } - }) -} diff --git a/electron/main/logger.ts b/electron/main/logger.ts deleted file mode 100644 index 950fb8214..000000000 --- a/electron/main/logger.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 简单的日志工具 - * 日志保存到 userData/logs/ 目录 - */ - -import * as fs from 'fs' -import * as path from 'path' -import { getLogsDir, ensureDir } from './paths' - -// 日志目录 -function getLogDir(): string { - return getLogsDir() -} - -// 日志文件路径 -function getLogPath(): string { - return path.join(getLogDir(), 'app.log') -} - -// 确保日志目录存在 -function ensureLogDir(): void { - ensureDir(getLogDir()) -} - -// 格式化时间 -function formatTime(): string { - const now = new Date() - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - const hour = String(now.getHours()).padStart(2, '0') - const minute = String(now.getMinutes()).padStart(2, '0') - const second = String(now.getSeconds()).padStart(2, '0') - return `${year}-${month}-${day} ${hour}:${minute}:${second}` -} - -// 写入日志 -function writeLog(level: string, message: string): void { - try { - ensureLogDir() - const logLine = `[${formatTime()}] [${level}] ${message}\n` - fs.appendFileSync(getLogPath(), logLine, 'utf-8') - } catch (error) { - // 日志写入失败时静默处理,避免影响主程序 - console.error('[Logger] 写入日志失败:', error) - } -} - -/** - * 日志工具 - */ -export const logger = { - info: (message: string) => writeLog('INFO', message), - warn: (message: string) => writeLog('WARN', message), - error: (message: string) => writeLog('ERROR', message), - debug: (message: string) => writeLog('DEBUG', message), -} diff --git a/electron/main/merger/index.ts b/electron/main/merger/index.ts deleted file mode 100644 index 647f1790c..000000000 --- a/electron/main/merger/index.ts +++ /dev/null @@ -1,601 +0,0 @@ -/** - * 聊天记录合并模块 - * 支持多个聊天记录文件合并为 ChatLab 专属格式 - */ - -import * as fs from 'fs' -import * as path from 'path' -import { parseFileSync, detectFormat } from '../parser' -import { importData } from '../database/core' -import { TempDbReader } from './tempCache' -import { getDownloadsDir } from '../paths' -import type { - ParseResult, - ParsedMessage, - ChatLabFormat, - ChatLabMember, - ChatLabMessage, - FileParseInfo, - MergeConflict, - ChatPlatform, - ChatType, - ParsedMember, -} from '../../../src/types/base' -import type { - ConflictCheckResult, - ConflictResolution, - MergeParams, - MergeResult, - MergeSource, -} from '../../../src/types/format' -import type { ParsedMeta } from '../parser/types' - -/** - * 获取默认输出目录(系统下载目录) - */ -function getDefaultOutputDir(): string { - return getDownloadsDir() -} - -/** - * 确保输出目录存在 - */ -function ensureOutputDir(dir: string): void { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } -} - -/** - * 生成输出文件名 - */ -function generateOutputFilename(name: string, format: 'json' | 'jsonl' = 'json'): string { - const date = new Date().toISOString().slice(0, 10).replace(/-/g, '') - const safeName = name.replace(/[/\\?%*:|"<>]/g, '_') - return `${safeName}_merged_${date}.${format}` -} - -/** - * 解析文件获取基本信息(用于预览) - * 注意:推荐使用 parser.parseFileInfo 获取更详细的信息 - */ -export async function parseFileInfo(filePath: string): Promise { - const format = detectFormat(filePath) - if (!format) { - throw new Error('无法识别文件格式') - } - - const result = await parseFileSync(filePath) - - return { - name: result.meta.name, - format: format.name, - platform: result.meta.platform, - messageCount: result.messages.length, - memberCount: result.members.length, - } -} - -/** - * 生成消息的唯一标识(用于去重和冲突检测) - */ -function getMessageKey(msg: ParsedMessage): string { - return `${msg.timestamp}_${msg.senderPlatformId}_${(msg.content || '').length}` -} - -/** - * 检查消息是否是纯图片消息 - * 纯图片消息格式如:[图片: xxx.jpg]、[图片: {xxx}.jpg] 等 - */ -function isImageOnlyMessage(content: string | undefined): boolean { - if (!content) return false - // 匹配 [图片: xxx] 格式,允许各种图片名称格式 - return /^\[图片:\s*.+\]$/.test(content.trim()) -} - -function detectConflictsInMessages( - allMessages: Array<{ msg: ParsedMessage; source: string }>, - conflicts: MergeConflict[] -): ConflictCheckResult { - // 按时间戳分组检测冲突 - const timeGroups = new Map>() - for (const item of allMessages) { - const ts = item.msg.timestamp - if (!timeGroups.has(ts)) { - timeGroups.set(ts, []) - } - timeGroups.get(ts)!.push(item) - } - console.log(`[Merger] 唯一时间戳数: ${timeGroups.size}`) - - // 统计有多条消息的时间戳 - let multiMsgTsCount = 0 - for (const [, items] of timeGroups) { - if (items.length > 1) multiMsgTsCount++ - } - console.log(`[Merger] 有多条消息的时间戳数: ${multiMsgTsCount}`) - - // 统计自动去重数量 - let autoDeduplicatedCount = 0 - - // 检测每个时间戳内的冲突 - for (const [ts, items] of timeGroups) { - if (items.length < 2) continue - - // 按发送者分组 - const senderGroups = new Map>() - for (const item of items) { - const sender = item.msg.senderPlatformId - if (!senderGroups.has(sender)) { - senderGroups.set(sender, []) - } - senderGroups.get(sender)!.push(item) - } - - // 检测同一时间戳同一发送者的不同内容 - for (const [sender, senderItems] of senderGroups) { - if (senderItems.length < 2) continue - - // 检查是否来自不同文件 - const sources = new Set(senderItems.map((it) => it.source)) - if (sources.size < 2) { - // 所有消息来自同一个文件,跳过(这是同一文件内同一秒内多条消息的情况) - continue - } - - // 按内容分组(完全相同的内容会被分到一组,自动去重) - const contentGroups = new Map>() - for (const item of senderItems) { - const content = item.msg.content || '' - if (!contentGroups.has(content)) { - contentGroups.set(content, []) - } - contentGroups.get(content)!.push(item) - } - - // 统计自动去重的消息(内容完全相同但来自不同文件) - for (const [, contentItems] of contentGroups) { - if (contentItems.length > 1) { - const contentSources = new Set(contentItems.map((it) => it.source)) - if (contentSources.size > 1) { - // 内容相同但来自不同文件,自动去重 - autoDeduplicatedCount += contentItems.length - 1 - } - } - } - - // 只有当有多个不同内容时才是真正的冲突 - if (contentGroups.size > 1) { - const contentEntries = Array.from(contentGroups.entries()) - - // 检查这些不同内容是否来自不同文件 - for (let i = 0; i < contentEntries.length - 1; i++) { - for (let j = i + 1; j < contentEntries.length; j++) { - const [content1, items1] = contentEntries[i] - const [content2, items2] = contentEntries[j] - - // 找到两个来源不同的消息 - const item1 = items1[0] - const item2 = items2.find((it) => it.source !== item1.source) - - // 如果找不到来自不同文件的消息,跳过 - if (!item2) continue - - // 如果两边都是纯图片消息,自动跳过(不需要用户选择) - if (isImageOnlyMessage(content1) && isImageOnlyMessage(content2)) { - autoDeduplicatedCount++ - continue - } - - // 打印冲突详情 - if (conflicts.length < 5) { - console.log(`[Merger] 冲突 #${conflicts.length + 1}:`) - console.log(` 时间戳: ${ts} (${new Date(ts * 1000).toLocaleString()})`) - console.log(` 发送者: ${sender} (${item1.msg.senderName})`) - console.log(` 文件1: ${item1.source}, 长度: ${content1.length}, 内容: "${content1.slice(0, 50)}..."`) - console.log(` 文件2: ${item2.source}, 长度: ${content2.length}, 内容: "${content2.slice(0, 50)}..."`) - } - - conflicts.push({ - id: `conflict_${ts}_${sender}_${conflicts.length}`, - timestamp: ts, - sender: item1.msg.senderName || sender, - contentLength1: content1.length, - contentLength2: content2.length, - content1: content1, - content2: content2, - }) - } - } - } - } - } - - console.log(`[Merger] 自动去重消息数(含图片冲突): ${autoDeduplicatedCount}`) - - console.log(`[Merger] 检测到冲突数: ${conflicts.length}`) - - // 计算去重后的消息数 - const uniqueKeys = new Set() - for (const item of allMessages) { - uniqueKeys.add(getMessageKey(item.msg)) - } - console.log(`[Merger] 去重后消息数: ${uniqueKeys.size}`) - - return { - conflicts, - totalMessages: uniqueKeys.size, - } -} - -/** - * 合并多个聊天记录文件(使用缓存的解析结果) - */ -export async function mergeFilesWithCache(params: MergeParams, cache: Map): Promise { - try { - const { filePaths, outputName, outputDir, conflictResolutions, andAnalyze } = params - - console.log('[Merger] mergeFilesWithCache: 开始合并') - console.log( - '[Merger] 缓存状态:', - filePaths.map((p) => `${path.basename(p)}: ${cache.has(p) ? '已缓存' : '未缓存'}`) - ) - - // 解析所有文件(优先使用缓存) - const parseResults: Array<{ result: ParseResult; source: string }> = [] - for (const filePath of filePaths) { - let result: ParseResult - if (cache.has(filePath)) { - result = cache.get(filePath)! - console.log(`[Merger] 使用缓存: ${path.basename(filePath)}`) - } else { - // 回退到文件解析(兼容性) - console.log(`[Merger] 缓存未命中,重新解析: ${path.basename(filePath)}`) - result = await parseFileSync(filePath) - } - parseResults.push({ result, source: path.basename(filePath) }) - } - - return executeMerge(parseResults, outputName, outputDir, conflictResolutions, andAnalyze) - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : '合并失败', - } - } -} - -// ==================== 临时数据库版本(内存优化) ==================== - -/** - * 检测合并冲突(使用临时数据库,内存友好) - */ -export async function checkConflictsWithTempDb( - filePaths: string[], - tempDbCache: Map -): Promise { - const allMessages: Array<{ msg: ParsedMessage; source: string }> = [] - const conflicts: MergeConflict[] = [] - - console.log('[Merger] checkConflictsWithTempDb: 开始检测冲突') - console.log( - '[Merger] 文件列表:', - filePaths.map((p) => path.basename(p)) - ) - console.log( - '[Merger] 临时数据库缓存状态:', - filePaths.map((p) => `${path.basename(p)}: ${tempDbCache.has(p) ? '已缓存' : '未缓存'}`) - ) - - // 从临时数据库读取所有消息 - const readers: TempDbReader[] = [] - try { - for (const filePath of filePaths) { - const tempDbPath = tempDbCache.get(filePath) - if (!tempDbPath) { - throw new Error(`未找到文件的临时数据库: ${path.basename(filePath)}`) - } - - const reader = new TempDbReader(tempDbPath) - readers.push(reader) - - const meta = reader.getMeta() - const sourceName = path.basename(filePath) - - console.log(`[Merger] 从临时数据库读取: ${sourceName}, 平台: ${meta?.platform}`) - - // 流式读取消息,避免一次性加载到内存 - reader.streamMessages(10000, (messages) => { - for (const msg of messages) { - allMessages.push({ msg, source: sourceName }) - } - }) - } - - console.log(`[Merger] 总消息数: ${allMessages.length}`) - - // 检查格式一致性 - const platforms = readers.map((r) => r.getMeta()?.platform || 'unknown') - const uniquePlatforms = [...new Set(platforms)] - if (uniquePlatforms.length > 1) { - throw new Error( - `不支持合并不同格式的聊天记录。\n检测到的格式:${uniquePlatforms.join('、')}\n请确保所有文件使用相同的导出工具和格式。` - ) - } - console.log('[Merger] 格式检查通过:', uniquePlatforms[0]) - - return detectConflictsInMessages(allMessages, conflicts) - } finally { - // 关闭所有 reader - for (const reader of readers) { - reader.close() - } - } -} - -/** - * 合并多个聊天记录文件(使用临时数据库,内存友好) - */ -export async function mergeFilesWithTempDb( - params: MergeParams, - tempDbCache: Map -): Promise { - const { filePaths, outputName, outputDir, outputFormat = 'json', conflictResolutions, andAnalyze } = params - - console.log('[Merger] mergeFilesWithTempDb: 开始合并') - console.log( - '[Merger] 临时数据库缓存状态:', - filePaths.map((p) => `${path.basename(p)}: ${tempDbCache.has(p) ? '已缓存' : '未缓存'}`) - ) - - const readers: TempDbReader[] = [] - - try { - // 打开所有临时数据库 - const parseResults: Array<{ meta: ParsedMeta; members: ParsedMember[]; source: string; reader: TempDbReader }> = [] - - for (const filePath of filePaths) { - const tempDbPath = tempDbCache.get(filePath) - if (!tempDbPath) { - throw new Error(`未找到文件的临时数据库: ${path.basename(filePath)}`) - } - - const reader = new TempDbReader(tempDbPath) - readers.push(reader) - - const meta = reader.getMeta() - if (!meta) { - throw new Error(`无法读取元信息: ${path.basename(filePath)}`) - } - - const members = reader.getMembers() - const sourceName = path.basename(filePath) - - console.log(`[Merger] 使用临时数据库: ${sourceName}`) - - parseResults.push({ meta, members, source: sourceName, reader }) - } - - // 合并成员 - const memberMap = new Map() - for (const { members } of parseResults) { - for (const member of members) { - const existing = memberMap.get(member.platformId) - if (existing) { - if (member.accountName) { - existing.accountName = member.accountName - } - if (member.groupNickname) { - existing.groupNickname = member.groupNickname - } - // 头像使用最新的(覆盖更新) - if (member.avatar) { - existing.avatar = member.avatar - } - } else { - memberMap.set(member.platformId, { - platformId: member.platformId, - accountName: member.accountName, - groupNickname: member.groupNickname, - avatar: member.avatar, - }) - } - } - } - - // 流式合并消息(去重)- 使用 Set 替代 Map 以提高性能 - // 注:冲突解决方案通过消息处理顺序生效(第一个被处理的版本会被保留) - const seenKeys = new Set() - const mergedMessages: ChatLabMessage[] = [] - let totalProcessed = 0 - const startTime = Date.now() - - for (const { reader, source } of parseResults) { - const readerStartTime = Date.now() - let readerCount = 0 - - reader.streamMessages(10000, (messages) => { - for (const msg of messages) { - const key = getMessageKey(msg) - - // 跳过已处理的消息(去重) - if (seenKeys.has(key)) { - continue - } - seenKeys.add(key) - - // 注:冲突已在去重时处理(seenKeys),用户选择的冲突解决方案 - // 决定了哪个版本的消息先被处理,后续相同 key 的消息会被跳过 - - mergedMessages.push({ - sender: msg.senderPlatformId, - accountName: msg.senderAccountName, - groupNickname: msg.senderGroupNickname, - timestamp: msg.timestamp, - type: msg.type, - content: msg.content, - }) - - readerCount++ - } - totalProcessed += messages.length - }) - - console.log(`[Merger] 处理 ${source}: ${readerCount} 条唯一消息, 耗时: ${Date.now() - readerStartTime}ms`) - } - - // 排序 - const sortStartTime = Date.now() - mergedMessages.sort((a, b) => a.timestamp - b.timestamp) - console.log(`[Merger] 排序耗时: ${Date.now() - sortStartTime}ms`) - - console.log(`[Merger] 合并后消息数: ${mergedMessages.length}`) - - // 确定平台(使用第一个文件的平台) - const platform = parseResults[0].meta.platform - - // 确定群ID和群头像(仅当所有文件都来自同一个群时保留) - const groupIds = new Set(parseResults.map((r) => r.meta.groupId).filter(Boolean)) - const groupId = groupIds.size === 1 ? parseResults.find((r) => r.meta.groupId)?.meta.groupId : undefined - // 如果有唯一群ID,使用最后一个文件的群头像(可能是最新的) - const groupAvatar = groupId - ? parseResults.filter((r) => r.meta.groupId === groupId).pop()?.meta.groupAvatar - : undefined - - // 构建来源信息 - const sources: MergeSource[] = parseResults.map(({ reader, source, meta }) => ({ - filename: source, - platform: meta.platform, - messageCount: reader.getMessageCount(), - })) - - // 构建 ChatLab 格式数据 - const chatLabHeader = { - version: '0.0.1', - exportedAt: Math.floor(Date.now() / 1000), - generator: 'ChatLab Merge Tool', - description: `合并自 ${parseResults.length} 个文件`, - } - - const chatLabMeta = { - name: outputName, - platform: platform as ChatPlatform, - type: parseResults[0].meta.type as ChatType, - sources, - groupId, - groupAvatar, - } - - const chatLabMembers = Array.from(memberMap.values()) - - // 写入文件 - const targetDir = outputDir || getDefaultOutputDir() - ensureOutputDir(targetDir) - const filename = generateOutputFilename(outputName, outputFormat) - const outputPath = path.join(targetDir, filename) - - const writeStartTime = Date.now() - - if (outputFormat === 'jsonl') { - // JSONL 格式:流式写入,每行一个 JSON 对象 - const writeStream = fs.createWriteStream(outputPath, { encoding: 'utf-8' }) - - // 写入 header 行 - writeStream.write( - JSON.stringify({ - _type: 'header', - chatlab: chatLabHeader, - meta: chatLabMeta, - }) + '\n' - ) - - // 写入 member 行 - for (const member of chatLabMembers) { - writeStream.write( - JSON.stringify({ - _type: 'member', - ...member, - }) + '\n' - ) - } - - // 写入 message 行 - for (const msg of mergedMessages) { - writeStream.write( - JSON.stringify({ - _type: 'message', - ...msg, - }) + '\n' - ) - } - - writeStream.end() - - // 等待写入完成 - await new Promise((resolve, reject) => { - writeStream.on('finish', resolve) - writeStream.on('error', reject) - }) - - console.log(`[Merger] 写入 JSONL 文件耗时: ${Date.now() - writeStartTime}ms`) - } else { - // JSON 格式:格式化输出 - const chatLabData: ChatLabFormat = { - chatlab: chatLabHeader, - meta: chatLabMeta, - members: chatLabMembers, - messages: mergedMessages, - } - fs.writeFileSync(outputPath, JSON.stringify(chatLabData, null, 2), 'utf-8') - console.log(`[Merger] 写入 JSON 文件耗时: ${Date.now() - writeStartTime}ms`) - } - console.log(`[Merger] 总合并耗时: ${Date.now() - startTime}ms`) - - // 如果需要分析,导入数据库 - let sessionId: string | undefined - if (andAnalyze) { - const importStartTime = Date.now() - const parseResult: ParseResult = { - meta: { - name: chatLabMeta.name, - platform: chatLabMeta.platform, - type: chatLabMeta.type, - groupId: chatLabMeta.groupId, - groupAvatar: chatLabMeta.groupAvatar, - }, - members: chatLabMembers.map((m) => ({ - platformId: m.platformId, - accountName: m.accountName, - groupNickname: m.groupNickname, - avatar: m.avatar, - })), - messages: mergedMessages.map((msg) => ({ - senderPlatformId: msg.sender, - senderAccountName: msg.accountName, - senderGroupNickname: msg.groupNickname, - timestamp: msg.timestamp, - type: msg.type, - content: msg.content, - })), - } - sessionId = importData(parseResult) - console.log(`[Merger] 导入数据库耗时: ${Date.now() - importStartTime}ms`) - } - - return { - success: true, - outputPath, - sessionId, - } - } catch (err) { - return { - success: false, - error: err instanceof Error ? err.message : '合并失败', - } - } finally { - // 关闭所有 reader - for (const reader of readers) { - reader.close() - } - } -} diff --git a/electron/main/merger/tempCache.ts b/electron/main/merger/tempCache.ts deleted file mode 100644 index 0389de4dd..000000000 --- a/electron/main/merger/tempCache.ts +++ /dev/null @@ -1,355 +0,0 @@ -/** - * 临时数据库缓存管理器 - * 用于合并功能:将解析结果存入临时 SQLite 数据库,避免内存溢出 - */ - -import Database from 'better-sqlite3' -import * as fs from 'fs' -import * as path from 'path' -import type { ParsedMember, ParsedMessage } from '../../../src/types/base' -import type { ParseResult, ParsedMeta } from '../parser/types' -import { getTempDir as getAppTempDir, ensureDir } from '../paths' - -/** - * 获取临时数据库目录 - */ -function getTempDir(): string { - const dir = getAppTempDir() - ensureDir(dir) - return dir -} - -/** - * 生成临时数据库文件路径 - */ -export function generateTempDbPath(sourceFilePath: string): string { - const timestamp = Date.now() - const random = Math.random().toString(36).substring(2, 8) - const baseName = path.basename(sourceFilePath, path.extname(sourceFilePath)) - const safeName = baseName.replace(/[/\\?%*:|"<>]/g, '_').substring(0, 50) - return path.join(getTempDir(), `merge_${safeName}_${timestamp}_${random}.db`) -} - -/** - * 创建临时数据库并初始化表结构 - */ -export function createTempDatabase(dbPath: string): Database.Database { - const db = new Database(dbPath) - - db.pragma('journal_mode = WAL') - db.pragma('synchronous = NORMAL') - - db.exec(` - CREATE TABLE IF NOT EXISTS meta ( - name TEXT NOT NULL, - platform TEXT NOT NULL, - type TEXT NOT NULL, - group_id TEXT, - group_avatar TEXT - ); - - CREATE TABLE IF NOT EXISTS member ( - platform_id TEXT PRIMARY KEY, - account_name TEXT, - group_nickname TEXT, - avatar TEXT - ); - - CREATE TABLE IF NOT EXISTS message ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - sender_platform_id TEXT NOT NULL, - sender_account_name TEXT, - sender_group_nickname TEXT, - timestamp INTEGER NOT NULL, - type INTEGER NOT NULL, - content TEXT - ); - - CREATE INDEX IF NOT EXISTS idx_message_ts ON message(timestamp); - CREATE INDEX IF NOT EXISTS idx_message_sender ON message(sender_platform_id); - `) - - return db -} - -/** - * 临时数据库写入器 - * 用于流式写入解析结果 - */ -export class TempDbWriter { - private db: Database.Database - private insertMeta: Database.Statement - private insertMember: Database.Statement - private insertMessage: Database.Statement - private memberSet: Set = new Set() - private messageCount: number = 0 - - constructor(dbPath: string) { - this.db = createTempDatabase(dbPath) - - // 准备语句 - this.insertMeta = this.db.prepare(` - INSERT INTO meta (name, platform, type, group_id, group_avatar) VALUES (?, ?, ?, ?, ?) - `) - this.insertMember = this.db.prepare(` - INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?) - `) - this.insertMessage = this.db.prepare(` - INSERT INTO message (sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content) - VALUES (?, ?, ?, ?, ?, ?) - `) - - // 开始事务 - this.db.exec('BEGIN TRANSACTION') - } - - /** - * 写入元信息 - */ - writeMeta(meta: ParsedMeta): void { - this.insertMeta.run(meta.name, meta.platform, meta.type, meta.groupId || null, meta.groupAvatar || null) - } - - /** - * 写入成员(批量) - */ - writeMembers(members: ParsedMember[]): void { - for (const m of members) { - if (!this.memberSet.has(m.platformId)) { - this.memberSet.add(m.platformId) - this.insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null, m.avatar || null) - } - } - } - - /** - * 写入消息(批量) - */ - writeMessages(messages: ParsedMessage[]): void { - for (const msg of messages) { - // 确保成员存在(消息中没有头像信息,设为 null) - if (!this.memberSet.has(msg.senderPlatformId)) { - this.memberSet.add(msg.senderPlatformId) - this.insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null) - } - - this.insertMessage.run( - msg.senderPlatformId, - msg.senderAccountName || null, - msg.senderGroupNickname || null, - msg.timestamp, - msg.type, - msg.content || null - ) - this.messageCount++ - } - } - - /** - * 完成写入(提交事务) - */ - finish(): { messageCount: number; memberCount: number } { - this.db.exec('COMMIT') - const result = { - messageCount: this.messageCount, - memberCount: this.memberSet.size, - } - this.db.close() - return result - } - - /** - * 取消写入(回滚事务) - */ - abort(): void { - try { - this.db.exec('ROLLBACK') - } catch { - // 忽略回滚错误 - } - this.db.close() - } -} - -/** - * 临时数据库读取器 - * 用于流式读取合并时的数据 - */ -export class TempDbReader { - private db: Database.Database - private dbPath: string - - constructor(dbPath: string) { - this.dbPath = dbPath - this.db = new Database(dbPath, { readonly: true }) - this.db.pragma('journal_mode = WAL') - } - - /** - * 读取元信息 - */ - getMeta(): ParsedMeta | null { - const row = this.db.prepare('SELECT * FROM meta LIMIT 1').get() as - | { name: string; platform: string; type: string; group_id: string | null; group_avatar: string | null } - | undefined - if (!row) return null - return { - name: row.name, - platform: row.platform, - type: row.type as 'group' | 'private', - groupId: row.group_id || undefined, - groupAvatar: row.group_avatar || undefined, - } - } - - /** - * 读取所有成员 - */ - getMembers(): ParsedMember[] { - const rows = this.db.prepare('SELECT * FROM member').all() as Array<{ - platform_id: string - account_name: string | null - group_nickname: string | null - avatar: string | null - }> - return rows.map((r) => ({ - platformId: r.platform_id, - accountName: r.account_name || r.platform_id, // 如果没有账号名称,使用 platformId - groupNickname: r.group_nickname || undefined, - avatar: r.avatar || undefined, - })) - } - - /** - * 获取消息总数 - */ - getMessageCount(): number { - const row = this.db.prepare('SELECT COUNT(*) as count FROM message').get() as { count: number } - return row.count - } - - /** - * 流式读取消息(分批) - * @param batchSize 每批消息数量 - * @param callback 处理每批消息的回调 - */ - streamMessages(batchSize: number, callback: (messages: ParsedMessage[]) => void): void { - const stmt = this.db.prepare(` - SELECT sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content - FROM message - ORDER BY timestamp ASC - LIMIT ? OFFSET ? - `) - - let offset = 0 - while (true) { - const rows = stmt.all(batchSize, offset) as Array<{ - sender_platform_id: string - sender_account_name: string | null - sender_group_nickname: string | null - timestamp: number - type: number - content: string | null - }> - - if (rows.length === 0) break - - const messages: ParsedMessage[] = rows.map((r) => ({ - senderPlatformId: r.sender_platform_id, - senderAccountName: r.sender_account_name || r.sender_platform_id, - senderGroupNickname: r.sender_group_nickname || undefined, - timestamp: r.timestamp, - type: r.type, - content: r.content || undefined, - })) - - callback(messages) - offset += batchSize - } - } - - /** - * 获取所有消息(用于冲突检测,内存中处理) - * 注意:对于超大文件,应使用 streamMessages - */ - getAllMessages(): ParsedMessage[] { - const rows = this.db - .prepare( - ` - SELECT sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content - FROM message - ORDER BY timestamp ASC - ` - ) - .all() as Array<{ - sender_platform_id: string - sender_account_name: string | null - sender_group_nickname: string | null - timestamp: number - type: number - content: string | null - }> - - return rows.map((r) => ({ - senderPlatformId: r.sender_platform_id, - senderAccountName: r.sender_account_name || r.sender_platform_id, - senderGroupNickname: r.sender_group_nickname || undefined, - timestamp: r.timestamp, - type: r.type, - content: r.content || undefined, - })) - } - - /** - * 关闭数据库连接 - */ - close(): void { - this.db.close() - } - - /** - * 获取数据库路径 - */ - getPath(): string { - return this.dbPath - } -} - -/** - * 删除临时数据库文件 - */ -export function deleteTempDatabase(dbPath: string): void { - try { - const walPath = dbPath + '-wal' - const shmPath = dbPath + '-shm' - - if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath) - if (fs.existsSync(walPath)) fs.unlinkSync(walPath) - if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath) - - console.log(`[TempCache] 已删除临时数据库: ${dbPath}`) - } catch (error) { - console.error(`[TempCache] 删除临时数据库失败: ${dbPath}`, error) - } -} - -/** - * 清理所有临时数据库(应用启动时调用) - */ -export function cleanupAllTempDatabases(): void { - try { - const dir = getTempDir() - if (!fs.existsSync(dir)) return - - const files = fs.readdirSync(dir) - for (const file of files) { - if (file.startsWith('merge_') && file.endsWith('.db')) { - const filePath = path.join(dir, file) - deleteTempDatabase(filePath) - } - } - console.log('[TempCache] 已清理所有临时数据库') - } catch (error) { - console.error('[TempCache] 清理临时数据库失败:', error) - } -} diff --git a/electron/main/network/proxy.ts b/electron/main/network/proxy.ts deleted file mode 100644 index 443e98063..000000000 --- a/electron/main/network/proxy.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * 代理配置管理模块 - * 提供 HTTP/HTTPS 代理的配置存储、读取和连接测试 - */ - -import * as fs from 'fs' -import * as path from 'path' -import { app, session } from 'electron' -import { getSettingsDir } from '../paths' - -// 代理配置接口 -export interface ProxyConfig { - enabled: boolean - url: string // 完整的代理 URL,如 http://127.0.0.1:7890 -} - -// 默认配置 -const DEFAULT_CONFIG: ProxyConfig = { - enabled: false, - url: '', -} - -// 配置文件路径 -let CONFIG_PATH: string | null = null - -function getConfigPath(): string { - if (CONFIG_PATH) return CONFIG_PATH - CONFIG_PATH = path.join(getSettingsDir(), 'proxy.json') - return CONFIG_PATH -} - -/** - * 加载代理配置 - */ -export function loadProxyConfig(): ProxyConfig { - const configPath = getConfigPath() - - if (!fs.existsSync(configPath)) { - return { ...DEFAULT_CONFIG } - } - - try { - const content = fs.readFileSync(configPath, 'utf-8') - const data = JSON.parse(content) - return { - enabled: Boolean(data.enabled), - url: String(data.url || ''), - } - } catch { - return { ...DEFAULT_CONFIG } - } -} - -/** - * 保存代理配置 - */ -export function saveProxyConfig(config: ProxyConfig): void { - const configPath = getConfigPath() - const dir = path.dirname(configPath) - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - - fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8') - - // 保存后立即应用代理设置到 Electron session - applyProxyToSession() -} - -/** - * 验证代理 URL 格式 - */ -export function validateProxyUrl(url: string): { valid: boolean; error?: string } { - if (!url) { - return { valid: false, error: '代理地址不能为空' } - } - - try { - const parsed = new URL(url) - if (!['http:', 'https:'].includes(parsed.protocol)) { - return { valid: false, error: '仅支持 http:// 或 https:// 协议' } - } - if (!parsed.hostname) { - return { valid: false, error: '代理地址格式无效' } - } - return { valid: true } - } catch { - return { valid: false, error: '代理地址格式无效,请使用 http://host:port 格式' } - } -} - -/** - * 将代理设置应用到 Electron session - * 这会影响所有通过 Electron 发起的网络请求(包括主进程的 fetch) - */ -export async function applyProxyToSession(): Promise { - const config = loadProxyConfig() - - try { - if (config.enabled && config.url) { - const validation = validateProxyUrl(config.url) - if (validation.valid) { - // 设置代理规则 - await session.defaultSession.setProxy({ - proxyRules: config.url, - }) - console.log(`[Proxy] 代理已启用: ${config.url}`) - } - } else { - // 清除代理设置 - await session.defaultSession.setProxy({ - proxyRules: '', - }) - console.log('[Proxy] 代理已禁用') - } - } catch (error) { - console.error('[Proxy] 设置代理失败:', error) - } -} - -/** - * 测试代理连接 - * 通过代理请求一个可靠的 HTTPS 地址来验证代理是否可用 - */ -export async function testProxyConnection(proxyUrl: string): Promise<{ success: boolean; error?: string }> { - // 先验证格式 - const validation = validateProxyUrl(proxyUrl) - if (!validation.valid) { - return { success: false, error: validation.error } - } - - // 测试 URL 列表(按优先级) - const testUrls = [ - 'https://www.google.com', - 'https://www.cloudflare.com', - 'https://api.deepseek.com', - ] - - try { - // 临时设置代理 - await session.defaultSession.setProxy({ - proxyRules: proxyUrl, - }) - - // 使用 Electron 的 net 模块测试连接 - const { net } = await import('electron') - - let lastError: string = '' - - for (const testUrl of testUrls) { - try { - const result = await new Promise<{ success: boolean; error?: string }>((resolve) => { - const request = net.request({ - method: 'HEAD', - url: testUrl, - }) - - const timeout = setTimeout(() => { - request.abort() - resolve({ success: false, error: '连接超时' }) - }, 10000) - - request.on('response', (response) => { - clearTimeout(timeout) - // 任何响应都说明代理可用 - if (response.statusCode < 500) { - resolve({ success: true }) - } else { - resolve({ success: false, error: `HTTP ${response.statusCode}` }) - } - }) - - request.on('error', (error) => { - clearTimeout(timeout) - resolve({ success: false, error: error.message }) - }) - - request.end() - }) - - if (result.success) { - // 恢复之前的代理设置 - await applyProxyToSession() - return { success: true } - } - - lastError = result.error || '' - } catch (e) { - lastError = e instanceof Error ? e.message : String(e) - } - } - - // 恢复之前的代理设置 - await applyProxyToSession() - return { success: false, error: lastError || '无法通过代理连接到测试服务器' } - } catch (error) { - // 恢复之前的代理设置 - await applyProxyToSession() - - const errorMessage = error instanceof Error ? error.message : String(error) - - // 友好的错误提示 - if (errorMessage.includes('ECONNREFUSED')) { - return { success: false, error: '连接被拒绝,请检查代理服务器是否运行中' } - } - if (errorMessage.includes('ETIMEDOUT') || errorMessage.includes('timeout')) { - return { success: false, error: '连接超时,请检查代理地址和端口' } - } - if (errorMessage.includes('ENOTFOUND')) { - return { success: false, error: '无法解析代理服务器地址' } - } - - return { success: false, error: `代理连接失败: ${errorMessage}` } - } -} - -/** - * 获取当前有效的代理 URL - * 如果代理已启用且 URL 有效,返回代理 URL,否则返回 undefined - */ -export function getActiveProxyUrl(): string | undefined { - const config = loadProxyConfig() - if (config.enabled && config.url) { - const validation = validateProxyUrl(config.url) - if (validation.valid) { - return config.url - } - } - return undefined -} - -/** - * 初始化代理模块 - * 应用启动时调用,加载并应用代理配置 - */ -export function initProxy(): void { - // 延迟执行,确保 app ready - if (app.isReady()) { - applyProxyToSession() - } else { - app.whenReady().then(() => { - applyProxyToSession() - }) - } -} diff --git a/electron/main/parser/formats/index.ts b/electron/main/parser/formats/index.ts deleted file mode 100644 index 80af7f052..000000000 --- a/electron/main/parser/formats/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * 格式模块注册 - * 导出所有支持的格式 - */ - -import type { FormatModule } from '../types' - -// 导入所有格式模块 -import chatlab from './chatlab' -import chatlabJsonl from './chatlab-jsonl' -import shuakamiQqExporter from './shuakami-qq-exporter' -import yccccccyEchotrace from './ycccccccy-echotrace' -import tyrrrzDiscordExporter from './tyrrrz-discord-exporter' -import whatsappNativeTxt from './whatsapp-native-txt' -import qqNativeTxt from './qq-native-txt' - -/** - * 所有支持的格式模块(按优先级排序) - */ -export const formats: FormatModule[] = [ - chatlab, // 优先级 1 - ChatLab JSON - chatlabJsonl, // 优先级 2 - ChatLab JSONL(流式格式,支持超大文件) - shuakamiQqExporter, // 优先级 10 - shuakami/qq-chat-exporter - yccccccyEchotrace, // 优先级 15 - ycccccccy/echotrace - tyrrrzDiscordExporter, // 优先级 20 - Tyrrrz/DiscordChatExporter - whatsappNativeTxt, // 优先级 25 - WhatsApp 官方导出 TXT - qqNativeTxt, // 优先级 30 - QQ 官方导出 TXT -] - -// 按名称导出,方便单独使用 -export { - chatlab, - chatlabJsonl, - shuakamiQqExporter, - yccccccyEchotrace, - tyrrrzDiscordExporter, - qqNativeTxt, - whatsappNativeTxt, -} diff --git a/electron/main/parser/formats/whatsapp-native-txt.ts b/electron/main/parser/formats/whatsapp-native-txt.ts deleted file mode 100644 index dca6e9c19..000000000 --- a/electron/main/parser/formats/whatsapp-native-txt.ts +++ /dev/null @@ -1,381 +0,0 @@ -/** - * WhatsApp 官方导出 TXT 格式解析器 - * 适配 WhatsApp 聊天导出功能 - * - * 格式特征: - * - 文件头:消息和通话已进行端到端加密 - * - 消息格式:YYYY/MM/DD HH:MM - 昵称: 内容 - * - 系统消息:YYYY/MM/DD HH:MM - 系统内容(无冒号分隔) - * - 媒体占位:<省略影音内容> - */ - -import * as fs from 'fs' -import * as path from 'path' -import * as readline from 'readline' -import { KNOWN_PLATFORMS, ChatType, MessageType } from '../../../../src/types/base' -import type { - FormatFeature, - FormatModule, - Parser, - ParseOptions, - ParseEvent, - ParsedMeta, - ParsedMember, - ParsedMessage, -} from '../types' -import { getFileSize, createProgress } from '../utils' - -// ==================== 辅助函数 ==================== - -/** - * 从文件名提取聊天名称 - * 例如:与开心每一天的 WhatsApp 聊天.txt → 开心每一天 - * 例如:与gaoberry37的 WhatsApp 聊天.txt → gaoberry37 - */ -function extractNameFromFilePath(filePath: string): string { - const basename = path.basename(filePath) - // 匹配:与xxx的 WhatsApp 聊天.txt - const match = basename.match(/^与(.+?)的\s*WhatsApp\s*聊天\.txt$/i) - if (match) { - return match[1].trim() - } - // 兜底:移除扩展名 - return basename.replace(/\.txt$/i, '') || '未知聊天' -} - -// ==================== 特征定义 ==================== - -export const feature: FormatFeature = { - id: 'whatsapp-native-txt', - name: 'WhatsApp 官方导出 (TXT)', - platform: KNOWN_PLATFORMS.WHATSAPP, - priority: 25, - extensions: ['.txt'], - signatures: { - // WhatsApp 导出文件的特征(中文/英文) - head: [ - /消息和通话已进行端到端加密/, // 中文 - /Messages and calls are end-to-end encrypted/i, // 英文 - /WhatsApp/i, - ], - }, -} - -// ==================== 辅助函数:清理不可见字符 ==================== - -/** - * 清理行首/行尾的不可见 Unicode 字符 - * WhatsApp 导出文件中可能包含 BOM、Left-to-Right Mark (U+200E) 等 - */ -function cleanLine(line: string): string { - // 移除常见的不可见字符:BOM、LTR Mark、RTL Mark、零宽字符等 - return line.replace(/^[\uFEFF\u200E\u200F\u200B\u200C\u200D\u2060]+/, '').trim() -} - -// ==================== 消息头正则 ==================== - -// 格式1:2025/12/22 12:35 - 地瓜: 内容(部分地区导出格式) -const MESSAGE_LINE_REGEX_V1 = /^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}) - (.+)$/ - -// 格式2:[6/7/25 22:44:26] 或 [10/12/25, 12:50:16](中文/英文地区导出格式) -// 日期和时间之间可能有逗号(英文)或没有(中文) -const MESSAGE_LINE_REGEX_V2 = /^\[(\d{1,2}\/\d{1,2}\/\d{2},? \d{1,2}:\d{2}:\d{2})\] (.+)$/ - -// 从消息内容中分离昵称和实际内容 -// 格式:昵称: 内容 -const SENDER_CONTENT_REGEX = /^(.+?): (.*)$/ - -// ==================== 系统消息识别 ==================== - -const SYSTEM_MESSAGE_PATTERNS = [ - // 中文系统消息 - /消息和通话已进行端到端加密/, - /创建了此群组/, - /加入群组/, - /添加了/, - /退出了群组/, - /移除了/, - /更改了本群组/, - /已将此群组的设置更改为/, - /这条消息已删除/, - /限时消息功能/, - /正在等待此消息/, - // 英文系统消息 - /Messages and calls are end-to-end encrypted/i, - /created this group/i, - /joined the group/i, - /added/i, - /left the group/i, - /removed/i, - /changed this group/i, - /This message was deleted/i, - /disappearing messages/i, -] - -function isSystemMessage(content: string): boolean { - return SYSTEM_MESSAGE_PATTERNS.some((pattern) => pattern.test(content)) -} - -// ==================== 消息类型判断 ==================== - -function detectMessageType(content: string): MessageType { - const trimmed = content.trim() - - // 媒体消息 - if (trimmed === '<省略影音内容>') return MessageType.IMAGE // 统一归类为图片 - if (trimmed.includes('<已附加:') || trimmed.includes('<附件:')) return MessageType.FILE - - // 删除消息 - if (trimmed === '这条消息已删除') return MessageType.RECALL - - // 系统消息 - if (isSystemMessage(trimmed)) return MessageType.SYSTEM - - return MessageType.TEXT -} - -// ==================== 时间解析 ==================== - -/** - * 解析 WhatsApp 时间格式为秒级时间戳 - * 支持两种格式: - * - 格式1:2025/12/22 12:35(YYYY/MM/DD HH:MM) - * - 格式2:6/7/25 22:44:26(M/D/YY HH:MM:SS) - */ -function parseWhatsAppTime(timeStr: string, isV2Format: boolean = false): number { - if (isV2Format) { - // 格式2:M/D/YY HH:MM:SS 或 M/D/YY, HH:MM:SS(可选逗号) - // 先移除可能的逗号 - const normalizedStr = timeStr.replace(',', '') - const match = normalizedStr.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}) (\d{1,2}):(\d{2}):(\d{2})$/) - if (match) { - const [, month, day, year, hour, minute, second] = match - // 将 2 位年份转换为 4 位(假设 00-99 对应 2000-2099) - const fullYear = 2000 + parseInt(year, 10) - const date = new Date(fullYear, parseInt(month, 10) - 1, parseInt(day, 10), parseInt(hour, 10), parseInt(minute, 10), parseInt(second, 10)) - return Math.floor(date.getTime() / 1000) - } - } - - // 格式1:YYYY/MM/DD HH:MM - const normalized = timeStr.replace(/\//g, '-').replace(' ', 'T') + ':00' - const date = new Date(normalized) - return Math.floor(date.getTime() / 1000) -} - -// ==================== 成员信息 ==================== - -interface MemberInfo { - platformId: string - nickname: string -} - -// ==================== 解析器实现 ==================== - -async function* parseWhatsApp(options: ParseOptions): AsyncGenerator { - const { filePath, batchSize = 5000, onProgress, onLog } = options - - const totalBytes = getFileSize(filePath) - let bytesRead = 0 - let messagesProcessed = 0 - let skippedLines = 0 - - // 发送初始进度 - const initialProgress = createProgress('parsing', 0, totalBytes, 0, '') - yield { type: 'progress', data: initialProgress } - onProgress?.(initialProgress) - - // 记录解析开始 - onLog?.('info', `开始解析 WhatsApp TXT 文件,大小: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`) - - // 收集数据 - const chatName = extractNameFromFilePath(filePath) - const memberMap = new Map() - const messages: ParsedMessage[] = [] - - // 当前正在解析的消息(可能跨多行) - let currentMessage: { - timestamp: number - sender: string | null // null 表示系统消息 - contentLines: string[] - } | null = null - - // 保存当前消息 - const saveCurrentMessage = () => { - if (currentMessage) { - const content = currentMessage.contentLines.join('\n').trim() - const type = detectMessageType(content) - - // 系统消息使用特殊 ID 和统一名称 - const senderPlatformId = currentMessage.sender || 'system' - const senderName = currentMessage.sender || '系统消息' - - messages.push({ - senderPlatformId, - senderAccountName: senderName, - timestamp: currentMessage.timestamp, - type, - content: content || null, - }) - - // 更新成员信息(跳过系统消息) - if (currentMessage.sender) { - memberMap.set(senderPlatformId, { - platformId: senderPlatformId, - nickname: senderName, - }) - } - - messagesProcessed++ - } - } - - // 逐行读取文件 - const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }) - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }) - - fileStream.on('data', (chunk: string | Buffer) => { - bytesRead += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length - }) - - for await (const line of rl) { - // 清理行首不可见字符 - const cleanedLine = cleanLine(line) - - // 尝试匹配消息行(两种格式) - let lineMatch = cleanedLine.match(MESSAGE_LINE_REGEX_V1) - let isV2Format = false - if (!lineMatch) { - lineMatch = cleanedLine.match(MESSAGE_LINE_REGEX_V2) - isV2Format = true - } - - if (lineMatch) { - // 保存前一条消息 - saveCurrentMessage() - - const timeStr = lineMatch[1] - const restContent = lineMatch[2] - - // 尝试分离发送者和内容 - const senderMatch = restContent.match(SENDER_CONTENT_REGEX) - if (senderMatch && !isSystemMessage(restContent)) { - // 普通消息 - currentMessage = { - timestamp: parseWhatsAppTime(timeStr, isV2Format), - sender: senderMatch[1].trim(), - contentLines: [senderMatch[2]], - } - } else { - // 系统消息 - currentMessage = { - timestamp: parseWhatsAppTime(timeStr, isV2Format), - sender: null, - contentLines: [restContent], - } - } - - // 更新进度 - if (messagesProcessed % 500 === 0) { - const progress = createProgress( - 'parsing', - bytesRead, - totalBytes, - messagesProcessed, - `已处理 ${messagesProcessed} 条消息...` - ) - onProgress?.(progress) - } - - continue - } - - // 非消息行:可能是多行消息的延续 - if (currentMessage && cleanedLine) { - currentMessage.contentLines.push(cleanedLine) - } else if (cleanedLine) { - // 无法解析的非空行 - skippedLines++ - } - } - - // 保存最后一条消息 - saveCurrentMessage() - - // 确定聊天类型:根据参与者数量判断 - // - 排除系统成员后,2 人或更少:私聊 - // - 超过 2 人:群聊 - const hasSystemMember = memberMap.has('system') - const realMemberCount = hasSystemMember ? memberMap.size - 1 : memberMap.size - - const chatType = realMemberCount > 2 ? ChatType.GROUP : ChatType.PRIVATE - - // 发送 meta - const meta: ParsedMeta = { - name: chatName, - platform: KNOWN_PLATFORMS.WHATSAPP, - type: chatType, - } - yield { type: 'meta', data: meta } - - // 发送成员 - const members: ParsedMember[] = Array.from(memberMap.values()).map((m) => ({ - platformId: m.platformId, - accountName: m.nickname, - })) - yield { type: 'members', data: members } - - // 分批发送消息 - for (let i = 0; i < messages.length; i += batchSize) { - const batch = messages.slice(i, i + batchSize) - yield { type: 'messages', data: batch } - } - - // 完成 - const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '') - yield { type: 'progress', data: doneProgress } - onProgress?.(doneProgress) - - // 统计消息类型 - const typeCounts = new Map() - for (const msg of messages) { - typeCounts.set(msg.type, (typeCounts.get(msg.type) || 0) + 1) - } - - // 记录解析摘要 - onLog?.('info', `解析完成: ${messagesProcessed} 条消息, ${memberMap.size} 个成员, 类型: ${chatType}`) - onLog?.( - 'info', - `消息类型统计: ${Array.from(typeCounts.entries()) - .map(([type, count]) => `${type}=${count}`) - .join(', ')}` - ) - if (skippedLines > 0) { - onLog?.('info', `跳过 ${skippedLines} 行无法解析的内容`) - } - - yield { - type: 'done', - data: { messageCount: messagesProcessed, memberCount: memberMap.size }, - } -} - -// ==================== 导出解析器 ==================== - -export const parser_: Parser = { - feature, - parse: parseWhatsApp, -} - -// ==================== 导出格式模块 ==================== - -const module_: FormatModule = { - feature, - parser: parser_, - // TXT 格式不需要预处理器 -} - -export default module_ diff --git a/electron/main/parser/sniffer.ts b/electron/main/parser/sniffer.ts deleted file mode 100644 index 052c5c72d..000000000 --- a/electron/main/parser/sniffer.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * Parser V2 - 嗅探层 - * 负责检测文件格式,匹配对应的解析器 - */ - -import * as fs from 'fs' -import * as path from 'path' -import type { FormatFeature, FormatModule, Parser, FormatMatchCheck, FormatDiagnosis } from './types' - -/** 文件头检测大小 (8KB) */ -const HEAD_SIZE = 8 * 1024 - -/** - * 读取文件头部内容 - */ -function readFileHead(filePath: string, size: number = HEAD_SIZE): string { - const fd = fs.openSync(filePath, 'r') - const buffer = Buffer.alloc(size) - const bytesRead = fs.readSync(fd, buffer, 0, size, 0) - fs.closeSync(fd) - return buffer.slice(0, bytesRead).toString('utf-8') -} - -/** - * 获取文件扩展名(小写) - */ -function getExtension(filePath: string): string { - return path.extname(filePath).toLowerCase() -} - -/** - * 检查文件头是否匹配签名 - */ -function matchHeadSignatures(headContent: string, patterns: RegExp[]): boolean { - return patterns.some((pattern) => pattern.test(headContent)) -} - -/** - * 检查必需字段是否存在 - */ -function matchRequiredFields(headContent: string, fields: string[]): boolean { - // 简单检查:字段名是否出现在文件头中 - // 对于 JSON 文件,检查 "fieldName" 是否存在 - return fields.every((field) => { - const pattern = new RegExp(`"${field.replace('.', '"\\s*:\\s*.*"')}"\\s*:`) - return pattern.test(headContent) || headContent.includes(`"${field}"`) - }) -} - -/** - * 检查必需字段并返回详细结果 - */ -function checkRequiredFieldsDetail( - headContent: string, - fields: string[] -): { allMatch: boolean; missing: string[] } { - const missing: string[] = [] - - for (const field of fields) { - const pattern = new RegExp(`"${field.replace('.', '"\\s*:\\s*.*"')}"\\s*:`) - const found = pattern.test(headContent) || headContent.includes(`"${field}"`) - if (!found) { - missing.push(field) - } - } - - return { - allMatch: missing.length === 0, - missing, - } -} - -/** - * 格式嗅探器 - * 管理所有格式特征,负责检测文件格式 - */ -export class FormatSniffer { - private formats: FormatModule[] = [] - - /** - * 注册格式模块 - */ - register(module: FormatModule): void { - this.formats.push(module) - // 按优先级排序(优先级数字越小越靠前) - this.formats.sort((a, b) => a.feature.priority - b.feature.priority) - } - - /** - * 批量注册格式模块 - */ - registerAll(modules: FormatModule[]): void { - for (const module of modules) { - this.register(module) - } - } - - /** - * 嗅探文件格式 - * @param filePath 文件路径 - * @returns 匹配的格式特征,如果无法识别则返回 null - */ - sniff(filePath: string): FormatFeature | null { - const ext = getExtension(filePath) - const headContent = readFileHead(filePath) - - for (const { feature } of this.formats) { - if (this.matchFeature(feature, ext, headContent)) { - return feature - } - } - - return null - } - - /** - * 获取文件对应的解析器 - * @param filePath 文件路径 - * @returns 匹配的解析器,如果无法识别则返回 null - */ - getParser(filePath: string): Parser | null { - const ext = getExtension(filePath) - const headContent = readFileHead(filePath) - - for (const { feature, parser } of this.formats) { - if (this.matchFeature(feature, ext, headContent)) { - return parser - } - } - - return null - } - - /** - * 根据格式 ID 获取解析器 - */ - getParserById(formatId: string): Parser | null { - const module = this.formats.find((m) => m.feature.id === formatId) - return module?.parser || null - } - - /** - * 获取所有支持的格式 - */ - getSupportedFormats(): FormatFeature[] { - return this.formats.map((m) => m.feature) - } - - /** - * 诊断文件格式 - * 返回详细的匹配信息,用于提供更好的错误提示 - * @param filePath 文件路径 - * @returns 诊断结果,包含每个格式的匹配详情 - */ - diagnose(filePath: string): FormatDiagnosis { - const ext = getExtension(filePath) - const headContent = readFileHead(filePath) - - const checks: FormatMatchCheck[] = [] - const partialMatches: FormatMatchCheck[] = [] - let matchedFormat: FormatFeature | null = null - - for (const { feature } of this.formats) { - const check = this.checkFeatureDetail(feature, ext, headContent) - checks.push(check) - - if (check.fullMatch && !matchedFormat) { - matchedFormat = feature - } else if (check.extensionMatch && !check.fullMatch) { - partialMatches.push(check) - } - } - - // 生成诊断建议 - const suggestion = this.generateSuggestion(ext, partialMatches, headContent) - - return { - recognized: matchedFormat !== null, - matchedFormat, - checks, - partialMatches, - suggestion, - } - } - - /** - * 检查单个格式的匹配详情 - */ - private checkFeatureDetail(feature: FormatFeature, ext: string, headContent: string): FormatMatchCheck { - const result: FormatMatchCheck = { - formatId: feature.id, - formatName: feature.name, - extensionMatch: feature.extensions.includes(ext), - headSignatureMatch: null, - requiredFieldsMatch: null, - missingFields: [], - fullMatch: false, - } - - // 扩展名不匹配,直接返回 - if (!result.extensionMatch) { - return result - } - - const { signatures } = feature - - // 检查文件头签名 - if (signatures.head && signatures.head.length > 0) { - result.headSignatureMatch = matchHeadSignatures(headContent, signatures.head) - } - - // 检查必需字段 - if (signatures.requiredFields && signatures.requiredFields.length > 0) { - const { allMatch, missing } = checkRequiredFieldsDetail(headContent, signatures.requiredFields) - result.requiredFieldsMatch = allMatch - result.missingFields = missing - } - - // 检查字段值模式 - let fieldPatternsMatch = true - if (signatures.fieldPatterns) { - for (const [, pattern] of Object.entries(signatures.fieldPatterns)) { - if (!pattern.test(headContent)) { - fieldPatternsMatch = false - break - } - } - } - - // 判断是否完全匹配 - result.fullMatch = - result.extensionMatch && - (result.headSignatureMatch === null || result.headSignatureMatch) && - (result.requiredFieldsMatch === null || result.requiredFieldsMatch) && - fieldPatternsMatch - - return result - } - - /** - * 生成诊断建议信息 - */ - private generateSuggestion(ext: string, partialMatches: FormatMatchCheck[], headContent: string): string { - if (partialMatches.length === 0) { - return `没有找到匹配扩展名 "${ext}" 的格式,请检查文件类型是否正确` - } - - // 找到最可能的格式(按优先级排序后的第一个部分匹配) - const mostLikely = partialMatches[0] - - // 构建详细的建议信息 - const issues: string[] = [] - - if (mostLikely.headSignatureMatch === false) { - issues.push('文件头签名不匹配') - } - - if (mostLikely.missingFields.length > 0) { - issues.push(`缺少必需字段: ${mostLikely.missingFields.join(', ')}`) - } - - if (issues.length > 0) { - return `文件疑似 ${mostLikely.formatName} 格式,但存在以下问题:${issues.join(';')}` - } - - // 如果是 JSON 文件,提供额外提示 - if (ext === '.json') { - // 检查文件头是否能看到有效的 JSON 结构 - const trimmed = headContent.trim() - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { - return '文件内容不是有效的 JSON 格式' - } - } - - return `扩展名匹配 ${mostLikely.formatName} 格式,但内容结构不符合预期` - } - - /** - * 检查特征是否匹配 - */ - private matchFeature(feature: FormatFeature, ext: string, headContent: string): boolean { - // 1. 检查扩展名 - if (!feature.extensions.includes(ext)) { - return false - } - - const { signatures } = feature - - // 2. 检查文件头签名(如果定义了) - if (signatures.head && signatures.head.length > 0) { - if (!matchHeadSignatures(headContent, signatures.head)) { - return false - } - } - - // 3. 检查必需字段(如果定义了) - if (signatures.requiredFields && signatures.requiredFields.length > 0) { - if (!matchRequiredFields(headContent, signatures.requiredFields)) { - return false - } - } - - // 4. 检查字段值模式(如果定义了) - if (signatures.fieldPatterns) { - for (const [, pattern] of Object.entries(signatures.fieldPatterns)) { - if (!pattern.test(headContent)) { - return false - } - } - } - - return true - } -} - -/** - * 创建并返回全局嗅探器实例 - */ -export function createSniffer(): FormatSniffer { - return new FormatSniffer() -} - diff --git a/electron/main/paths.ts b/electron/main/paths.ts deleted file mode 100644 index 456c9fa3e..000000000 --- a/electron/main/paths.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * 统一路径管理模块 - * 所有应用数据存储在 app.getPath('userData') 目录下 - * - * 各平台路径: - * - Windows: %APPDATA%/ChatLab (例如 C:\Users\xxx\AppData\Roaming\ChatLab) - * - macOS: ~/Library/Application Support/ChatLab - * - Linux: ~/.config/ChatLab - */ - -import { app } from 'electron' -import * as fs from 'fs' -import * as path from 'path' - -// 缓存路径,避免重复计算 -let _appDataDir: string | null = null -let _legacyDataDir: string | null = null - -/** - * 获取应用数据根目录 - * 使用 userData/data 子目录,与 Electron 缓存隔离 - */ -export function getAppDataDir(): string { - if (_appDataDir) return _appDataDir - - try { - const userDataPath = app.getPath('userData') - // 使用子目录存放应用数据,避免与 Electron 缓存混淆 - _appDataDir = path.join(userDataPath, 'data') - } catch (error) { - console.error('[Paths] Error getting userData path:', error) - _appDataDir = path.join(process.cwd(), 'userData', 'data') - } - - return _appDataDir -} - -/** - * 获取旧版数据目录(Documents/ChatLab) - * 用于数据迁移检测 - */ -export function getLegacyDataDir(): string { - if (_legacyDataDir) return _legacyDataDir - - try { - const docPath = app.getPath('documents') - _legacyDataDir = path.join(docPath, 'ChatLab') - } catch (error) { - console.error('[Paths] Error getting documents path:', error) - _legacyDataDir = path.join(process.cwd(), 'ChatLab') - } - - return _legacyDataDir -} - -/** - * 获取系统下载目录 - * 用于用户导出文件的默认位置 - */ -export function getDownloadsDir(): string { - try { - return app.getPath('downloads') - } catch (error) { - console.error('[Paths] Error getting downloads path:', error) - return path.join(process.cwd(), 'downloads') - } -} - -/** - * 获取数据库目录 - */ -export function getDatabaseDir(): string { - return path.join(getAppDataDir(), 'databases') -} - -/** - * 获取 AI 数据目录(对话历史、LLM 配置) - */ -export function getAiDataDir(): string { - return path.join(getAppDataDir(), 'ai') -} - -/** - * 获取设置目录 - */ -export function getSettingsDir(): string { - return path.join(getAppDataDir(), 'settings') -} - -/** - * 获取临时文件目录 - */ -export function getTempDir(): string { - return path.join(getAppDataDir(), 'temp') -} - -/** - * 获取日志目录 - */ -export function getLogsDir(): string { - return path.join(getAppDataDir(), 'logs') -} - -/** - * 确保目录存在 - */ -export function ensureDir(dirPath: string): void { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }) - } -} - -/** - * 确保所有应用目录存在 - */ -export function ensureAppDirs(): void { - ensureDir(getDatabaseDir()) - ensureDir(getAiDataDir()) - ensureDir(getSettingsDir()) - ensureDir(getTempDir()) - ensureDir(getLogsDir()) -} - -// ==================== 数据迁移 ==================== - -/** - * 检查是否需要从 Documents/ChatLab 迁移数据 - */ -export function needsLegacyMigration(): boolean { - const legacyDir = getLegacyDataDir() - - // 检查 Documents/ChatLab 是否存在 - if (fs.existsSync(legacyDir)) { - return true - } - - return false -} - -/** - * 从指定源目录迁移数据到目标目录 - * 采用合并策略:只复制不存在的文件,不覆盖已存在的文件 - */ -function migrateDirectory( - srcDir: string, - destDir: string, - subDirs: string[] -): { migratedDirs: string[]; skippedDirs: string[] } { - const migratedDirs: string[] = [] - const skippedDirs: string[] = [] - - for (const subDir of subDirs) { - const srcSubPath = path.join(srcDir, subDir) - const destSubPath = path.join(destDir, subDir) - - // 如果源子目录不存在或为空,跳过 - if (!fs.existsSync(srcSubPath)) { - continue - } - - const srcFiles = fs.readdirSync(srcSubPath).filter((f) => !f.startsWith('.')) - if (srcFiles.length === 0) { - continue - } - - // 确保目标子目录存在 - ensureDir(destSubPath) - - // 获取目标目录中已存在的文件 - const existingFiles = new Set(fs.readdirSync(destSubPath)) - - // 合并策略:只复制目标目录中不存在的文件 - let copiedCount = 0 - let skippedCount = 0 - - for (const file of srcFiles) { - const srcPath = path.join(srcSubPath, file) - const destPath = path.join(destSubPath, file) - - // 如果目标文件已存在,跳过(不覆盖) - if (existingFiles.has(file)) { - console.log(`[Paths] Skipping ${subDir}/${file}: already exists in destination`) - skippedCount++ - continue - } - - const stat = fs.statSync(srcPath) - if (stat.isDirectory()) { - copyDirRecursive(srcPath, destPath) - } else { - fs.copyFileSync(srcPath, destPath) - } - copiedCount++ - } - - if (copiedCount > 0) { - migratedDirs.push(subDir) - console.log(`[Paths] Migrated ${subDir}: ${copiedCount} items copied, ${skippedCount} skipped`) - } else if (skippedCount > 0) { - skippedDirs.push(subDir) - console.log(`[Paths] ${subDir}: all ${skippedCount} items already exist in destination`) - } - } - - return { migratedDirs, skippedDirs } -} - -/** - * 写入迁移日志到 app.log - * 使用内联实现避免循环依赖 - */ -function writeMigrationLog(message: string): void { - try { - const logDir = getLogsDir() - ensureDir(logDir) - const logPath = path.join(logDir, 'app.log') - const now = new Date() - const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}` - const logLine = `[${timestamp}] [MIGRATION] ${message}\n` - fs.appendFileSync(logPath, logLine, 'utf-8') - } catch { - // 日志写入失败时静默处理 - } -} - -/** - * 执行从 Documents/ChatLab 到新目录的数据迁移 - * 迁移整个目录的所有内容,采用合并策略:只复制不存在的文件,不覆盖已存在的文件 - * 只有在所有数据都成功迁移后才删除旧目录 - */ -export function migrateFromLegacyDir(): { success: boolean; migratedDirs: string[]; error?: string } { - const legacyDir = getLegacyDataDir() - const newDir = getAppDataDir() - - try { - if (!fs.existsSync(legacyDir)) { - return { success: true, migratedDirs: [] } - } - - // 获取旧目录下的所有子目录和文件 - const entries = fs.readdirSync(legacyDir, { withFileTypes: true }) - const dirsToMigrate = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name) - const filesToMigrate = entries.filter((e) => e.isFile() && !e.name.startsWith('.')).map((e) => e.name) - - const result = migrateDirectory(legacyDir, newDir, dirsToMigrate) - - // 迁移根目录下的文件 - ensureDir(newDir) - for (const file of filesToMigrate) { - const srcPath = path.join(legacyDir, file) - const destPath = path.join(newDir, file) - if (!fs.existsSync(destPath)) { - fs.copyFileSync(srcPath, destPath) - } - } - - // 构建迁移摘要 - const summary: string[] = [] - summary.push(`Migration from ${legacyDir} to ${newDir}`) - - // 迁移成功,删除旧目录 - fs.rmSync(legacyDir, { recursive: true, force: true }) - summary.push('Status: Success, legacy directory removed') - - if (result.migratedDirs.length > 0) { - summary.push(`Migrated dirs: ${result.migratedDirs.join(', ')}`) - } - if (filesToMigrate.length > 0) { - summary.push(`Migrated files: ${filesToMigrate.length}`) - } - - // 写入迁移日志 - writeMigrationLog(summary.join(' | ')) - - return { success: true, migratedDirs: result.migratedDirs } - } catch (error) { - console.error('[Paths] Migration failed:', error) - const errorMsg = error instanceof Error ? error.message : String(error) - writeMigrationLog(`Migration failed: ${errorMsg}`) - return { - success: false, - migratedDirs: [], - error: errorMsg, - } - } -} - -/** - * 递归复制目录 - */ -function copyDirRecursive(src: string, dest: string): void { - ensureDir(dest) - const entries = fs.readdirSync(src, { withFileTypes: true }) - - for (const entry of entries) { - const srcPath = path.join(src, entry.name) - const destPath = path.join(dest, entry.name) - - if (entry.isDirectory()) { - copyDirRecursive(srcPath, destPath) - } else { - fs.copyFileSync(srcPath, destPath) - } - } -} - -/** - * 删除旧版数据目录(可选,供用户确认后调用) - */ -export function removeLegacyDir(): boolean { - const legacyDir = getLegacyDataDir() - - if (!fs.existsSync(legacyDir)) { - return true - } - - try { - fs.rmSync(legacyDir, { recursive: true, force: true }) - console.log(`[Paths] Removed legacy directory: ${legacyDir}`) - return true - } catch (error) { - console.error('[Paths] Failed to remove legacy directory:', error) - return false - } -} - diff --git a/electron/main/update.ts b/electron/main/update.ts deleted file mode 100644 index 6fde82bb1..000000000 --- a/electron/main/update.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { dialog, app } from 'electron' -import { autoUpdater } from 'electron-updater' -import { platform } from '@electron-toolkit/utils' -import { logger } from './logger' -import { getActiveProxyUrl } from './network/proxy' - -/** - * 配置自动更新的代理设置 - * electron-updater 通过环境变量读取代理配置 - */ -function configureUpdateProxy(): void { - const proxyUrl = getActiveProxyUrl() - - if (proxyUrl) { - // 设置环境变量,electron-updater 会自动读取 - process.env.HTTPS_PROXY = proxyUrl - process.env.HTTP_PROXY = proxyUrl - logger.info(`[Update] 使用代理: ${proxyUrl}`) - } else { - // 清除代理环境变量 - delete process.env.HTTPS_PROXY - delete process.env.HTTP_PROXY - } -} - -/** - * 判断版本号是否为预发布版本 - * 预发布版本格式:0.3.0-beta.1, 0.4.2-alpha.23, 1.0.0-rc.1 等 - * 标准版本格式:0.3.0, 1.0.0, 2.1.3 等 - */ -function isPreReleaseVersion(version: string): boolean { - // 预发布版本包含连字符后跟预发布标识(alpha, beta, rc, dev, canary 等) - return /-/.test(version) -} - -let isFirstShow = true -// 标记是否为手动检查更新(手动检查时即使是预发布版本也显示弹窗) -let isManualCheck = false -const checkUpdate = (win) => { - // 配置代理 - configureUpdateProxy() - - autoUpdater.autoDownload = false // 自动下载 - autoUpdater.autoInstallOnAppQuit = true // 应用退出后自动安装 - - // 开发模式下模拟更新检测(需要创建 dev-app-update.yml 文件) - // 取消下面的注释来启用开发模式更新测试 - // if (!app.isPackaged) { - // Object.defineProperty(app, 'isPackaged', { - // get() { - // return true - // }, - // }) - // } - - let showUpdateMessageBox = false - autoUpdater.on('update-available', (info) => { - // win.webContents.send('show-message', 'electron:发现新版本') - if (showUpdateMessageBox) return - - // 检查是否为预发布版本 - const isPreRelease = isPreReleaseVersion(info.version) - - // 预发布版本仅在手动检查时显示更新弹窗 - if (isPreRelease && !isManualCheck) { - console.log(`[Update] 发现预发布版本 ${info.version},跳过自动更新提示`) - logger.info(`[Update] 发现预发布版本 ${info.version},跳过自动更新提示(需手动检查更新)`) - return - } - - showUpdateMessageBox = true - - // 解析更新日志 - let releaseNotes = '' - if (info.releaseNotes) { - if (typeof info.releaseNotes === 'string') { - releaseNotes = info.releaseNotes - } else if (Array.isArray(info.releaseNotes)) { - releaseNotes = info.releaseNotes.map((note) => note.note || note).join('\n') - } - // 简单清理 HTML 标签,合并连续空行,截断下载说明 - releaseNotes = releaseNotes - .replace(/<[^>]*>/g, '') - .replace(/\n{2,}/g, '\n') - .trim() - - // 如果包含下载说明章节,截断该部分及之后的内容(匹配二级标题,支持中英文) - const downloadGuideIndex = Math.min( - ...[releaseNotes.indexOf('## Download'), releaseNotes.indexOf('## 下载说明')] - .filter((i) => i > 0) - .concat([Infinity]) - ) - if (downloadGuideIndex < Infinity) { - releaseNotes = releaseNotes.substring(0, downloadGuideIndex).trim() - } - } - - const detail = releaseNotes - ? `更新内容:\n${releaseNotes}\n\n是否立即下载并安装新版本?` - : '是否立即下载并安装新版本?' - - dialog - .showMessageBox({ - title: '发现新版本 v' + info.version, - message: '发现新版本 v' + info.version, - detail, - buttons: ['立即下载', '取消'], - defaultId: 0, - cancelId: 1, - type: 'question', - noLink: true, - }) - .then((result) => { - showUpdateMessageBox = false - if (result.response === 0) { - autoUpdater - .downloadUpdate() - .then(() => { - console.log('wait for post download operation') - }) - .catch((downloadError) => { - // 下载失败记录到日志,不显示给用户 - logger.error(`[Update] 下载更新失败: ${downloadError}`) - }) - } - }) - }) - - // 监听下载进度事件 - autoUpdater.on('download-progress', (progressObj) => { - console.log(`更新下载进度: ${progressObj.percent}%`) - win.webContents.send('update-download-progress', progressObj.percent) - }) - - // 下载完成 - autoUpdater.on('update-downloaded', () => { - dialog - .showMessageBox({ - title: '下载完成', - message: '新版本已准备就绪,是否现在安装?', - buttons: ['安装', platform.isMacOS ? '之后提醒' : '稍后(应用退出后自动安装)'], - defaultId: 1, - cancelId: 2, - type: 'question', - }) - .then((result) => { - if (result.response === 0) { - win.webContents.send('begin-install') - // @ts-ignore - app.isQuiting = true - setTimeout(() => { - setImmediate(() => { - autoUpdater.quitAndInstall() - }) - }, 100) - } - }) - }) - - // 不需要更新 - autoUpdater.on('update-not-available', (info) => { - // 客户端打开会默认弹一次,用isFirstShow来控制不弹 - if (isFirstShow) { - isFirstShow = false - } else { - win.webContents.send('show-message', { - type: 'success', - message: '已是最新版本', - }) - } - }) - - // 错误处理(静默处理,记录到日志) - autoUpdater.on('error', (err) => { - // 更新错误记录到日志,不显示给用户 - logger.error(`[Update] 更新错误: ${err.message || err}`) - }) - - // 等待 3 秒再检查更新,确保窗口准备完成,用户进入系统 - setTimeout(() => { - isManualCheck = false // 自动检查 - autoUpdater.checkForUpdates().catch((err) => { - console.log('[Update] 检查更新失败:', err) - }) - }, 3000) -} - -/** - * 手动检查更新 - * 手动检查时,即使是预发布版本也会显示更新弹窗 - */ -const manualCheckForUpdates = () => { - // 配置代理 - configureUpdateProxy() - - isManualCheck = true // 手动检查 - isFirstShow = false // 手动检查时,无论结果都显示提示 - - autoUpdater.checkForUpdates().catch((err) => { - console.log('[Update] 手动检查更新失败:', err) - logger.error(`[Update] 手动检查更新失败: ${err}`) - }) -} - -/** - * 模拟更新弹窗(仅用于开发测试) - * 控制台通过:window.api.app.simulateUpdate() 测试 - */ -const simulateUpdateDialog = (win) => { - const mockInfo = { - version: '9.9.9', - releaseNotes: `## 更新内容\n\n- 🎉 新增聊天记录查看器\n- 🔧 修复已知问题\n- ⚡️ 性能优化`, - } - - // 解析更新日志 - let releaseNotes = mockInfo.releaseNotes.replace(/<[^>]*>/g, '').trim() - - const detail = releaseNotes - ? `更新内容:\n${releaseNotes}\n\n是否立即下载并安装新版本?` - : '是否立即下载并安装新版本?' - - dialog.showMessageBox({ - title: '发现新版本 v' + mockInfo.version, - message: '发现新版本 v' + mockInfo.version, - detail, - buttons: ['立即下载', '取消'], - defaultId: 0, - cancelId: 1, - type: 'question', - noLink: true, - }) -} - -export { checkUpdate, simulateUpdateDialog, manualCheckForUpdates } diff --git a/electron/main/worker/core/dbCore.ts b/electron/main/worker/core/dbCore.ts deleted file mode 100644 index e1b36826c..000000000 --- a/electron/main/worker/core/dbCore.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * 数据库核心工具模块 - * 提供数据库连接管理和通用工具函数 - */ - -import Database from 'better-sqlite3' -import * as fs from 'fs' -import * as path from 'path' - -// 数据库目录(由 Worker 初始化时设置) -let DB_DIR: string = '' - -// 数据库连接缓存 -const dbCache = new Map() - -/** - * 初始化数据库目录 - */ -export function initDbDir(dir: string): void { - DB_DIR = dir -} - -/** - * 获取数据库文件路径 - */ -export function getDbPath(sessionId: string): string { - return path.join(DB_DIR, `${sessionId}.db`) -} - -/** - * 打开数据库(带缓存) - */ -export function openDatabase(sessionId: string): Database.Database | null { - // 检查缓存 - if (dbCache.has(sessionId)) { - return dbCache.get(sessionId)! - } - - const dbPath = getDbPath(sessionId) - if (!fs.existsSync(dbPath)) { - return null - } - - const db = new Database(dbPath, { readonly: true }) - db.pragma('journal_mode = WAL') - - // 缓存连接 - dbCache.set(sessionId, db) - return db -} - -/** - * 关闭指定会话的数据库连接 - */ -export function closeDatabase(sessionId: string): void { - const db = dbCache.get(sessionId) - if (db) { - db.close() - dbCache.delete(sessionId) - } -} - -/** - * 关闭所有数据库连接 - */ -export function closeAllDatabases(): void { - for (const [sessionId, db] of dbCache.entries()) { - db.close() - dbCache.delete(sessionId) - } -} - -/** - * 获取数据库目录 - */ -export function getDbDir(): string { - return DB_DIR -} - -// ==================== 时间过滤工具 ==================== - -export interface TimeFilter { - startTs?: number - endTs?: number -} - -/** - * 构建时间过滤 WHERE 子句 - */ -export function buildTimeFilter(filter?: TimeFilter): { clause: string; params: number[] } { - const conditions: string[] = [] - const params: number[] = [] - - if (filter?.startTs !== undefined) { - conditions.push('ts >= ?') - params.push(filter.startTs) - } - if (filter?.endTs !== undefined) { - conditions.push('ts <= ?') - params.push(filter.endTs) - } - - return { - clause: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '', - params, - } -} - -/** - * 构建排除系统消息的过滤条件 - */ -export function buildSystemMessageFilter(existingClause: string): string { - // 系统消息过滤:account_name 不等于 '系统消息' - const systemFilter = "COALESCE(m.account_name, '') != '系统消息'" - - if (existingClause.includes('WHERE')) { - return existingClause + ' AND ' + systemFilter - } else { - return ' WHERE ' + systemFilter - } -} - diff --git a/electron/main/worker/dbWorker.ts b/electron/main/worker/dbWorker.ts deleted file mode 100644 index 00269a4b6..000000000 --- a/electron/main/worker/dbWorker.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * 数据库 Worker 线程 - * 在独立线程中执行数据库操作,避免阻塞主进程 - * - * 本文件作为 Worker 入口,负责: - * 1. 初始化数据库目录 - * 2. 接收主进程消息 - * 3. 分发到对应的查询模块 - * 4. 返回结果 - */ - -import { parentPort, workerData } from 'worker_threads' -import { initDbDir, closeDatabase, closeAllDatabases } from './core' -import { - getAvailableYears, - getMemberActivity, - getHourlyActivity, - getDailyActivity, - getWeekdayActivity, - getMonthlyActivity, - getMessageTypeDistribution, - getTimeRange, - getMemberNameHistory, - getAllSessions, - getSession, - getRepeatAnalysis, - getCatchphraseAnalysis, - getNightOwlAnalysis, - getDragonKingAnalysis, - getDivingAnalysis, - getMonologueAnalysis, - getMentionAnalysis, - getLaughAnalysis, - getMemeBattleAnalysis, - getCheckInAnalysis, - searchMessages, - getMessageContext, - getRecentMessages, - getAllRecentMessages, - getConversationBetween, - getMessagesBefore, - getMessagesAfter, - // 成员管理 - getMembers, - updateMemberAliases, - deleteMember, - // SQL 实验室 - executeRawSQL, - getSchema, -} from './query' -import { streamImport, streamParseFileInfo } from './import' - -// 初始化数据库目录 -initDbDir(workerData.dbDir) - -// ==================== 消息处理 ==================== - -interface WorkerMessage { - id: string - type: string - payload: any -} - -// 同步消息处理器 -const syncHandlers: Record any> = { - // 基础查询 - getAvailableYears: (p) => getAvailableYears(p.sessionId), - getMemberActivity: (p) => getMemberActivity(p.sessionId, p.filter), - getHourlyActivity: (p) => getHourlyActivity(p.sessionId, p.filter), - getDailyActivity: (p) => getDailyActivity(p.sessionId, p.filter), - getWeekdayActivity: (p) => getWeekdayActivity(p.sessionId, p.filter), - getMonthlyActivity: (p) => getMonthlyActivity(p.sessionId, p.filter), - getMessageTypeDistribution: (p) => getMessageTypeDistribution(p.sessionId, p.filter), - getTimeRange: (p) => getTimeRange(p.sessionId), - getMemberNameHistory: (p) => getMemberNameHistory(p.sessionId, p.memberId), - - // 会话管理 - getAllSessions: () => getAllSessions(), - getSession: (p) => getSession(p.sessionId), - closeDatabase: (p) => { - closeDatabase(p.sessionId) - return true - }, - closeAll: () => { - closeAllDatabases() - return true - }, - - // 成员管理 - getMembers: (p) => getMembers(p.sessionId), - updateMemberAliases: (p) => updateMemberAliases(p.sessionId, p.memberId, p.aliases), - deleteMember: (p) => deleteMember(p.sessionId, p.memberId), - - // 高级分析 - getRepeatAnalysis: (p) => getRepeatAnalysis(p.sessionId, p.filter), - getCatchphraseAnalysis: (p) => getCatchphraseAnalysis(p.sessionId, p.filter), - getNightOwlAnalysis: (p) => getNightOwlAnalysis(p.sessionId, p.filter), - getDragonKingAnalysis: (p) => getDragonKingAnalysis(p.sessionId, p.filter), - getDivingAnalysis: (p) => getDivingAnalysis(p.sessionId, p.filter), - getMonologueAnalysis: (p) => getMonologueAnalysis(p.sessionId, p.filter), - getMentionAnalysis: (p) => getMentionAnalysis(p.sessionId, p.filter), - getLaughAnalysis: (p) => getLaughAnalysis(p.sessionId, p.filter, p.keywords), - getMemeBattleAnalysis: (p) => getMemeBattleAnalysis(p.sessionId, p.filter), - getCheckInAnalysis: (p) => getCheckInAnalysis(p.sessionId, p.filter), - - // AI 查询 - searchMessages: (p) => searchMessages(p.sessionId, p.keywords, p.filter, p.limit, p.offset, p.senderId), - getMessageContext: (p) => getMessageContext(p.sessionId, p.messageIds, p.contextSize), - getRecentMessages: (p) => getRecentMessages(p.sessionId, p.filter, p.limit), - getAllRecentMessages: (p) => getAllRecentMessages(p.sessionId, p.filter, p.limit), - getConversationBetween: (p) => getConversationBetween(p.sessionId, p.memberId1, p.memberId2, p.filter, p.limit), - getMessagesBefore: (p) => getMessagesBefore(p.sessionId, p.beforeId, p.limit, p.filter, p.senderId, p.keywords), - getMessagesAfter: (p) => getMessagesAfter(p.sessionId, p.afterId, p.limit, p.filter, p.senderId, p.keywords), - - // SQL 实验室 - executeRawSQL: (p) => executeRawSQL(p.sessionId, p.sql), - getSchema: (p) => getSchema(p.sessionId), -} - -// 异步消息处理器(流式操作) -const asyncHandlers: Record Promise> = { - // 流式导入 - streamImport: (p, id) => streamImport(p.filePath, id), - // 流式解析文件信息(用于合并预览) - streamParseFileInfo: (p, id) => streamParseFileInfo(p.filePath, id), -} - -// 处理消息 -parentPort?.on('message', async (message: WorkerMessage) => { - const { id, type, payload } = message - - try { - // 检查是否是异步处理器 - const asyncHandler = asyncHandlers[type] - if (asyncHandler) { - const result = await asyncHandler(payload, id) - parentPort?.postMessage({ id, success: true, result }) - return - } - - // 同步处理器 - const syncHandler = syncHandlers[type] - if (!syncHandler) { - throw new Error(`Unknown message type: ${type}`) - } - - const result = syncHandler(payload) - parentPort?.postMessage({ id, success: true, result }) - } catch (error) { - parentPort?.postMessage({ - id, - success: false, - error: error instanceof Error ? error.message : String(error), - }) - } -}) - -// 进程退出时关闭所有数据库连接 -process.on('exit', () => { - closeAllDatabases() -}) diff --git a/electron/main/worker/import/index.ts b/electron/main/worker/import/index.ts deleted file mode 100644 index 4ae1a1ccc..000000000 --- a/electron/main/worker/import/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 导入模块入口 - * 统一导出流式导入相关函数和类型 - */ - -export { - streamImport, - streamParseFileInfo, - type StreamImportResult, - type StreamParseFileInfoResult, -} from './streamImport' - diff --git a/electron/main/worker/import/streamImport.ts b/electron/main/worker/import/streamImport.ts deleted file mode 100644 index bbd55c6c4..000000000 --- a/electron/main/worker/import/streamImport.ts +++ /dev/null @@ -1,884 +0,0 @@ -/** - * 流式导入模块 - * 在 Worker 线程中流式解析文件并批量写入数据库 - */ - -import Database from 'better-sqlite3' -import * as fs from 'fs' -import * as path from 'path' -import { parentPort } from 'worker_threads' -import { - streamParseFile, - detectFormat, - getPreprocessor, - needsPreprocess, - type ParseProgress, - type ParsedMeta, - type ParsedMember, - type ParsedMessage, -} from '../../parser' -import { getDbDir } from '../core' -import { initPerfLog, logPerf, logPerfDetail, resetPerfLog, logInfo, logError, logSummary } from '../core' - -/** 流式导入结果 */ -export interface StreamImportResult { - success: boolean - sessionId?: string - error?: string -} - -// ==================== 临时数据库相关(用于合并功能) ==================== - -/** - * 获取临时数据库目录(Worker 环境) - */ -function getTempDir(): string { - const dbDir = getDbDir() - const tempDir = path.join(path.dirname(dbDir), 'temp') - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }) - } - return tempDir -} - -/** - * 生成临时数据库文件路径 - */ -function generateTempDbPath(sourceFilePath: string): string { - const timestamp = Date.now() - const random = Math.random().toString(36).substring(2, 8) - const baseName = path.basename(sourceFilePath, path.extname(sourceFilePath)) - const safeName = baseName.replace(/[/\\?%*:|"<>]/g, '_').substring(0, 50) - return path.join(getTempDir(), `merge_${safeName}_${timestamp}_${random}.db`) -} - -/** - * 创建临时数据库并初始化表结构 - */ -function createTempDatabase(dbPath: string): Database.Database { - const db = new Database(dbPath) - - db.pragma('journal_mode = WAL') - db.pragma('synchronous = NORMAL') - - db.exec(` - CREATE TABLE IF NOT EXISTS meta ( - name TEXT NOT NULL, - platform TEXT NOT NULL, - type TEXT NOT NULL, - group_id TEXT, - group_avatar TEXT, - owner_id TEXT, - schema_version INTEGER DEFAULT 1 - ); - - CREATE TABLE IF NOT EXISTS member ( - platform_id TEXT PRIMARY KEY, - account_name TEXT, - group_nickname TEXT, - avatar TEXT - ); - - CREATE TABLE IF NOT EXISTS message ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - sender_platform_id TEXT NOT NULL, - sender_account_name TEXT, - sender_group_nickname TEXT, - timestamp INTEGER NOT NULL, - type INTEGER NOT NULL, - content TEXT - ); - - CREATE INDEX IF NOT EXISTS idx_message_ts ON message(timestamp); - CREATE INDEX IF NOT EXISTS idx_message_sender ON message(sender_platform_id); - `) - - return db -} - -/** - * 发送进度到主进程 - */ -function sendProgress(requestId: string, progress: ParseProgress): void { - parentPort?.postMessage({ - id: requestId, - type: 'progress', - payload: progress, - }) -} - -/** - * 生成唯一的会话ID - */ -function generateSessionId(): string { - const timestamp = Date.now() - const random = Math.random().toString(36).substring(2, 8) - return `chat_${timestamp}_${random}` -} - -/** - * 获取数据库文件路径 - */ -function getDbPath(sessionId: string): string { - return path.join(getDbDir(), `${sessionId}.db`) -} - -/** - * 创建数据库并初始化表结构(不含索引,用于快速导入) - */ -function createDatabaseWithoutIndexes(sessionId: string): Database.Database { - const dbDir = getDbDir() - if (!fs.existsSync(dbDir)) { - fs.mkdirSync(dbDir, { recursive: true }) - } - - const dbPath = getDbPath(sessionId) - const db = new Database(dbPath) - - db.pragma('journal_mode = WAL') - db.pragma('synchronous = NORMAL') - // 增加缓存大小以提高写入性能 - db.pragma('cache_size = -64000') // 64MB 缓存 - - // 创建表结构(不创建索引,导入完成后再创建) - db.exec(` - CREATE TABLE IF NOT EXISTS meta ( - name TEXT NOT NULL, - platform TEXT NOT NULL, - type TEXT NOT NULL, - imported_at INTEGER NOT NULL, - group_id TEXT, - group_avatar TEXT, - owner_id TEXT, - schema_version INTEGER DEFAULT 2 - ); - - CREATE TABLE IF NOT EXISTS member ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - platform_id TEXT NOT NULL UNIQUE, - account_name TEXT, - group_nickname TEXT, - aliases TEXT DEFAULT '[]', - avatar TEXT, - roles TEXT DEFAULT '[]' - ); - - CREATE TABLE IF NOT EXISTS member_name_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - member_id INTEGER NOT NULL, - name_type TEXT NOT NULL, - name TEXT NOT NULL, - start_ts INTEGER NOT NULL, - end_ts INTEGER, - FOREIGN KEY(member_id) REFERENCES member(id) - ); - - CREATE TABLE IF NOT EXISTS message ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - sender_id INTEGER NOT NULL, - sender_account_name TEXT, - sender_group_nickname TEXT, - ts INTEGER NOT NULL, - type INTEGER NOT NULL, - content TEXT, - reply_to_message_id TEXT DEFAULT NULL, - platform_message_id TEXT DEFAULT NULL, - FOREIGN KEY(sender_id) REFERENCES member(id) - ); - `) - - return db -} - -/** - * 导入完成后创建索引 - */ -function createIndexes(db: Database.Database): void { - db.exec(` - CREATE INDEX IF NOT EXISTS idx_message_ts ON message(ts); - CREATE INDEX IF NOT EXISTS idx_message_sender ON message(sender_id); - CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id); - CREATE INDEX IF NOT EXISTS idx_member_name_history_member_id ON member_name_history(member_id); - `) -} - -/** - * 流式导入聊天记录 - * @param filePath 文件路径 - * @param requestId 请求ID(用于进度回调) - */ -export async function streamImport(filePath: string, requestId: string): Promise { - // 检测格式 - const formatFeature = detectFormat(filePath) - if (!formatFeature) { - return { success: false, error: 'error.unrecognized_format' } - } - - // 初始化性能日志(实时写入文件) - resetPerfLog() - const sessionId = generateSessionId() - initPerfLog(sessionId) - - // 记录导入开始信息 - logInfo(`文件路径: ${filePath}`) - logInfo(`检测到格式: ${formatFeature.name} (${formatFeature.id})`) - logInfo(`平台: ${formatFeature.platform}`) - logPerf('开始导入', 0) - - // 预处理:如果格式需要且文件较大,先精简 - let actualFilePath = filePath - let tempFilePath: string | null = null - const preprocessor = getPreprocessor(filePath) - - if (preprocessor && needsPreprocess(filePath)) { - logInfo('文件需要预处理,开始精简大文件...') - sendProgress(requestId, { - stage: 'parsing', - bytesRead: 0, - totalBytes: 0, - messagesProcessed: 0, - percentage: 0, - message: '', // Frontend translates based on stage - }) - - try { - tempFilePath = await preprocessor.preprocess(filePath, (progress) => { - sendProgress(requestId, { - ...progress, - message: '', // Frontend translates based on stage - }) - }) - actualFilePath = tempFilePath - logInfo(`预处理完成,临时文件: ${tempFilePath}`) - } catch (err) { - const errorMsg = `预处理失败: ${err instanceof Error ? err.message : String(err)}` - logError(errorMsg, err instanceof Error ? err : undefined) - return { - success: false, - error: errorMsg, - } - } - } - - const db = createDatabaseWithoutIndexes(sessionId) - - // 准备语句 - const insertMeta = db.prepare(` - INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?) - `) - const insertMember = db.prepare(` - INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar, roles) VALUES (?, ?, ?, ?, ?) - `) - const getMemberId = db.prepare(`SELECT id FROM member WHERE platform_id = ?`) - const insertMessage = db.prepare(` - INSERT INTO message (sender_id, sender_account_name, sender_group_nickname, ts, type, content, reply_to_message_id, platform_message_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `) - const insertNameHistory = db.prepare(` - INSERT INTO member_name_history (member_id, name_type, name, start_ts, end_ts) VALUES (?, ?, ?, ?, ?) - `) - const updateMemberAccountName = db.prepare(`UPDATE member SET account_name = ? WHERE platform_id = ?`) - const updateMemberGroupNickname = db.prepare(`UPDATE member SET group_nickname = ? WHERE platform_id = ?`) - - // 成员ID映射(platformId -> dbId) - const memberIdMap = new Map() - // 分别追踪 account_name 和 group_nickname 的变化 - const accountNameTracker = new Map< - string, - { - currentName: string - lastSeenTs: number - history: Array<{ name: string; startTs: number }> - } - >() - const groupNicknameTracker = new Map< - string, - { - currentName: string - lastSeenTs: number - history: Array<{ name: string; startTs: number }> - } - >() - // 是否已插入 meta - let metaInserted = false - - // 分批提交配置(每 50000 条消息提交一次) - const BATCH_COMMIT_SIZE = 50000 - // WAL checkpoint 间隔(每 200000 条执行一次 checkpoint) - const CHECKPOINT_INTERVAL = 200000 - let messageCountInBatch = 0 - let totalMessageCount = 0 - let lastCheckpointCount = 0 - let inTransaction = false - - // 开始第一个事务 - const beginTransaction = () => { - if (!inTransaction) { - db.exec('BEGIN TRANSACTION') - inTransaction = true - } - } - - // 执行 WAL checkpoint(将 WAL 日志合并到主数据库) - const doCheckpoint = () => { - try { - db.pragma('wal_checkpoint(TRUNCATE)') - } catch { - // 忽略 WAL checkpoint 失败 - } - } - - // 提交当前事务并开始新事务 - const commitAndBeginNew = () => { - if (inTransaction) { - db.exec('COMMIT') - inTransaction = false - - // 记录性能日志 - logPerf(`提交事务`, totalMessageCount, BATCH_COMMIT_SIZE) - - // 定期执行 WAL checkpoint(防止 WAL 文件过大导致变慢) - if (totalMessageCount - lastCheckpointCount >= CHECKPOINT_INTERVAL) { - doCheckpoint() - logPerf('WAL checkpoint', totalMessageCount) - lastCheckpointCount = totalMessageCount - } - - // Send write progress (frontend shows messagesProcessed count) - sendProgress(requestId, { - stage: 'importing', - bytesRead: 0, - totalBytes: 0, - messagesProcessed: totalMessageCount, - percentage: 100, - message: '', // Frontend translates based on stage and shows messagesProcessed - }) - } - beginTransaction() - } - - beginTransaction() - - // 标记是否需要在 finally 中删除数据库文件 - // 仅在导入失败或消息数为 0 时设置为 true - let shouldDeleteDb = false - let importError: string | null = null - - try { - await streamParseFile(actualFilePath, { - batchSize: 5000, - - onProgress: (progress) => { - // 转发进度到主进程 - sendProgress(requestId, progress) - }, - - onLog: (level, message) => { - // 将解析器日志写入导入日志文件 - if (level === 'error') { - logError(message) - } else { - logInfo(message) - } - }, - - onMeta: (meta: ParsedMeta) => { - if (!metaInserted) { - logInfo(`写入 meta: name=${meta.name}, type=${meta.type}`) - insertMeta.run( - meta.name, - meta.platform, - meta.type, - Math.floor(Date.now() / 1000), - meta.groupId || null, - meta.groupAvatar || null, - meta.ownerId || null - ) - metaInserted = true - } - }, - - onMembers: (members: ParsedMember[]) => { - for (const member of members) { - insertMember.run( - member.platformId, - member.accountName || null, - member.groupNickname || null, - member.avatar || null, - member.roles ? JSON.stringify(member.roles) : '[]' - ) - const row = getMemberId.get(member.platformId) as { id: number } | undefined - if (row) { - memberIdMap.set(member.platformId, row.id) - } - } - }, - - onMessageBatch: (messages: ParsedMessage[]) => { - // 分阶段计时 - let memberLookupTime = 0 - let memberInsertTime = 0 - let messageInsertTime = 0 - let nicknameTrackTime = 0 - let memberLookupCount = 0 - let memberInsertCount = 0 - let nicknameChangeCount = 0 - - for (const msg of messages) { - // 数据验证:跳过无效消息 - if (!msg.senderPlatformId || !msg.senderAccountName) { - continue - } - if (msg.timestamp === undefined || msg.timestamp === null || isNaN(msg.timestamp)) { - continue - } - if (msg.type === undefined || msg.type === null) { - continue - } - - // 确保成员存在 - let t0 = Date.now() - if (!memberIdMap.has(msg.senderPlatformId)) { - // 消息中没有头像和角色信息,设为默认值 - insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null, '[]') - const row = getMemberId.get(msg.senderPlatformId) as { id: number } | undefined - if (row) { - memberIdMap.set(msg.senderPlatformId, row.id) - } - memberInsertCount++ - memberInsertTime += Date.now() - t0 - } else { - memberLookupCount++ - memberLookupTime += Date.now() - t0 - } - - const senderId = memberIdMap.get(msg.senderPlatformId) - if (senderId === undefined) continue - - // 插入消息 - // 防御性处理:确保所有值都是 SQLite 兼容的类型 - // SQLite 只支持: numbers, strings, bigints, buffers, null - let safeContent: string | null = null - if (msg.content != null) { - safeContent = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) - } - - t0 = Date.now() - insertMessage.run( - senderId, - msg.senderAccountName || null, - msg.senderGroupNickname || null, - msg.timestamp, - msg.type, - safeContent, - msg.replyToMessageId || null, - msg.platformMessageId || null - ) - messageInsertTime += Date.now() - t0 - messageCountInBatch++ - totalMessageCount++ - - // 追踪昵称变化(仅记录,不写入数据库,最后批量处理) - t0 = Date.now() - - // 追踪 account_name 变化 - const accountName = msg.senderAccountName - if (accountName && accountName !== msg.senderPlatformId) { - const tracker = accountNameTracker.get(msg.senderPlatformId) - if (!tracker) { - accountNameTracker.set(msg.senderPlatformId, { - currentName: accountName, - lastSeenTs: msg.timestamp, - history: [{ name: accountName, startTs: msg.timestamp }], - }) - nicknameChangeCount++ - } else if (tracker.currentName !== accountName) { - tracker.history.push({ name: accountName, startTs: msg.timestamp }) - tracker.currentName = accountName - tracker.lastSeenTs = msg.timestamp - nicknameChangeCount++ - } else { - tracker.lastSeenTs = msg.timestamp - } - } - - // 追踪 group_nickname 变化 - const groupNickname = msg.senderGroupNickname - if (groupNickname) { - const tracker = groupNicknameTracker.get(msg.senderPlatformId) - if (!tracker) { - groupNicknameTracker.set(msg.senderPlatformId, { - currentName: groupNickname, - lastSeenTs: msg.timestamp, - history: [{ name: groupNickname, startTs: msg.timestamp }], - }) - nicknameChangeCount++ - } else if (tracker.currentName !== groupNickname) { - tracker.history.push({ name: groupNickname, startTs: msg.timestamp }) - tracker.currentName = groupNickname - tracker.lastSeenTs = msg.timestamp - nicknameChangeCount++ - } else { - tracker.lastSeenTs = msg.timestamp - } - } - - nicknameTrackTime += Date.now() - t0 - - // 分批提交(每 50000 条) - if (messageCountInBatch >= BATCH_COMMIT_SIZE) { - // 记录详细分阶段耗时 - const detail = - `[详细] 成员查找: ${memberLookupTime}ms (${memberLookupCount}次) | ` + - `成员插入: ${memberInsertTime}ms (${memberInsertCount}次) | ` + - `消息插入: ${messageInsertTime}ms | ` + - `昵称追踪: ${nicknameTrackTime}ms (变化${nicknameChangeCount}次)` - logPerfDetail(detail) - - commitAndBeginNew() - messageCountInBatch = 0 - - // 重置计时 - memberLookupTime = 0 - memberInsertTime = 0 - messageInsertTime = 0 - nicknameTrackTime = 0 - memberLookupCount = 0 - memberInsertCount = 0 - nicknameChangeCount = 0 - } - } - }, - }) - - // 提交最后的消息事务 - if (inTransaction) { - db.exec('COMMIT') - inTransaction = false - } - - // 批量写入昵称历史(在索引创建前,写入速度更快) - sendProgress(requestId, { - stage: 'importing', - bytesRead: 0, - totalBytes: 0, - messagesProcessed: totalMessageCount, - percentage: 100, - message: '', // Frontend translates based on stage - }) - logPerf('开始写入昵称历史', totalMessageCount) - - // 开始新事务 - db.exec('BEGIN TRANSACTION') - let historyCount = 0 - let filteredCount = 0 - - // 处理 account_name 历史 - for (const [platformId, tracker] of accountNameTracker.entries()) { - if (!platformId || platformId === '0' || platformId === 'undefined') continue - - const senderId = memberIdMap.get(platformId) - if (!senderId) continue - - // 清理历史记录 - const uniqueNames = new Map() - for (const h of tracker.history) { - const existing = uniqueNames.get(h.name) - if (!existing) { - uniqueNames.set(h.name, { startTs: h.startTs, lastTs: h.startTs }) - } else { - existing.lastTs = h.startTs - } - } - - uniqueNames.delete(platformId) - - if (uniqueNames.size <= 1) { - filteredCount++ - updateMemberAccountName.run(tracker.currentName, platformId) - continue - } - - const sortedHistory = Array.from(uniqueNames.entries()).sort((a, b) => a[1].startTs - b[1].startTs) - for (let i = 0; i < sortedHistory.length; i++) { - const [name, { startTs }] = sortedHistory[i] - const endTs = i < sortedHistory.length - 1 ? sortedHistory[i + 1][1].startTs : null - insertNameHistory.run(senderId, 'account_name', name, startTs, endTs) - historyCount++ - } - - updateMemberAccountName.run(tracker.currentName, platformId) - } - - // 处理 group_nickname 历史 - for (const [platformId, tracker] of groupNicknameTracker.entries()) { - if (!platformId || platformId === '0' || platformId === 'undefined') continue - - const senderId = memberIdMap.get(platformId) - if (!senderId) continue - - const uniqueNames = new Map() - for (const h of tracker.history) { - const existing = uniqueNames.get(h.name) - if (!existing) { - uniqueNames.set(h.name, { startTs: h.startTs, lastTs: h.startTs }) - } else { - existing.lastTs = h.startTs - } - } - - if (uniqueNames.size <= 1) { - filteredCount++ - updateMemberGroupNickname.run(tracker.currentName, platformId) - continue - } - - const sortedHistory = Array.from(uniqueNames.entries()).sort((a, b) => a[1].startTs - b[1].startTs) - for (let i = 0; i < sortedHistory.length; i++) { - const [name, { startTs }] = sortedHistory[i] - const endTs = i < sortedHistory.length - 1 ? sortedHistory[i + 1][1].startTs : null - insertNameHistory.run(senderId, 'group_nickname', name, startTs, endTs) - historyCount++ - } - - updateMemberGroupNickname.run(tracker.currentName, platformId) - } - - db.exec('COMMIT') - logPerf(`昵称历史写入完成 (${historyCount}条)`, totalMessageCount) - - // 创建索引(导入完成后批量创建,比边导入边更新快很多) - sendProgress(requestId, { - stage: 'importing', - bytesRead: 0, - totalBytes: 0, - messagesProcessed: totalMessageCount, - percentage: 100, - message: '', // Frontend translates based on stage - }) - logPerf('开始创建索引', totalMessageCount) - createIndexes(db) - logPerf('索引创建完成', totalMessageCount) - - // 最终 WAL checkpoint - sendProgress(requestId, { - stage: 'importing', - bytesRead: 0, - totalBytes: 0, - messagesProcessed: totalMessageCount, - percentage: 100, - message: '', // Frontend translates based on stage - }) - doCheckpoint() - logPerf('WAL checkpoint 完成', totalMessageCount) - logPerf('导入完成', totalMessageCount) - - // 写入日志摘要 - logSummary(totalMessageCount, memberIdMap.size) - - // 检查消息数量,如果为 0 则视为导入失败 - if (totalMessageCount === 0) { - logError('导入失败:未解析到任何消息,可能是文件格式不匹配或内容为空') - // 标记需要删除数据库文件(将在 finally 中执行,确保数据库已关闭) - shouldDeleteDb = true - importError = 'error.no_messages' - } - } catch (error) { - // 记录错误日志 - logError('导入失败', error instanceof Error ? error : undefined) - - // 回滚当前事务 - if (inTransaction) { - try { - db.exec('ROLLBACK') - } catch { - // 忽略回滚错误 - } - } - - // 标记需要删除数据库文件(将在 finally 中执行,确保数据库已关闭) - shouldDeleteDb = true - importError = error instanceof Error ? error.message : String(error) - } finally { - // 先关闭数据库连接(释放文件锁) - db.close() - - // 清理临时文件 - if (tempFilePath && preprocessor) { - preprocessor.cleanup(tempFilePath) - } - - // 删除失败的数据库文件(在数据库关闭后执行,避免 Windows 上的 EBUSY 错误) - if (shouldDeleteDb) { - const dbPath = getDbPath(sessionId) - try { - if (fs.existsSync(dbPath)) { - fs.unlinkSync(dbPath) - } - // 同时清理 WAL 和 SHM 文件 - const walPath = dbPath + '-wal' - const shmPath = dbPath + '-shm' - if (fs.existsSync(walPath)) { - fs.unlinkSync(walPath) - } - if (fs.existsSync(shmPath)) { - fs.unlinkSync(shmPath) - } - } catch (cleanupError) { - logError('清理失败的数据库文件时出错', cleanupError instanceof Error ? cleanupError : undefined) - } - } - } - - // 返回结果(移到 try-catch-finally 之外) - if (importError) { - return { success: false, error: importError } - } - return { success: true, sessionId } -} - -/** 流式解析文件信息的返回结果 */ -export interface StreamParseFileInfoResult { - // 基本信息(用于预览) - name: string - format: string - platform: string - messageCount: number - memberCount: number - fileSize: number - // 临时数据库路径(用于后续合并,避免内存溢出) - tempDbPath: string -} - -/** - * 流式解析文件,写入临时数据库 - * 用于合并功能:解析结果存入临时 SQLite,避免内存溢出 - */ -export async function streamParseFileInfo(filePath: string, requestId: string): Promise { - const formatFeature = detectFormat(filePath) - if (!formatFeature) { - throw new Error('无法识别文件格式') - } - - // 获取文件大小 - const fileSize = fs.statSync(filePath).size - - // 立即发送初始进度,让用户知道已开始处理 - sendProgress(requestId, { - stage: 'parsing', - bytesRead: 0, - totalBytes: fileSize, - messagesProcessed: 0, - percentage: 0, - message: '', // Frontend translates based on stage - }) - - // 创建临时数据库 - const tempDbPath = generateTempDbPath(filePath) - const db = createTempDatabase(tempDbPath) - - // 准备语句 - const insertMeta = db.prepare( - 'INSERT INTO meta (name, platform, type, group_id, group_avatar, owner_id) VALUES (?, ?, ?, ?, ?, ?)' - ) - const insertMember = db.prepare( - 'INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)' - ) - const insertMessage = db.prepare(` - INSERT INTO message (sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content) - VALUES (?, ?, ?, ?, ?, ?) - `) - - let meta: ParsedMeta = { name: '未知群聊', platform: formatFeature.platform, type: 'group' } - const memberSet = new Set() - let messageCount = 0 - let metaInserted = false - - // 开始事务 - db.exec('BEGIN TRANSACTION') - - try { - await streamParseFile(filePath, { - // 对于大文件使用更小的批次,以更频繁地更新进度 - batchSize: fileSize > 100 * 1024 * 1024 ? 2000 : 5000, - - onProgress: (progress) => { - sendProgress(requestId, progress) - }, - - onMeta: (parsedMeta) => { - meta = parsedMeta - if (!metaInserted) { - insertMeta.run( - parsedMeta.name, - parsedMeta.platform, - parsedMeta.type, - parsedMeta.groupId || null, - parsedMeta.groupAvatar || null, - parsedMeta.ownerId || null - ) - metaInserted = true - } - }, - - onMembers: (parsedMembers) => { - for (const m of parsedMembers) { - if (!memberSet.has(m.platformId)) { - memberSet.add(m.platformId) - insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null, m.avatar || null) - } - } - }, - - onMessageBatch: (batch) => { - for (const msg of batch) { - // 确保成员存在 - if (!memberSet.has(msg.senderPlatformId)) { - memberSet.add(msg.senderPlatformId) - // 消息中没有头像信息,设为 null - insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null) - } - - insertMessage.run( - msg.senderPlatformId, - msg.senderAccountName || null, - msg.senderGroupNickname || null, - msg.timestamp, - msg.type, - msg.content || null - ) - messageCount++ - } - }, - }) - - // 提交事务 - db.exec('COMMIT') - db.close() - - return { - name: meta.name, - format: formatFeature.name, - platform: meta.platform, - messageCount, - memberCount: memberSet.size, - fileSize, - tempDbPath, - } - } catch (error) { - // 回滚并清理 - try { - db.exec('ROLLBACK') - } catch { - // 忽略回滚错误 - } - db.close() - - // 删除失败的临时数据库 - if (fs.existsSync(tempDbPath)) { - fs.unlinkSync(tempDbPath) - } - - throw error - } -} diff --git a/electron/main/worker/query/advanced/activity.ts b/electron/main/worker/query/advanced/activity.ts deleted file mode 100644 index e25019ac6..000000000 --- a/electron/main/worker/query/advanced/activity.ts +++ /dev/null @@ -1,616 +0,0 @@ -/** - * 活跃度分析模块 - * 包含:夜猫分析、龙王分析、潜水分析、打卡分析 - */ - -import { openDatabase, buildTimeFilter, buildSystemMessageFilter, type TimeFilter } from '../../core' - -// ==================== 夜猫分析 ==================== - -/** - * 根据深夜发言数获取称号 - */ -function getNightOwlTitleByCount(count: number): string { - if (count === 0) return '养生达人' - if (count <= 20) return '偶尔失眠' - if (count <= 50) return '经常失眠' - if (count <= 100) return '夜猫子' - if (count <= 200) return '秃头预备役' - if (count <= 500) return '修仙练习生' - return '守夜冠军' -} - -/** - * 将时间戳转换为"调整后的日期"(以凌晨5点为界) - */ -function getAdjustedDate(ts: number): string { - const date = new Date(ts * 1000) - const hour = date.getHours() - - if (hour < 5) { - date.setDate(date.getDate() - 1) - } - - return date.toISOString().split('T')[0] -} - -/** - * 格式化分钟数为 HH:MM - */ -function formatMinutes(minutes: number): string { - const h = Math.floor(minutes / 60) - const m = Math.round(minutes % 60) - return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}` -} - -/** - * 获取夜猫分析数据 - */ -export function getNightOwlAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { - nightOwlRank: [], - lastSpeakerRank: [], - firstSpeakerRank: [], - consecutiveRecords: [], - champions: [], - totalDays: 0, - } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const messages = db - .prepare( - ` - SELECT - msg.id, - msg.sender_id as senderId, - msg.ts, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - ORDER BY msg.ts ASC - ` - ) - .all(...params) as Array<{ - id: number - senderId: number - ts: number - platformId: string - name: string - }> - - if (messages.length === 0) return emptyResult - - const memberInfo = new Map() - const nightStats = new Map< - number, - { - total: number - h23: number - h0: number - h1: number - h2: number - h3to4: number - totalMessages: number - } - >() - const dailyMessages = new Map>() - const memberNightDays = new Map>() - - for (const msg of messages) { - if (!memberInfo.has(msg.senderId)) { - memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) - } - - const date = new Date(msg.ts * 1000) - const hour = date.getHours() - const minute = date.getMinutes() - const adjustedDate = getAdjustedDate(msg.ts) - - if (!nightStats.has(msg.senderId)) { - nightStats.set(msg.senderId, { total: 0, h23: 0, h0: 0, h1: 0, h2: 0, h3to4: 0, totalMessages: 0 }) - } - const stats = nightStats.get(msg.senderId)! - stats.totalMessages++ - - if (hour === 23) { - stats.h23++ - stats.total++ - } else if (hour === 0) { - stats.h0++ - stats.total++ - } else if (hour === 1) { - stats.h1++ - stats.total++ - } else if (hour === 2) { - stats.h2++ - stats.total++ - } else if (hour >= 3 && hour < 5) { - stats.h3to4++ - stats.total++ - } - - if (hour >= 23 || hour < 5) { - if (!memberNightDays.has(msg.senderId)) { - memberNightDays.set(msg.senderId, new Set()) - } - memberNightDays.get(msg.senderId)!.add(adjustedDate) - } - - if (!dailyMessages.has(adjustedDate)) { - dailyMessages.set(adjustedDate, []) - } - dailyMessages.get(adjustedDate)!.push({ senderId: msg.senderId, ts: msg.ts, hour, minute }) - } - - const totalDays = dailyMessages.size - - // 构建修仙排行榜 - const nightOwlRank: any[] = [] - for (const [memberId, stats] of nightStats.entries()) { - if (stats.total === 0) continue - const info = memberInfo.get(memberId)! - nightOwlRank.push({ - memberId, - platformId: info.platformId, - name: info.name, - totalNightMessages: stats.total, - title: getNightOwlTitleByCount(stats.total), - hourlyBreakdown: { - h23: stats.h23, - h0: stats.h0, - h1: stats.h1, - h2: stats.h2, - h3to4: stats.h3to4, - }, - percentage: stats.totalMessages > 0 ? Math.round((stats.total / stats.totalMessages) * 10000) / 100 : 0, - }) - } - nightOwlRank.sort((a, b) => b.totalNightMessages - a.totalNightMessages) - - // 最晚/最早发言 - const lastSpeakerStats = new Map() - const firstSpeakerStats = new Map() - - for (const [, dayMessages] of dailyMessages.entries()) { - if (dayMessages.length === 0) continue - - const lastMsg = dayMessages[dayMessages.length - 1] - if (!lastSpeakerStats.has(lastMsg.senderId)) { - lastSpeakerStats.set(lastMsg.senderId, { count: 0, times: [] }) - } - const lastStats = lastSpeakerStats.get(lastMsg.senderId)! - lastStats.count++ - lastStats.times.push(lastMsg.hour * 60 + lastMsg.minute) - - const firstMsg = dayMessages[0] - if (!firstSpeakerStats.has(firstMsg.senderId)) { - firstSpeakerStats.set(firstMsg.senderId, { count: 0, times: [] }) - } - const firstStats = firstSpeakerStats.get(firstMsg.senderId)! - firstStats.count++ - firstStats.times.push(firstMsg.hour * 60 + firstMsg.minute) - } - - // 构建排行 - const lastSpeakerRank: any[] = [] - for (const [memberId, stats] of lastSpeakerStats.entries()) { - const info = memberInfo.get(memberId)! - const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length - const maxMinutes = Math.max(...stats.times) - lastSpeakerRank.push({ - memberId, - platformId: info.platformId, - name: info.name, - count: stats.count, - avgTime: formatMinutes(avgMinutes), - extremeTime: formatMinutes(maxMinutes), - percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0, - }) - } - lastSpeakerRank.sort((a, b) => b.count - a.count) - - const firstSpeakerRank: any[] = [] - for (const [memberId, stats] of firstSpeakerStats.entries()) { - const info = memberInfo.get(memberId)! - const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length - const minMinutes = Math.min(...stats.times) - firstSpeakerRank.push({ - memberId, - platformId: info.platformId, - name: info.name, - count: stats.count, - avgTime: formatMinutes(avgMinutes), - extremeTime: formatMinutes(minMinutes), - percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0, - }) - } - firstSpeakerRank.sort((a, b) => b.count - a.count) - - // 连续修仙天数 - const consecutiveRecords: any[] = [] - - for (const [memberId, nightDaysSet] of memberNightDays.entries()) { - if (nightDaysSet.size === 0) continue - - const info = memberInfo.get(memberId)! - const sortedDays = Array.from(nightDaysSet).sort() - - let maxStreak = 1 - let currentStreak = 1 - - for (let i = 1; i < sortedDays.length; i++) { - const prevDate = new Date(sortedDays[i - 1]) - const currDate = new Date(sortedDays[i]) - const diffDays = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) - - if (diffDays === 1) { - currentStreak++ - maxStreak = Math.max(maxStreak, currentStreak) - } else { - currentStreak = 1 - } - } - - const lastDay = sortedDays[sortedDays.length - 1] - const today = new Date().toISOString().split('T')[0] - const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0] - const isCurrentStreak = lastDay === today || lastDay === yesterday - - consecutiveRecords.push({ - memberId, - platformId: info.platformId, - name: info.name, - maxConsecutiveDays: maxStreak, - currentStreak: isCurrentStreak ? currentStreak : 0, - }) - } - consecutiveRecords.sort((a, b) => b.maxConsecutiveDays - a.maxConsecutiveDays) - - // 综合排名 - const championScores = new Map() - - for (const item of nightOwlRank) { - if (!championScores.has(item.memberId)) { - championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) - } - championScores.get(item.memberId)!.nightMessages = item.totalNightMessages - } - - for (const item of lastSpeakerRank) { - if (!championScores.has(item.memberId)) { - championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) - } - championScores.get(item.memberId)!.lastSpeakerCount = item.count - } - - for (const item of consecutiveRecords) { - if (!championScores.has(item.memberId)) { - championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) - } - championScores.get(item.memberId)!.consecutiveDays = item.maxConsecutiveDays - } - - const champions: any[] = [] - for (const [memberId, scores] of championScores.entries()) { - const info = memberInfo.get(memberId)! - const score = scores.nightMessages * 1 + scores.lastSpeakerCount * 10 + scores.consecutiveDays * 20 - if (score > 0) { - champions.push({ - memberId, - platformId: info.platformId, - name: info.name, - score, - nightMessages: scores.nightMessages, - lastSpeakerCount: scores.lastSpeakerCount, - consecutiveDays: scores.consecutiveDays, - }) - } - } - champions.sort((a, b) => b.score - a.score) - - return { - nightOwlRank, - lastSpeakerRank, - firstSpeakerRank, - consecutiveRecords, - champions, - totalDays, - } -} - -// ==================== 龙王分析 ==================== - -/** - * 获取龙王排名 - */ -export function getDragonKingAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { rank: [], totalDays: 0 } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const dailyTopSpeakers = db - .prepare( - ` - WITH daily_counts AS ( - SELECT - strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date, - msg.sender_id, - m.platform_id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - COUNT(*) as msg_count - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY date, msg.sender_id - ), - daily_max AS ( - SELECT date, MAX(msg_count) as max_count - FROM daily_counts - GROUP BY date - ) - SELECT dc.sender_id, dc.platform_id, dc.name, COUNT(*) as dragon_days - FROM daily_counts dc - JOIN daily_max dm ON dc.date = dm.date AND dc.msg_count = dm.max_count - GROUP BY dc.sender_id - ORDER BY dragon_days DESC - ` - ) - .all(...params) as Array<{ - sender_id: number - platform_id: string - name: string - dragon_days: number - }> - - const totalDaysRow = db - .prepare( - ` - SELECT COUNT(DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime')) as total - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - ` - ) - .get(...params) as { total: number } - - const totalDays = totalDaysRow.total - - const rank = dailyTopSpeakers.map((item) => ({ - memberId: item.sender_id, - platformId: item.platform_id, - name: item.name, - count: item.dragon_days, - percentage: totalDays > 0 ? Math.round((item.dragon_days / totalDays) * 10000) / 100 : 0, - })) - - return { rank, totalDays } -} - -// ==================== 潜水分析 ==================== - -/** - * 获取潜水排名 - */ -export function getDivingAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { rank: [] } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const lastMessages = db - .prepare( - ` - SELECT - m.id as member_id, - m.platform_id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - MAX(msg.ts) as last_ts - FROM member m - JOIN message msg ON m.id = msg.sender_id - ${clauseWithSystem.replace('msg.', 'msg.')} - GROUP BY m.id - ORDER BY last_ts ASC - ` - ) - .all(...params) as Array<{ - member_id: number - platform_id: string - name: string - last_ts: number - }> - - const now = Math.floor(Date.now() / 1000) - - const rank = lastMessages.map((item) => ({ - memberId: item.member_id, - platformId: item.platform_id, - name: item.name, - lastMessageTs: item.last_ts, - daysSinceLastMessage: Math.floor((now - item.last_ts) / 86400), - })) - - return { rank } -} - -// ==================== 打卡分析 ==================== - -/** - * 获取打卡分析数据(火花榜 + 忠臣榜) - */ -export function getCheckInAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { - streakRank: [], - loyaltyRank: [], - totalDays: 0, - } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - const whereClause = buildSystemMessageFilter(clause) - - // 1. 获取每个成员每天是否发言的数据 - // 检查时间戳格式:如果 ts > 1e12 则是毫秒,否则是秒 - const sampleTs = db.prepare(`SELECT ts FROM message LIMIT 1`).get() as { ts: number } | undefined - const tsIsMillis = sampleTs?.ts && sampleTs.ts > 1e12 - const tsExpr = tsIsMillis ? 'msg.ts / 1000' : 'msg.ts' - - const dailyActivity = db - .prepare( - ` - SELECT - msg.sender_id as senderId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - DATE(${tsExpr}, 'unixepoch', 'localtime') as day - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - GROUP BY msg.sender_id, day - ORDER BY msg.sender_id, day - ` - ) - .all(...params) as Array<{ - senderId: number - name: string - day: string - }> - - if (dailyActivity.length === 0) return emptyResult - - // 2. 获取群聊总天数 - const allDays = new Set(dailyActivity.map((r) => r.day)) - const totalDays = allDays.size - - // 获取最后一天(用于判断当前连续) - const sortedDays = Array.from(allDays).sort() - const lastDay = sortedDays[sortedDays.length - 1] - - // 3. 按成员分组 - const memberDays = new Map }>() - for (const record of dailyActivity) { - if (!memberDays.has(record.senderId)) { - memberDays.set(record.senderId, { name: record.name, days: new Set() }) - } - memberDays.get(record.senderId)!.days.add(record.day) - } - - // 4. 计算每个成员的连续发言和累计发言 - const streakData: Array<{ - memberId: number - name: string - maxStreak: number - maxStreakStart: string - maxStreakEnd: string - currentStreak: number - }> = [] - - const loyaltyData: Array<{ - memberId: number - name: string - totalDays: number - }> = [] - - for (const [memberId, data] of memberDays) { - const sortedMemberDays = Array.from(data.days).sort() - const totalMemberDays = sortedMemberDays.length - - // 计算最长连续 - let maxStreak = 1 - let maxStreakStart = sortedMemberDays[0] - let maxStreakEnd = sortedMemberDays[0] - - let currentStreakCount = 1 - let currentStreakStart = sortedMemberDays[0] - - for (let i = 1; i < sortedMemberDays.length; i++) { - const prevDate = new Date(sortedMemberDays[i - 1]) - const currDate = new Date(sortedMemberDays[i]) - const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)) - - if (diffDays === 1) { - // 连续 - currentStreakCount++ - } else { - // 中断,检查是否更新最大值 - if (currentStreakCount > maxStreak) { - maxStreak = currentStreakCount - maxStreakStart = currentStreakStart - maxStreakEnd = sortedMemberDays[i - 1] - } - currentStreakCount = 1 - currentStreakStart = sortedMemberDays[i] - } - } - - // 检查最后一段连续 - if (currentStreakCount > maxStreak) { - maxStreak = currentStreakCount - maxStreakStart = currentStreakStart - maxStreakEnd = sortedMemberDays[sortedMemberDays.length - 1] - } - - // 计算当前连续(是否以最后一天结束) - let finalCurrentStreak = 0 - if (sortedMemberDays[sortedMemberDays.length - 1] === lastDay) { - // 从最后一天往前数 - finalCurrentStreak = 1 - for (let i = sortedMemberDays.length - 2; i >= 0; i--) { - const currDate = new Date(sortedMemberDays[i + 1]) - const prevDate = new Date(sortedMemberDays[i]) - const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)) - if (diffDays === 1) { - finalCurrentStreak++ - } else { - break - } - } - } - - streakData.push({ - memberId, - name: data.name, - maxStreak, - maxStreakStart, - maxStreakEnd, - currentStreak: finalCurrentStreak, - }) - - loyaltyData.push({ - memberId, - name: data.name, - totalDays: totalMemberDays, - }) - } - - // 5. 排序 - const streakRank = streakData.sort((a, b) => b.maxStreak - a.maxStreak) - - const sortedLoyalty = loyaltyData.sort((a, b) => b.totalDays - a.totalDays) - const maxLoyaltyDays = sortedLoyalty.length > 0 ? sortedLoyalty[0].totalDays : 1 - const loyaltyRank = sortedLoyalty.map((item) => ({ - ...item, - percentage: Math.round((item.totalDays / maxLoyaltyDays) * 100), - })) - - return { - streakRank, - loyaltyRank, - totalDays, - } -} - diff --git a/electron/main/worker/query/advanced/behavior.ts b/electron/main/worker/query/advanced/behavior.ts deleted file mode 100644 index d35b310b4..000000000 --- a/electron/main/worker/query/advanced/behavior.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * 行为分析模块 - * 包含:自言自语分析、斗图分析 - */ - -import { openDatabase, buildTimeFilter, type TimeFilter } from '../../core' - -// ==================== 自言自语分析 ==================== - -/** - * 获取自言自语分析 - */ -export function getMonologueAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { rank: [], maxComboRecord: null } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0" - } else { - whereClause = " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0" - } - - const messages = db - .prepare( - ` - SELECT - msg.id, - msg.sender_id as senderId, - msg.ts, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - ORDER BY msg.ts ASC - ` - ) - .all(...params) as Array<{ - id: number - senderId: number - ts: number - platformId: string - name: string - }> - - if (messages.length === 0) return emptyResult - - const memberInfo = new Map() - const memberStats = new Map< - number, - { - totalStreaks: number - maxCombo: number - lowStreak: number - midStreak: number - highStreak: number - } - >() - - let globalMaxCombo: { memberId: number; comboLength: number; startTs: number } | null = null - const MAX_INTERVAL = 300 - - let currentStreak = { - senderId: -1, - count: 0, - startTs: 0, - lastTs: 0, - } - - const finishStreak = () => { - if (currentStreak.count >= 3) { - const memberId = currentStreak.senderId - - if (!memberStats.has(memberId)) { - memberStats.set(memberId, { - totalStreaks: 0, - maxCombo: 0, - lowStreak: 0, - midStreak: 0, - highStreak: 0, - }) - } - - const stats = memberStats.get(memberId)! - stats.totalStreaks++ - stats.maxCombo = Math.max(stats.maxCombo, currentStreak.count) - - if (currentStreak.count >= 10) { - stats.highStreak++ - } else if (currentStreak.count >= 5) { - stats.midStreak++ - } else { - stats.lowStreak++ - } - - if (!globalMaxCombo || currentStreak.count > globalMaxCombo.comboLength) { - globalMaxCombo = { - memberId, - comboLength: currentStreak.count, - startTs: currentStreak.startTs, - } - } - } - } - - for (const msg of messages) { - if (!memberInfo.has(msg.senderId)) { - memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) - } - - const isSameSender = msg.senderId === currentStreak.senderId - const isWithinInterval = msg.ts - currentStreak.lastTs <= MAX_INTERVAL - - if (isSameSender && isWithinInterval) { - currentStreak.count++ - currentStreak.lastTs = msg.ts - } else { - finishStreak() - currentStreak = { - senderId: msg.senderId, - count: 1, - startTs: msg.ts, - lastTs: msg.ts, - } - } - } - - finishStreak() - - const rank: any[] = [] - for (const [memberId, stats] of memberStats.entries()) { - const info = memberInfo.get(memberId)! - rank.push({ - memberId, - platformId: info.platformId, - name: info.name, - totalStreaks: stats.totalStreaks, - maxCombo: stats.maxCombo, - lowStreak: stats.lowStreak, - midStreak: stats.midStreak, - highStreak: stats.highStreak, - }) - } - rank.sort((a, b) => b.totalStreaks - a.totalStreaks) - - let maxComboRecord: any = null - if (globalMaxCombo) { - const info = memberInfo.get(globalMaxCombo.memberId)! - maxComboRecord = { - memberId: globalMaxCombo.memberId, - platformId: info.platformId, - memberName: info.name, - comboLength: globalMaxCombo.comboLength, - startTs: globalMaxCombo.startTs, - } - } - - return { rank, maxComboRecord } -} - -// ==================== 斗图分析 ==================== - -/** - * 获取斗图分析数据 - * 斗图定义:至少2人参与,总共发了3张图(图片或表情),中间无文本打断 - */ -export function getMemeBattleAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { - topBattles: [], - rankByCount: [], - rankByImageCount: [], - totalBattles: 0, - } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - - // 排除系统消息 (type=6) - // 斗图只看图片(1)和表情(5),其他类型(如文本0, 语音2等)视为打断 - // 我们查询所有非系统消息,在内存中遍历判断 - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += ' AND msg.type != 6' - } else { - whereClause = ' WHERE msg.type != 6' - } - - const messages = db - .prepare( - ` - SELECT - msg.sender_id as senderId, - msg.type, - msg.ts, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - ORDER BY msg.ts ASC - ` - ) - .all(...params) as Array<{ - senderId: number - type: number - ts: number - platformId: string - name: string - }> - - const battles: Array<{ - startTime: number - endTime: number - msgs: Array<{ senderId: number; name: string; platformId: string }> - }> = [] - - let currentChain: Array<{ senderId: number; name: string; platformId: string; ts: number }> = [] - - // 辅助函数:处理当前链 - const processChain = () => { - if (currentChain.length >= 3) { - const senders = new Set(currentChain.map((m) => m.senderId)) - if (senders.size >= 2) { - // 满足条件:至少3张图,至少2人 - battles.push({ - startTime: currentChain[0].ts, - endTime: currentChain[currentChain.length - 1].ts, - msgs: currentChain.map(({ senderId, name, platformId }) => ({ senderId, name, platformId })), - }) - } - } - currentChain = [] - } - - for (const msg of messages) { - // 1=图片, 5=表情 - if (msg.type === 1 || msg.type === 5) { - currentChain.push({ - senderId: msg.senderId, - name: msg.name, - platformId: msg.platformId, - ts: msg.ts, - }) - } else { - // 其他类型消息(文本、语音等)打断斗图 - processChain() - } - } - // 处理最后一条链 - processChain() - - if (battles.length === 0) return emptyResult - - // 1. 史诗级斗图榜(前30) - const topBattles = battles - .map((battle) => ({ - startTime: battle.startTime, - endTime: battle.endTime, - totalImages: battle.msgs.length, - participantCount: new Set(battle.msgs.map((m) => m.senderId)).size, - participants: Object.values( - battle.msgs.reduce( - (acc, curr) => { - if (!acc[curr.senderId]) { - acc[curr.senderId] = { memberId: curr.senderId, name: curr.name, imageCount: 0 } - } - acc[curr.senderId].imageCount++ - return acc - }, - {} as Record - ) - ).sort((a, b) => b.imageCount - a.imageCount), - })) - .sort((a, b) => b.totalImages - a.totalImages) - .slice(0, 30) - - // 2. 统计达人榜 - const memberStats = new Map< - number, - { - memberId: number - platformId: string - name: string - battleCount: number // 参与场次 - imageCount: number // 发图总数 - } - >() - - for (const battle of battles) { - const participantsInBattle = new Set() - - for (const msg of battle.msgs) { - if (!memberStats.has(msg.senderId)) { - memberStats.set(msg.senderId, { - memberId: msg.senderId, - platformId: msg.platformId, - name: msg.name, - battleCount: 0, - imageCount: 0, - }) - } - const stats = memberStats.get(msg.senderId)! - stats.imageCount++ - participantsInBattle.add(msg.senderId) - } - - // 参与场次+1 - for (const memberId of participantsInBattle) { - const stats = memberStats.get(memberId)! - stats.battleCount++ - } - } - - const allStats = Array.from(memberStats.values()) - - // 按参与场次排名 - const rankByCount = [...allStats] - .sort((a, b) => b.battleCount - a.battleCount) - .map((item) => ({ - memberId: item.memberId, - platformId: item.platformId, - name: item.name, - count: item.battleCount, - percentage: battles.length > 0 ? Math.round((item.battleCount / battles.length) * 10000) / 100 : 0, - })) - - // 按图片总数排名 - const totalBattleImages = battles.reduce((sum, b) => sum + b.msgs.length, 0) - const rankByImageCount = [...allStats] - .sort((a, b) => b.imageCount - a.imageCount) - .map((item) => ({ - memberId: item.memberId, - platformId: item.platformId, - name: item.name, - count: item.imageCount, - percentage: totalBattleImages > 0 ? Math.round((item.imageCount / totalBattleImages) * 10000) / 100 : 0, - })) - - return { - topBattles, - rankByCount, - rankByImageCount, - totalBattles: battles.length, - } -} - diff --git a/electron/main/worker/query/advanced/index.ts b/electron/main/worker/query/advanced/index.ts deleted file mode 100644 index 911e9d0e4..000000000 --- a/electron/main/worker/query/advanced/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * 高级分析模块入口 - * 统一导出所有分析函数 - */ - -// 复读 + 口头禅分析 -export { getRepeatAnalysis, getCatchphraseAnalysis } from './repeat' - -// 活跃度分析:夜猫、龙王、潜水、打卡 -export { getNightOwlAnalysis, getDragonKingAnalysis, getDivingAnalysis, getCheckInAnalysis } from './activity' - -// 行为分析:自言自语、斗图 -export { getMonologueAnalysis, getMemeBattleAnalysis } from './behavior' - -// 社交分析:@ 互动、含笑量 -export { getMentionAnalysis, getLaughAnalysis } from './social' - diff --git a/electron/main/worker/query/advanced/repeat.ts b/electron/main/worker/query/advanced/repeat.ts deleted file mode 100644 index c891b436c..000000000 --- a/electron/main/worker/query/advanced/repeat.ts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * 复读分析 + 口头禅分析模块 - */ - -import { openDatabase, buildTimeFilter, type TimeFilter } from '../../core' - -// ==================== 复读分析 ==================== - -/** - * 获取复读分析数据 - */ -export function getRepeatAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { - originators: [], - initiators: [], - breakers: [], - originatorRates: [], - initiatorRates: [], - breakerRates: [], - chainLengthDistribution: [], - hotContents: [], - avgChainLength: 0, - totalRepeatChains: 0, - } - - if (!db) return emptyResult - - const { clause, params } = buildTimeFilter(filter) - - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += - " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND TRIM(msg.content) != ''" - } else { - whereClause = - " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND TRIM(msg.content) != ''" - } - - const messages = db - .prepare( - ` - SELECT - msg.id, - msg.sender_id as senderId, - msg.content, - msg.ts, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - ORDER BY msg.ts ASC, msg.id ASC - ` - ) - .all(...params) as Array<{ - id: number - senderId: number - content: string - ts: number - platformId: string - name: string - }> - - const originatorCount = new Map() - const initiatorCount = new Map() - const breakerCount = new Map() - const memberMessageCount = new Map() - const memberInfo = new Map() - const chainLengthCount = new Map() - const contentStats = new Map< - string, - { count: number; maxChainLength: number; originatorId: number; lastTs: number; firstMessageId: number } - >() - - let currentContent: string | null = null - let repeatChain: Array<{ id: number; senderId: number; content: string; ts: number }> = [] - let totalRepeatChains = 0 - let totalChainLength = 0 - - const fastestRepeaterStats = new Map() - - const processRepeatChain = (chain: Array<{ id: number; senderId: number; content: string; ts: number }>, breakerId?: number) => { - if (chain.length < 3) return - - totalRepeatChains++ - const chainLength = chain.length - totalChainLength += chainLength - - const originatorId = chain[0].senderId - originatorCount.set(originatorId, (originatorCount.get(originatorId) || 0) + 1) - - const initiatorId = chain[1].senderId - initiatorCount.set(initiatorId, (initiatorCount.get(initiatorId) || 0) + 1) - - if (breakerId !== undefined) { - breakerCount.set(breakerId, (breakerCount.get(breakerId) || 0) + 1) - } - - chainLengthCount.set(chainLength, (chainLengthCount.get(chainLength) || 0) + 1) - - const content = chain[0].content - const chainTs = chain[0].ts - const firstMsgId = chain[0].id - const existing = contentStats.get(content) - if (existing) { - existing.count++ - existing.lastTs = Math.max(existing.lastTs, chainTs) - if (chainLength > existing.maxChainLength) { - existing.maxChainLength = chainLength - existing.originatorId = originatorId - existing.firstMessageId = firstMsgId - } - } else { - contentStats.set(content, { count: 1, maxChainLength: chainLength, originatorId, lastTs: chainTs, firstMessageId: firstMsgId }) - } - - // 计算反应时间 (Fastest Follower) - // 从第二个消息开始,计算与前一条消息的时间差 - for (let i = 1; i < chain.length; i++) { - const currentMsg = chain[i] - const prevMsg = chain[i - 1] - const diff = (currentMsg.ts - prevMsg.ts) * 1000 // 毫秒 - - // 只统计 20 秒内的复读,排除间隔过久的"伪复读" - if (diff <= 20 * 1000) { - if (!fastestRepeaterStats.has(currentMsg.senderId)) { - fastestRepeaterStats.set(currentMsg.senderId, { totalDiff: 0, count: 0 }) - } - const stats = fastestRepeaterStats.get(currentMsg.senderId)! - stats.totalDiff += diff - stats.count++ - } - } - } - - for (const msg of messages) { - if (!memberInfo.has(msg.senderId)) { - memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) - } - - memberMessageCount.set(msg.senderId, (memberMessageCount.get(msg.senderId) || 0) + 1) - - const content = msg.content.trim() - - if (content === currentContent) { - const lastSender = repeatChain[repeatChain.length - 1]?.senderId - if (lastSender !== msg.senderId) { - repeatChain.push({ id: msg.id, senderId: msg.senderId, content, ts: msg.ts }) - } - } else { - processRepeatChain(repeatChain, msg.senderId) - - currentContent = content - repeatChain = [{ id: msg.id, senderId: msg.senderId, content, ts: msg.ts }] - } - } - - processRepeatChain(repeatChain) - - const buildRankList = (countMap: Map, total: number): any[] => { - const items: any[] = [] - for (const [memberId, count] of countMap.entries()) { - const info = memberInfo.get(memberId) - if (info) { - items.push({ - memberId, - platformId: info.platformId, - name: info.name, - count, - percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0, - }) - } - } - return items.sort((a, b) => b.count - a.count) - } - - const buildRateList = (countMap: Map): any[] => { - const items: any[] = [] - for (const [memberId, count] of countMap.entries()) { - const info = memberInfo.get(memberId) - const totalMessages = memberMessageCount.get(memberId) || 0 - if (info && totalMessages > 0) { - items.push({ - memberId, - platformId: info.platformId, - name: info.name, - count, - totalMessages, - rate: Math.round((count / totalMessages) * 10000) / 100, - }) - } - } - return items.sort((a, b) => b.rate - a.rate) - } - - const buildFastestList = (): any[] => { - const items: any[] = [] - for (const [memberId, stats] of fastestRepeaterStats.entries()) { - // 过滤掉偶尔复读的人,至少参与5次复读才统计,避免数据偏差 - if (stats.count < 5) continue - - const info = memberInfo.get(memberId) - if (info) { - items.push({ - memberId, - platformId: info.platformId, - name: info.name, - count: stats.count, - avgTimeDiff: Math.round(stats.totalDiff / stats.count), - }) - } - } - return items.sort((a, b) => a.avgTimeDiff - b.avgTimeDiff) // 越快越好 - } - - const chainLengthDistribution: any[] = [] - for (const [length, count] of chainLengthCount.entries()) { - chainLengthDistribution.push({ length, count }) - } - chainLengthDistribution.sort((a, b) => a.length - b.length) - - const hotContents: any[] = [] - for (const [content, stats] of contentStats.entries()) { - const originatorInfo = memberInfo.get(stats.originatorId) - hotContents.push({ - content, - count: stats.count, - maxChainLength: stats.maxChainLength, - originatorName: originatorInfo?.name || '未知', - lastTs: stats.lastTs, - firstMessageId: stats.firstMessageId, - }) - } - hotContents.sort((a, b) => b.maxChainLength - a.maxChainLength) - const top50HotContents = hotContents.slice(0, 100) - - return { - originators: buildRankList(originatorCount, totalRepeatChains), - initiators: buildRankList(initiatorCount, totalRepeatChains), - breakers: buildRankList(breakerCount, totalRepeatChains), - fastestRepeaters: buildFastestList(), - originatorRates: buildRateList(originatorCount), - initiatorRates: buildRateList(initiatorCount), - breakerRates: buildRateList(breakerCount), - chainLengthDistribution, - hotContents: top50HotContents, - avgChainLength: totalRepeatChains > 0 ? Math.round((totalChainLength / totalRepeatChains) * 100) / 100 : 0, - totalRepeatChains, - } -} - -// ==================== 口头禅分析 ==================== - -/** - * 获取口头禅分析数据 - */ -export function getCatchphraseAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - if (!db) return { members: [] } - - const { clause, params } = buildTimeFilter(filter) - - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += - " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2" - } else { - whereClause = - " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2" - } - - const rows = db - .prepare( - ` - SELECT - m.id as memberId, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - TRIM(msg.content) as content, - COUNT(*) as count - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - GROUP BY m.id, TRIM(msg.content) - ORDER BY m.id, count DESC - ` - ) - .all(...params) as Array<{ - memberId: number - platformId: string - name: string - content: string - count: number - }> - - const memberMap = new Map< - number, - { - memberId: number - platformId: string - name: string - catchphrases: Array<{ content: string; count: number }> - } - >() - - for (const row of rows) { - if (!memberMap.has(row.memberId)) { - memberMap.set(row.memberId, { - memberId: row.memberId, - platformId: row.platformId, - name: row.name, - catchphrases: [], - }) - } - - const member = memberMap.get(row.memberId)! - if (member.catchphrases.length < 10) { - member.catchphrases.push({ - content: row.content, - count: row.count, - }) - } - } - - const members = Array.from(memberMap.values()) - members.sort((a, b) => { - const aTotal = a.catchphrases.reduce((sum, c) => sum + c.count, 0) - const bTotal = b.catchphrases.reduce((sum, c) => sum + c.count, 0) - return bTotal - aTotal - }) - - return { members } -} diff --git a/electron/main/worker/query/advanced/social.ts b/electron/main/worker/query/advanced/social.ts deleted file mode 100644 index f3e86de57..000000000 --- a/electron/main/worker/query/advanced/social.ts +++ /dev/null @@ -1,504 +0,0 @@ -/** - * 社交分析模块 - * 包含:@ 互动分析、含笑量分析 - */ - -import { openDatabase, buildTimeFilter, type TimeFilter } from '../../core' - -// ==================== @ 互动分析 ==================== - -/** - * 获取 @ 互动分析数据 - */ -export function getMentionAnalysis(sessionId: string, filter?: TimeFilter): any { - const db = openDatabase(sessionId) - const emptyResult = { - topMentioners: [], - topMentioned: [], - oneWay: [], - twoWay: [], - totalMentions: 0, - memberDetails: [], - } - - if (!db) return emptyResult - - // 1. 查询所有成员信息 - const members = db - .prepare( - ` - SELECT id, platform_id as platformId, COALESCE(group_nickname, account_name, platform_id) as name - FROM member - WHERE COALESCE(account_name, '') != '系统消息' - ` - ) - .all() as Array<{ id: number; platformId: string; name: string }> - - if (members.length === 0) return emptyResult - - // 2. 构建昵称到成员ID的映射(包括历史昵称) - const nameToMemberId = new Map() - const memberIdToInfo = new Map() - - for (const member of members) { - memberIdToInfo.set(member.id, { platformId: member.platformId, name: member.name }) - // 当前昵称 - nameToMemberId.set(member.name, member.id) - - // 查询历史昵称 - const history = db - .prepare( - ` - SELECT name FROM member_name_history - WHERE member_id = ? - ` - ) - .all(member.id) as Array<{ name: string }> - - for (const h of history) { - if (!nameToMemberId.has(h.name)) { - nameToMemberId.set(h.name, member.id) - } - } - } - - // 3. 查询所有消息(带时间过滤) - const { clause, params } = buildTimeFilter(filter) - - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += - " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'" - } else { - whereClause = - " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'" - } - - const messages = db - .prepare( - ` - SELECT - msg.sender_id as senderId, - msg.content - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - ` - ) - .all(...params) as Array<{ senderId: number; content: string }> - - // 4. 解析 @ 并构建关系矩阵 - // mentionMatrix[fromId][toId] = count - const mentionMatrix = new Map>() - const mentionedCount = new Map() // 被 @ 的次数 - const mentionerCount = new Map() // 发起 @ 的次数 - let totalMentions = 0 - - // @ 正则:匹配 @昵称(昵称不含空格和@) - const mentionRegex = /@([^\s@]+)/g - - for (const msg of messages) { - const matches = msg.content.matchAll(mentionRegex) - const mentionedInThisMsg = new Set() // 避免同一消息重复计数同一人 - - for (const match of matches) { - const mentionedName = match[1] - const mentionedId = nameToMemberId.get(mentionedName) - - // 只统计能匹配到成员的 @,且不能 @ 自己 - if (mentionedId && mentionedId !== msg.senderId && !mentionedInThisMsg.has(mentionedId)) { - mentionedInThisMsg.add(mentionedId) - totalMentions++ - - // 更新矩阵 - if (!mentionMatrix.has(msg.senderId)) { - mentionMatrix.set(msg.senderId, new Map()) - } - const fromMap = mentionMatrix.get(msg.senderId)! - fromMap.set(mentionedId, (fromMap.get(mentionedId) || 0) + 1) - - // 更新计数 - mentionerCount.set(msg.senderId, (mentionerCount.get(msg.senderId) || 0) + 1) - mentionedCount.set(mentionedId, (mentionedCount.get(mentionedId) || 0) + 1) - } - } - } - - if (totalMentions === 0) return emptyResult - - // 5. 构建排行榜 - const topMentioners: any[] = [] - for (const [memberId, count] of mentionerCount.entries()) { - const info = memberIdToInfo.get(memberId)! - topMentioners.push({ - memberId, - platformId: info.platformId, - name: info.name, - count, - percentage: Math.round((count / totalMentions) * 10000) / 100, - }) - } - topMentioners.sort((a, b) => b.count - a.count) - - const topMentioned: any[] = [] - for (const [memberId, count] of mentionedCount.entries()) { - const info = memberIdToInfo.get(memberId)! - topMentioned.push({ - memberId, - platformId: info.platformId, - name: info.name, - count, - percentage: Math.round((count / totalMentions) * 10000) / 100, - }) - } - topMentioned.sort((a, b) => b.count - a.count) - - // 6. 检测单向关注 - // 条件:A @ B 的比例 >= 80%(即 B @ A / A @ B < 20%) - const oneWay: any[] = [] - const processedPairs = new Set() - - for (const [fromId, toMap] of mentionMatrix.entries()) { - for (const [toId, fromToCount] of toMap.entries()) { - const pairKey = `${Math.min(fromId, toId)}-${Math.max(fromId, toId)}` - if (processedPairs.has(pairKey)) continue - processedPairs.add(pairKey) - - const toFromCount = mentionMatrix.get(toId)?.get(fromId) || 0 - const total = fromToCount + toFromCount - - // 只有总互动 >= 3 次才考虑 - if (total < 3) continue - - const ratio = fromToCount / total - - // 单向关注:一方占比 >= 80% - if (ratio >= 0.8) { - const fromInfo = memberIdToInfo.get(fromId)! - const toInfo = memberIdToInfo.get(toId)! - oneWay.push({ - fromMemberId: fromId, - fromName: fromInfo.name, - toMemberId: toId, - toName: toInfo.name, - fromToCount, - toFromCount, - ratio: Math.round(ratio * 100) / 100, - }) - } else if (ratio <= 0.2) { - // 反向单向关注 - const fromInfo = memberIdToInfo.get(fromId)! - const toInfo = memberIdToInfo.get(toId)! - oneWay.push({ - fromMemberId: toId, - fromName: toInfo.name, - toMemberId: fromId, - toName: fromInfo.name, - fromToCount: toFromCount, - toFromCount: fromToCount, - ratio: Math.round((1 - ratio) * 100) / 100, - }) - } - } - } - oneWay.sort((a, b) => b.fromToCount - a.fromToCount) - - // 7. 检测双向奔赴(CP检测) - // 条件:双方互相 @ 总次数 >= 5 次,且比例在 30%-70% 之间 - const twoWay: any[] = [] - processedPairs.clear() - - for (const [fromId, toMap] of mentionMatrix.entries()) { - for (const [toId, fromToCount] of toMap.entries()) { - const pairKey = `${Math.min(fromId, toId)}-${Math.max(fromId, toId)}` - if (processedPairs.has(pairKey)) continue - processedPairs.add(pairKey) - - const toFromCount = mentionMatrix.get(toId)?.get(fromId) || 0 - const total = fromToCount + toFromCount - - // 总互动 >= 5 次 - if (total < 5) continue - - // 必须双方都有 @ - if (toFromCount === 0 || fromToCount === 0) continue - - const ratio = Math.min(fromToCount, toFromCount) / Math.max(fromToCount, toFromCount) - - // 平衡度 >= 30%(即 30%-100%) - if (ratio >= 0.3) { - const member1Info = memberIdToInfo.get(fromId)! - const member2Info = memberIdToInfo.get(toId)! - twoWay.push({ - member1Id: fromId, - member1Name: member1Info.name, - member2Id: toId, - member2Name: member2Info.name, - member1To2: fromToCount, - member2To1: toFromCount, - total, - balance: Math.round(ratio * 100) / 100, - }) - } - } - } - twoWay.sort((a, b) => b.total - a.total) - - // 8. 构建成员详情(每个成员的 @ 关系 TOP 5) - const memberDetails: any[] = [] - - for (const member of members) { - const memberId = member.id - const info = memberIdToInfo.get(memberId)! - - // 该成员最常 @ 的人 - const topMentionedByThis: any[] = [] - const toMap = mentionMatrix.get(memberId) - if (toMap) { - for (const [toId, count] of toMap.entries()) { - const toInfo = memberIdToInfo.get(toId)! - topMentionedByThis.push({ - fromMemberId: memberId, - fromName: info.name, - toMemberId: toId, - toName: toInfo.name, - count, - }) - } - topMentionedByThis.sort((a, b) => b.count - a.count) - } - - // 最常 @ 该成员的人 - const topMentionersOfThis: any[] = [] - for (const [fromId, toMap] of mentionMatrix.entries()) { - const count = toMap.get(memberId) - if (count) { - const fromInfo = memberIdToInfo.get(fromId)! - topMentionersOfThis.push({ - fromMemberId: fromId, - fromName: fromInfo.name, - toMemberId: memberId, - toName: info.name, - count, - }) - } - } - topMentionersOfThis.sort((a, b) => b.count - a.count) - - // 只有有数据的成员才添加 - if (topMentionedByThis.length > 0 || topMentionersOfThis.length > 0) { - memberDetails.push({ - memberId, - name: info.name, - topMentioned: topMentionedByThis.slice(0, 5), - topMentioners: topMentionersOfThis.slice(0, 5), - }) - } - } - - return { - topMentioners, - topMentioned, - oneWay, - twoWay, - totalMentions, - memberDetails, - } -} - -// ==================== 含笑量分析 ==================== - -/** - * 将关键词转换为正则表达式模式 - */ -function keywordToPattern(keyword: string): string { - // 转义特殊字符 - const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - - // 特殊处理一些关键词的变体 - if (keyword === '哈哈') { - return '哈哈+' - } - - return escaped -} - -/** - * 获取含笑量分析数据 - * @param sessionId 会话ID - * @param filter 时间过滤 - * @param keywords 自定义关键词列表(可选,默认使用内置列表) - */ -export function getLaughAnalysis(sessionId: string, filter?: TimeFilter, keywords?: string[]): any { - const db = openDatabase(sessionId) - const emptyResult = { - rankByRate: [], - rankByCount: [], - typeDistribution: [], - totalLaughs: 0, - totalMessages: 0, - groupLaughRate: 0, - } - - if (!db) return emptyResult - - // 使用传入的关键词或默认关键词 - const laughKeywords = keywords && keywords.length > 0 ? keywords : [] - - // 构建正则表达式 - const patterns = laughKeywords.map(keywordToPattern) - const laughRegex = new RegExp(`(${patterns.join('|')})`, 'gi') - - // 查询所有消息 - const { clause, params } = buildTimeFilter(filter) - - let whereClause = clause - if (whereClause.includes('WHERE')) { - whereClause += " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL" - } else { - whereClause = " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL" - } - - const messages = db - .prepare( - ` - SELECT - msg.sender_id as senderId, - msg.content, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${whereClause} - ` - ) - .all(...params) as Array<{ - senderId: number - content: string - platformId: string - name: string - }> - - if (messages.length === 0) return emptyResult - - // 统计数据 - const memberStats = new Map< - number, - { - platformId: string - name: string - laughCount: number - messageCount: number - keywordCounts: Map // 每个关键词的计数 - } - >() - const typeCount = new Map() - let totalLaughs = 0 - - for (const msg of messages) { - // 初始化成员统计 - if (!memberStats.has(msg.senderId)) { - memberStats.set(msg.senderId, { - platformId: msg.platformId, - name: msg.name, - laughCount: 0, - messageCount: 0, - keywordCounts: new Map(), - }) - } - - const stats = memberStats.get(msg.senderId)! - stats.messageCount++ - - // 匹配笑声关键词 - const matches = msg.content.match(laughRegex) - if (matches) { - stats.laughCount += matches.length - totalLaughs += matches.length - - // 统计类型分布 - for (const match of matches) { - // 归类到对应的关键词类型 - let matchedType = '其他' - for (const keyword of laughKeywords) { - const pattern = new RegExp(`^${keywordToPattern(keyword)}$`, 'i') - if (pattern.test(match)) { - matchedType = keyword - break - } - } - typeCount.set(matchedType, (typeCount.get(matchedType) || 0) + 1) - // 记录到成员的关键词计数 - stats.keywordCounts.set(matchedType, (stats.keywordCounts.get(matchedType) || 0) + 1) - } - } - } - - const totalMessages = messages.length - - if (totalLaughs === 0) return emptyResult - - // 构建排行榜 - const rankItems: any[] = [] - for (const [memberId, stats] of memberStats.entries()) { - if (stats.laughCount > 0) { - // 构建该成员的关键词分布(按原始关键词顺序) - const keywordDistribution: Array<{ keyword: string; count: number; percentage: number }> = [] - for (const keyword of laughKeywords) { - const count = stats.keywordCounts.get(keyword) || 0 - if (count > 0) { - keywordDistribution.push({ - keyword, - count, - percentage: Math.round((count / stats.laughCount) * 10000) / 100, - }) - } - } - // 处理"其他"类型 - const otherCount = stats.keywordCounts.get('其他') || 0 - if (otherCount > 0) { - keywordDistribution.push({ - keyword: '其他', - count: otherCount, - percentage: Math.round((otherCount / stats.laughCount) * 10000) / 100, - }) - } - - rankItems.push({ - memberId, - platformId: stats.platformId, - name: stats.name, - laughCount: stats.laughCount, - messageCount: stats.messageCount, - laughRate: Math.round((stats.laughCount / stats.messageCount) * 10000) / 100, - percentage: Math.round((stats.laughCount / totalLaughs) * 10000) / 100, - keywordDistribution, - }) - } - } - - // 按含笑率排序 - const rankByRate = [...rankItems].sort((a, b) => b.laughRate - a.laughRate) - // 按贡献度(绝对数量)排序 - const rankByCount = [...rankItems].sort((a, b) => b.laughCount - a.laughCount) - - // 构建类型分布 - const typeDistribution: any[] = [] - for (const [type, count] of typeCount.entries()) { - typeDistribution.push({ - type, - count, - percentage: Math.round((count / totalLaughs) * 10000) / 100, - }) - } - typeDistribution.sort((a, b) => b.count - a.count) - - return { - rankByRate, - rankByCount, - typeDistribution, - totalLaughs, - totalMessages, - groupLaughRate: Math.round((totalLaughs / totalMessages) * 10000) / 100, - } -} diff --git a/electron/main/worker/query/basic.ts b/electron/main/worker/query/basic.ts deleted file mode 100644 index 8d87a2b4a..000000000 --- a/electron/main/worker/query/basic.ts +++ /dev/null @@ -1,637 +0,0 @@ -/** - * 基础查询模块 - * 提供活跃度、时段分布等基础统计查询 - */ - -import Database from 'better-sqlite3' -import * as fs from 'fs' -import * as path from 'path' -import { - openDatabase, - closeDatabase, - getDbDir, - getDbPath, - buildTimeFilter, - buildSystemMessageFilter, - type TimeFilter, -} from '../core' - -// ==================== 基础查询 ==================== - -/** - * 获取可用的年份列表 - */ -export function getAvailableYears(sessionId: string): number[] { - const db = openDatabase(sessionId) - if (!db) return [] - - const rows = db - .prepare( - ` - SELECT DISTINCT CAST(strftime('%Y', ts, 'unixepoch', 'localtime') AS INTEGER) as year - FROM message - ORDER BY year DESC - ` - ) - .all() as Array<{ year: number }> - - return rows.map((r) => r.year) -} - -/** - * 获取成员活跃度排行 - */ -export function getMemberActivity(sessionId: string, filter?: TimeFilter): any[] { - // 先确保数据库有 avatar 字段(兼容旧数据库) - ensureAvatarColumn(sessionId) - - const db = openDatabase(sessionId) - if (!db) return [] - - const { clause, params } = buildTimeFilter(filter) - - const msgFilterBase = clause ? clause.replace('WHERE', 'AND') : '' - const msgFilterWithSystem = msgFilterBase + " AND COALESCE(m.account_name, '') != '系统消息'" - - const totalClauseWithSystem = buildSystemMessageFilter(clause) - const totalMessages = ( - db - .prepare( - `SELECT COUNT(*) as count - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${totalClauseWithSystem}` - ) - .get(...params) as { count: number } - ).count - - const rows = db - .prepare( - ` - SELECT - m.id as memberId, - m.platform_id as platformId, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, - m.avatar as avatar, - COUNT(msg.id) as messageCount - FROM member m - LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem} - WHERE COALESCE(m.account_name, '') != '系统消息' - GROUP BY m.id - HAVING messageCount > 0 - ORDER BY messageCount DESC - ` - ) - .all(...params) as Array<{ - memberId: number - platformId: string - name: string - avatar: string | null - messageCount: number - }> - - return rows.map((row) => ({ - memberId: row.memberId, - platformId: row.platformId, - name: row.name, - avatar: row.avatar, - messageCount: row.messageCount, - percentage: totalMessages > 0 ? Math.round((row.messageCount / totalMessages) * 10000) / 100 : 0, - })) -} - -/** - * 获取每小时活跃度分布 - */ -export function getHourlyActivity(sessionId: string, filter?: TimeFilter): any[] { - const db = openDatabase(sessionId) - if (!db) return [] - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const rows = db - .prepare( - ` - SELECT - CAST(strftime('%H', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as hour, - COUNT(*) as messageCount - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY hour - ORDER BY hour - ` - ) - .all(...params) as Array<{ hour: number; messageCount: number }> - - const result: any[] = [] - for (let h = 0; h < 24; h++) { - const found = rows.find((r) => r.hour === h) - result.push({ - hour: h, - messageCount: found ? found.messageCount : 0, - }) - } - - return result -} - -/** - * 获取每日活跃度趋势 - */ -export function getDailyActivity(sessionId: string, filter?: TimeFilter): any[] { - const db = openDatabase(sessionId) - if (!db) return [] - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const rows = db - .prepare( - ` - SELECT - strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date, - COUNT(*) as messageCount - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY date - ORDER BY date - ` - ) - .all(...params) as Array<{ date: string; messageCount: number }> - - return rows -} - -/** - * 获取星期活跃度分布 - */ -export function getWeekdayActivity(sessionId: string, filter?: TimeFilter): any[] { - const db = openDatabase(sessionId) - if (!db) return [] - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const rows = db - .prepare( - ` - SELECT - CASE - WHEN CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER) = 0 THEN 7 - ELSE CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER) - END as weekday, - COUNT(*) as messageCount - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY weekday - ORDER BY weekday - ` - ) - .all(...params) as Array<{ weekday: number; messageCount: number }> - - const result: any[] = [] - for (let w = 1; w <= 7; w++) { - const found = rows.find((r) => r.weekday === w) - result.push({ - weekday: w, - messageCount: found ? found.messageCount : 0, - }) - } - - return result -} - -/** - * 获取月份活跃度分布 - */ -export function getMonthlyActivity(sessionId: string, filter?: TimeFilter): any[] { - const db = openDatabase(sessionId) - if (!db) return [] - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const rows = db - .prepare( - ` - SELECT - CAST(strftime('%m', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as month, - COUNT(*) as messageCount - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY month - ORDER BY month - ` - ) - .all(...params) as Array<{ month: number; messageCount: number }> - - const result: any[] = [] - for (let m = 1; m <= 12; m++) { - const found = rows.find((r) => r.month === m) - result.push({ - month: m, - messageCount: found ? found.messageCount : 0, - }) - } - - return result -} - -/** - * 获取消息类型分布 - */ -export function getMessageTypeDistribution(sessionId: string, filter?: TimeFilter): any[] { - const db = openDatabase(sessionId) - if (!db) return [] - - const { clause, params } = buildTimeFilter(filter) - const clauseWithSystem = buildSystemMessageFilter(clause) - - const rows = db - .prepare( - ` - SELECT msg.type, COUNT(*) as count - FROM message msg - JOIN member m ON msg.sender_id = m.id - ${clauseWithSystem} - GROUP BY msg.type - ORDER BY count DESC - ` - ) - .all(...params) as Array<{ type: number; count: number }> - - return rows.map((r) => ({ - type: r.type, - count: r.count, - })) -} - -/** - * 获取时间范围 - */ -export function getTimeRange(sessionId: string): { start: number; end: number } | null { - const db = openDatabase(sessionId) - if (!db) return null - - const row = db - .prepare( - ` - SELECT MIN(ts) as start, MAX(ts) as end FROM message - ` - ) - .get() as { start: number | null; end: number | null } - - if (row.start === null || row.end === null) return null - - return { start: row.start, end: row.end } -} - -/** - * 获取成员的历史昵称记录 - */ -export function getMemberNameHistory(sessionId: string, memberId: number): any[] { - const db = openDatabase(sessionId) - if (!db) return [] - - const rows = db - .prepare( - ` - SELECT name_type as nameType, name, start_ts as startTs, end_ts as endTs - FROM member_name_history - WHERE member_id = ? - ORDER BY start_ts DESC - ` - ) - .all(memberId) as Array<{ nameType: string; name: string; startTs: number; endTs: number | null }> - - return rows -} - -// ==================== 会话管理 ==================== - -interface DbMeta { - name: string - platform: string - type: string - imported_at: number - group_id: string | null - group_avatar: string | null -} - -/** - * 获取所有会话列表 - */ -export function getAllSessions(): any[] { - const dbDir = getDbDir() - if (!fs.existsSync(dbDir)) { - return [] - } - - const sessions: any[] = [] - const files = fs.readdirSync(dbDir).filter((f) => f.endsWith('.db')) - - for (const file of files) { - const sessionId = file.replace('.db', '') - const dbPath = path.join(dbDir, file) - - try { - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - - const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined - - if (meta) { - const messageCount = ( - db - .prepare( - `SELECT COUNT(*) as count - FROM message msg - JOIN member m ON msg.sender_id = m.id - WHERE COALESCE(m.account_name, '') != '系统消息'` - ) - .get() as { count: number } - ).count - const memberCount = ( - db - .prepare( - `SELECT COUNT(*) as count - FROM member - WHERE COALESCE(account_name, '') != '系统消息'` - ) - .get() as { count: number } - ).count - - sessions.push({ - id: sessionId, - name: meta.name, - platform: meta.platform, - type: meta.type, - importedAt: meta.imported_at, - messageCount, - memberCount, - dbPath, - groupId: meta.group_id || null, - groupAvatar: meta.group_avatar || null, - ownerId: meta.owner_id || null, - }) - } - - db.close() - } catch (error) { - console.error(`[Worker] Failed to read database ${file}:`, error) - } - } - - return sessions.sort((a, b) => b.importedAt - a.importedAt) -} - -/** - * 获取单个会话信息 - */ -export function getSession(sessionId: string): any | null { - const db = openDatabase(sessionId) - if (!db) return null - - const meta = db.prepare('SELECT * FROM meta LIMIT 1').get() as DbMeta | undefined - if (!meta) return null - - const messageCount = ( - db - .prepare( - `SELECT COUNT(*) as count - FROM message msg - JOIN member m ON msg.sender_id = m.id - WHERE COALESCE(m.account_name, '') != '系统消息'` - ) - .get() as { count: number } - ).count - - const memberCount = ( - db - .prepare( - `SELECT COUNT(*) as count - FROM member - WHERE COALESCE(account_name, '') != '系统消息'` - ) - .get() as { count: number } - ).count - - return { - id: sessionId, - name: meta.name, - platform: meta.platform, - type: meta.type, - importedAt: meta.imported_at, - messageCount, - memberCount, - dbPath: getDbPath(sessionId), - groupId: meta.group_id || null, - groupAvatar: meta.group_avatar || null, - ownerId: meta.owner_id || null, - } -} - -// ==================== 成员管理 ==================== - -/** - * 成员信息(含统计数据) - */ -interface MemberWithStats { - id: number - platformId: string - accountName: string | null - groupNickname: string | null - aliases: string[] - messageCount: number - avatar: string | null -} - -// 用于标记已检查过 aliases 字段的会话 -const aliasesCheckedSessions = new Set() -// 用于标记已检查过 avatar 字段的会话 -const avatarCheckedSessions = new Set() - -/** - * 确保 member 表有 aliases 字段(数据库迁移) - * 用于兼容旧数据库 - */ -function ensureAliasesColumn(sessionId: string): void { - // 每个会话只检查一次 - if (aliasesCheckedSessions.has(sessionId)) return - - const dbPath = getDbPath(sessionId) - if (!fs.existsSync(dbPath)) return - - // 先关闭可能缓存的只读连接 - closeDatabase(sessionId) - - // 使用写入模式打开数据库检查并添加字段 - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - - try { - // 检查 aliases 字段是否存在 - const columns = db.prepare('PRAGMA table_info(member)').all() as Array<{ name: string }> - const hasAliases = columns.some((col) => col.name === 'aliases') - - if (!hasAliases) { - // 添加 aliases 字段 - db.exec("ALTER TABLE member ADD COLUMN aliases TEXT DEFAULT '[]'") - console.log(`[Worker] Added aliases column to member table in session ${sessionId}`) - } - - // 标记为已检查 - aliasesCheckedSessions.add(sessionId) - } finally { - db.close() - } -} - -/** - * 确保 member 表有 avatar 字段(数据库迁移) - * 用于兼容旧数据库 - */ -export function ensureAvatarColumn(sessionId: string): void { - // 每个会话只检查一次 - if (avatarCheckedSessions.has(sessionId)) return - - const dbPath = getDbPath(sessionId) - if (!fs.existsSync(dbPath)) return - - // 先关闭可能缓存的只读连接 - closeDatabase(sessionId) - - // 使用写入模式打开数据库检查并添加字段 - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - - try { - // 检查 avatar 字段是否存在 - const columns = db.prepare('PRAGMA table_info(member)').all() as Array<{ name: string }> - const hasAvatar = columns.some((col) => col.name === 'avatar') - - if (!hasAvatar) { - // 添加 avatar 字段 - db.exec('ALTER TABLE member ADD COLUMN avatar TEXT') - console.log(`[Worker] Added avatar column to member table in session ${sessionId}`) - } - - // 标记为已检查 - avatarCheckedSessions.add(sessionId) - } finally { - db.close() - } -} - -/** - * 获取所有成员列表(含消息数、别名和头像) - */ -export function getMembers(sessionId: string): MemberWithStats[] { - // 先确保数据库有 aliases 和 avatar 字段(兼容旧数据库) - ensureAliasesColumn(sessionId) - ensureAvatarColumn(sessionId) - - const db = openDatabase(sessionId) - if (!db) return [] - - const rows = db - .prepare( - ` - SELECT - m.id, - m.platform_id as platformId, - m.account_name as accountName, - m.group_nickname as groupNickname, - m.aliases, - m.avatar, - COUNT(msg.id) as messageCount - FROM member m - LEFT JOIN message msg ON m.id = msg.sender_id - WHERE COALESCE(m.group_nickname, m.account_name, m.platform_id) != '系统消息' - GROUP BY m.id - ORDER BY messageCount DESC - ` - ) - .all() as Array<{ - id: number - platformId: string - accountName: string | null - groupNickname: string | null - aliases: string | null - avatar: string | null - messageCount: number - }> - - return rows.map((row) => ({ - id: row.id, - platformId: row.platformId, - accountName: row.accountName, - groupNickname: row.groupNickname, - aliases: row.aliases ? JSON.parse(row.aliases) : [], - messageCount: row.messageCount, - avatar: row.avatar, - })) -} - -/** - * 更新成员别名 - */ -export function updateMemberAliases(sessionId: string, memberId: number, aliases: string[]): boolean { - const dbPath = getDbPath(sessionId) - if (!fs.existsSync(dbPath)) { - return false - } - - try { - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - - const stmt = db.prepare('UPDATE member SET aliases = ? WHERE id = ?') - stmt.run(JSON.stringify(aliases), memberId) - - db.close() - return true - } catch (error) { - console.error('[Worker] Failed to update member aliases:', error) - return false - } -} - -/** - * 删除成员及其所有消息 - */ -export function deleteMember(sessionId: string, memberId: number): boolean { - const dbPath = getDbPath(sessionId) - if (!fs.existsSync(dbPath)) { - return false - } - - try { - const db = new Database(dbPath) - db.pragma('journal_mode = WAL') - - // 使用事务删除成员及其相关数据 - const deleteTransaction = db.transaction(() => { - // 1. 删除该成员的消息 - db.prepare('DELETE FROM message WHERE sender_id = ?').run(memberId) - - // 2. 删除该成员的昵称历史 - db.prepare('DELETE FROM member_name_history WHERE member_id = ?').run(memberId) - - // 3. 删除成员记录 - db.prepare('DELETE FROM member WHERE id = ?').run(memberId) - }) - - deleteTransaction() - db.close() - return true - } catch (error) { - console.error('[Worker] Failed to delete member:', error) - return false - } -} diff --git a/electron/main/worker/query/index.ts b/electron/main/worker/query/index.ts deleted file mode 100644 index aa1336246..000000000 --- a/electron/main/worker/query/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 查询模块入口 - * 统一导出基础查询和高级分析函数 - */ - -// 基础查询 -export { - getAvailableYears, - getMemberActivity, - getHourlyActivity, - getDailyActivity, - getWeekdayActivity, - getMonthlyActivity, - getMessageTypeDistribution, - getTimeRange, - getMemberNameHistory, - getAllSessions, - getSession, - // 成员管理 - getMembers, - updateMemberAliases, - deleteMember, -} from './basic' - -// 高级分析 -export { - getRepeatAnalysis, - getCatchphraseAnalysis, - getNightOwlAnalysis, - getDragonKingAnalysis, - getDivingAnalysis, - getCheckInAnalysis, - getMonologueAnalysis, - getMemeBattleAnalysis, - getMentionAnalysis, - getLaughAnalysis, -} from './advanced' - -// 聊天记录查询 -export { - searchMessages, - getMessageContext, - getRecentMessages, - getAllRecentMessages, - getConversationBetween, - getMessagesBefore, - getMessagesAfter, -} from './messages' - -// 聊天记录查询类型 -export type { MessageResult, PaginatedMessages, MessagesWithTotal } from './messages' - -// SQL 实验室 -export { executeRawSQL, getSchema } from './sql' -export type { SQLResult, TableSchema } from './sql' diff --git a/electron/main/worker/query/messages.ts b/electron/main/worker/query/messages.ts deleted file mode 100644 index 84978e4ec..000000000 --- a/electron/main/worker/query/messages.ts +++ /dev/null @@ -1,652 +0,0 @@ -/** - * 聊天记录查询模块 - * 提供通用的消息查询功能:搜索、筛选、上下文、无限滚动等 - * 在 Worker 线程中执行 - */ - -import { openDatabase, buildTimeFilter, type TimeFilter } from '../core' -import { ensureAvatarColumn } from './basic' - -// ==================== 类型定义 ==================== - -/** - * 消息查询结果类型 - */ -export interface MessageResult { - id: number - senderName: string - senderPlatformId: string - senderAliases: string[] - senderAvatar: string | null - content: string - timestamp: number - type: number - replyToMessageId: string | null - replyToContent: string | null - replyToSenderName: string | null -} - -/** - * 分页消息结果 - */ -export interface PaginatedMessages { - messages: MessageResult[] - hasMore: boolean -} - -/** - * 带总数的消息结果 - */ -export interface MessagesWithTotal { - messages: MessageResult[] - total: number -} - -// ==================== 工具函数 ==================== - -/** - * 数据库行类型(包含 aliases JSON 字符串和头像) - */ -interface DbMessageRow { - id: number - senderName: string - senderPlatformId: string - aliases: string | null - avatar: string | null - content: string - timestamp: number - type: number - reply_to_message_id: string | null - replyToContent: string | null - replyToSenderName: string | null -} - -/** - * 将数据库行转换为可序列化的 MessageResult - * 处理 BigInt 等类型,确保 IPC 传输安全 - */ -function sanitizeMessageRow(row: DbMessageRow): MessageResult { - // 解析别名 JSON - let aliases: string[] = [] - if (row.aliases) { - try { - aliases = JSON.parse(row.aliases) - } catch { - aliases = [] - } - } - - return { - id: Number(row.id), - senderName: String(row.senderName || ''), - senderPlatformId: String(row.senderPlatformId || ''), - senderAliases: aliases, - senderAvatar: row.avatar || null, - content: row.content != null ? String(row.content) : '', - timestamp: Number(row.timestamp), - type: Number(row.type), - replyToMessageId: row.reply_to_message_id || null, - replyToContent: row.replyToContent || null, - replyToSenderName: row.replyToSenderName || null, - } -} - -/** - * 构建通用的发送者筛选条件 - */ -function buildSenderCondition(senderId?: number): { condition: string; params: number[] } { - if (senderId === undefined) { - return { condition: '', params: [] } - } - return { condition: 'AND msg.sender_id = ?', params: [senderId] } -} - -/** - * 构建关键词筛选条件(OR 逻辑) - */ -function buildKeywordCondition(keywords?: string[]): { condition: string; params: string[] } { - if (!keywords || keywords.length === 0) { - return { condition: '', params: [] } - } - const condition = `AND (${keywords.map(() => `msg.content LIKE ?`).join(' OR ')})` - const params = keywords.map((k) => `%${k}%`) - return { condition, params } -} - -// 排除系统消息的通用过滤条件 -const SYSTEM_FILTER = "AND COALESCE(m.account_name, '') != '系统消息'" - -// 只获取文本消息的过滤条件 -const TEXT_ONLY_FILTER = 'AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content != \'\'' - -// ==================== 查询函数 ==================== - -/** - * 获取最近的消息(AI Agent 专用,只返回文本消息) - * @param sessionId 会话 ID - * @param filter 时间过滤器 - * @param limit 返回数量限制 - */ -export function getRecentMessages( - sessionId: string, - filter?: TimeFilter, - limit: number = 100 -): MessagesWithTotal { - // 确保数据库有 avatar 字段(兼容旧数据库) - ensureAvatarColumn(sessionId) - - const db = openDatabase(sessionId) - if (!db) return { messages: [], total: 0 } - - // 构建时间过滤条件 - const { clause: timeClause, params: timeParams } = buildTimeFilter(filter) - const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' - - // 查询总数 - const countSql = ` - SELECT COUNT(*) as total - FROM message msg - JOIN member m ON msg.sender_id = m.id - WHERE 1=1 - ${timeCondition} - ${SYSTEM_FILTER} - ${TEXT_ONLY_FILTER} - ` - const totalRow = db.prepare(countSql).get(...timeParams) as { total: number } - const total = totalRow?.total || 0 - - // 查询最近消息(按时间降序) - const sql = ` - SELECT - msg.id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, - m.platform_id as senderPlatformId, - m.aliases, - m.avatar, - msg.content, - msg.ts as timestamp, - msg.type, - msg.reply_to_message_id, - reply_msg.content as replyToContent, - COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName - FROM message msg - JOIN member m ON msg.sender_id = m.id - LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id - LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id - WHERE 1=1 - ${timeCondition} - ${SYSTEM_FILTER} - ${TEXT_ONLY_FILTER} - ORDER BY msg.ts DESC - LIMIT ? - ` - - const rows = db.prepare(sql).all(...timeParams, limit) as DbMessageRow[] - - // 返回时按时间正序排列(便于阅读) - return { - messages: rows.map(sanitizeMessageRow).reverse(), - total, - } -} - -/** - * 获取所有最近的消息(消息查看器专用,包含所有类型消息) - * @param sessionId 会话 ID - * @param filter 时间过滤器 - * @param limit 返回数量限制 - */ -export function getAllRecentMessages( - sessionId: string, - filter?: TimeFilter, - limit: number = 100 -): MessagesWithTotal { - // 确保数据库有 avatar 字段(兼容旧数据库) - ensureAvatarColumn(sessionId) - - const db = openDatabase(sessionId) - if (!db) return { messages: [], total: 0 } - - // 构建时间过滤条件 - const { clause: timeClause, params: timeParams } = buildTimeFilter(filter) - const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' - - // 查询总数 - const countSql = ` - SELECT COUNT(*) as total - FROM message msg - JOIN member m ON msg.sender_id = m.id - WHERE 1=1 - ${timeCondition} - ` - const totalRow = db.prepare(countSql).get(...timeParams) as { total: number } - const total = totalRow?.total || 0 - - // 查询最近消息(按时间降序) - const sql = ` - SELECT - msg.id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, - m.platform_id as senderPlatformId, - m.aliases, - m.avatar, - msg.content, - msg.ts as timestamp, - msg.type, - msg.reply_to_message_id, - reply_msg.content as replyToContent, - COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName - FROM message msg - JOIN member m ON msg.sender_id = m.id - LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id - LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id - WHERE 1=1 - ${timeCondition} - ORDER BY msg.ts DESC - LIMIT ? - ` - - const rows = db.prepare(sql).all(...timeParams, limit) as DbMessageRow[] - - // 返回时按时间正序排列(便于阅读) - return { - messages: rows.map(sanitizeMessageRow).reverse(), - total, - } -} - -/** - * 关键词搜索消息 - * @param sessionId 会话 ID - * @param keywords 关键词数组(OR 逻辑),可以为空数组 - * @param filter 时间过滤器 - * @param limit 返回数量限制 - * @param offset 偏移量(分页) - * @param senderId 可选的发送者成员 ID - */ -export function searchMessages( - sessionId: string, - keywords: string[], - filter?: TimeFilter, - limit: number = 20, - offset: number = 0, - senderId?: number -): MessagesWithTotal { - // 确保数据库有 avatar 字段(兼容旧数据库) - ensureAvatarColumn(sessionId) - - const db = openDatabase(sessionId) - if (!db) return { messages: [], total: 0 } - - // 构建关键词条件(OR 逻辑) - let keywordCondition = '1=1' // 默认条件(始终为真) - const keywordParams: string[] = [] - if (keywords.length > 0) { - keywordCondition = `(${keywords.map(() => `msg.content LIKE ?`).join(' OR ')})` - keywordParams.push(...keywords.map((k) => `%${k}%`)) - } - - // 构建时间过滤条件 - const { clause: timeClause, params: timeParams } = buildTimeFilter(filter) - const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' - - // 构建发送者筛选条件 - const { condition: senderCondition, params: senderParams } = buildSenderCondition(senderId) - - // 查询总数 - const countSql = ` - SELECT COUNT(*) as total - FROM message msg - JOIN member m ON msg.sender_id = m.id - WHERE ${keywordCondition} - ${timeCondition} - ${senderCondition} - ` - const totalRow = db.prepare(countSql).get(...keywordParams, ...timeParams, ...senderParams) as { total: number } - const total = totalRow?.total || 0 - - // 查询消息 - const sql = ` - SELECT - msg.id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, - m.platform_id as senderPlatformId, - m.aliases, - m.avatar, - msg.content, - msg.ts as timestamp, - msg.type, - msg.reply_to_message_id, - reply_msg.content as replyToContent, - COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName - FROM message msg - JOIN member m ON msg.sender_id = m.id - LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id - LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id - WHERE ${keywordCondition} - ${timeCondition} - ${senderCondition} - ORDER BY msg.ts DESC - LIMIT ? OFFSET ? - ` - - const rows = db.prepare(sql).all(...keywordParams, ...timeParams, ...senderParams, limit, offset) as DbMessageRow[] - - return { - messages: rows.map(sanitizeMessageRow), - total, - } -} - -/** - * 获取消息上下文(指定消息前后的消息) - * 使用消息 ID 方式获取精确的前后 N 条消息 - * - * @param sessionId 会话 ID - * @param messageIds 消息 ID 列表(支持单个或批量) - * @param contextSize 上下文大小,前后各多少条消息,默认 20 - */ -export function getMessageContext( - sessionId: string, - messageIds: number | number[], - contextSize: number = 20 -): MessageResult[] { - // 确保数据库有 avatar 字段(兼容旧数据库) - ensureAvatarColumn(sessionId) - - const db = openDatabase(sessionId) - if (!db) return [] - - // 统一转为数组 - const ids = Array.isArray(messageIds) ? messageIds : [messageIds] - if (ids.length === 0) return [] - - // 收集所有上下文消息的 ID(使用 Set 去重) - const contextIds = new Set() - - for (const messageId of ids) { - // 添加目标消息本身 - contextIds.add(messageId) - - // 获取前 contextSize 条消息(id < messageId,按 id 降序取前 N 个) - const beforeSql = ` - SELECT id FROM message - WHERE id < ? - ORDER BY id DESC - LIMIT ? - ` - const beforeRows = db.prepare(beforeSql).all(messageId, contextSize) as { id: number }[] - beforeRows.forEach((row) => contextIds.add(row.id)) - - // 获取后 contextSize 条消息(id > messageId,按 id 升序取前 N 个) - const afterSql = ` - SELECT id FROM message - WHERE id > ? - ORDER BY id ASC - LIMIT ? - ` - const afterRows = db.prepare(afterSql).all(messageId, contextSize) as { id: number }[] - afterRows.forEach((row) => contextIds.add(row.id)) - } - - // 如果没有找到任何消息 - if (contextIds.size === 0) return [] - - // 批量查询所有上下文消息 - const idList = Array.from(contextIds) - const placeholders = idList.map(() => '?').join(', ') - - const sql = ` - SELECT - msg.id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, - m.platform_id as senderPlatformId, - m.aliases, - m.avatar, - msg.content, - msg.ts as timestamp, - msg.type, - msg.reply_to_message_id, - reply_msg.content as replyToContent, - COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName - FROM message msg - JOIN member m ON msg.sender_id = m.id - LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id - LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id - WHERE msg.id IN (${placeholders}) - ORDER BY msg.id ASC - ` - - const rows = db.prepare(sql).all(...idList) as DbMessageRow[] - - return rows.map(sanitizeMessageRow) -} - -/** - * 获取指定消息之前的 N 条消息(用于向上无限滚动) - * @param sessionId 会话 ID - * @param beforeId 在此消息 ID 之前的消息 - * @param limit 返回数量限制 - * @param filter 可选的时间筛选条件 - * @param senderId 可选的发送者筛选 - * @param keywords 可选的关键词筛选 - */ -export function getMessagesBefore( - sessionId: string, - beforeId: number, - limit: number = 50, - filter?: TimeFilter, - senderId?: number, - keywords?: string[] -): PaginatedMessages { - // 确保数据库有 avatar 字段(兼容旧数据库) - ensureAvatarColumn(sessionId) - - const db = openDatabase(sessionId) - if (!db) return { messages: [], hasMore: false } - - // 构建时间过滤条件 - const { clause: timeClause, params: timeParams } = buildTimeFilter(filter) - const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' - - // 构建关键词条件 - const { condition: keywordCondition, params: keywordParams } = buildKeywordCondition(keywords) - - // 构建发送者筛选条件 - const { condition: senderCondition, params: senderParams } = buildSenderCondition(senderId) - - const sql = ` - SELECT - msg.id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, - m.platform_id as senderPlatformId, - m.aliases, - m.avatar, - msg.content, - msg.ts as timestamp, - msg.type, - msg.reply_to_message_id, - reply_msg.content as replyToContent, - COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName - FROM message msg - JOIN member m ON msg.sender_id = m.id - LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id - LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id - WHERE msg.id < ? - ${timeCondition} - ${keywordCondition} - ${senderCondition} - ORDER BY msg.id DESC - LIMIT ? - ` - - const rows = db.prepare(sql).all(beforeId, ...timeParams, ...keywordParams, ...senderParams, limit + 1) as DbMessageRow[] - - const hasMore = rows.length > limit - const resultRows = hasMore ? rows.slice(0, limit) : rows - - // 返回时按 ID 升序排列 - return { - messages: resultRows.map(sanitizeMessageRow).reverse(), - hasMore, - } -} - -/** - * 获取指定消息之后的 N 条消息(用于向下无限滚动) - * @param sessionId 会话 ID - * @param afterId 在此消息 ID 之后的消息 - * @param limit 返回数量限制 - * @param filter 可选的时间筛选条件 - * @param senderId 可选的发送者筛选 - * @param keywords 可选的关键词筛选 - */ -export function getMessagesAfter( - sessionId: string, - afterId: number, - limit: number = 50, - filter?: TimeFilter, - senderId?: number, - keywords?: string[] -): PaginatedMessages { - // 确保数据库有 avatar 字段(兼容旧数据库) - ensureAvatarColumn(sessionId) - - const db = openDatabase(sessionId) - if (!db) return { messages: [], hasMore: false } - - // 构建时间过滤条件 - const { clause: timeClause, params: timeParams } = buildTimeFilter(filter) - const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' - - // 构建关键词条件 - const { condition: keywordCondition, params: keywordParams } = buildKeywordCondition(keywords) - - // 构建发送者筛选条件 - const { condition: senderCondition, params: senderParams } = buildSenderCondition(senderId) - - const sql = ` - SELECT - msg.id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, - m.platform_id as senderPlatformId, - m.aliases, - m.avatar, - msg.content, - msg.ts as timestamp, - msg.type, - msg.reply_to_message_id, - reply_msg.content as replyToContent, - COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName - FROM message msg - JOIN member m ON msg.sender_id = m.id - LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id - LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id - WHERE msg.id > ? - ${timeCondition} - ${keywordCondition} - ${senderCondition} - ORDER BY msg.id ASC - LIMIT ? - ` - - const rows = db.prepare(sql).all(afterId, ...timeParams, ...keywordParams, ...senderParams, limit + 1) as DbMessageRow[] - - const hasMore = rows.length > limit - const resultRows = hasMore ? rows.slice(0, limit) : rows - - return { - messages: resultRows.map(sanitizeMessageRow), - hasMore, - } -} - -/** - * 获取两个成员之间的对话 - * 提取两人相邻发言形成的对话片段 - * @param sessionId 会话 ID - * @param memberId1 成员1的 ID - * @param memberId2 成员2的 ID - * @param filter 时间过滤器 - * @param limit 返回消息数量限制 - */ -export function getConversationBetween( - sessionId: string, - memberId1: number, - memberId2: number, - filter?: TimeFilter, - limit: number = 100 -): MessagesWithTotal & { member1Name: string; member2Name: string } { - // 确保数据库有 avatar 字段(兼容旧数据库) - ensureAvatarColumn(sessionId) - - const db = openDatabase(sessionId) - if (!db) return { messages: [], total: 0, member1Name: '', member2Name: '' } - - // 获取成员名称 - const member1 = db.prepare(` - SELECT COALESCE(group_nickname, account_name, platform_id) as name - FROM member WHERE id = ? - `).get(memberId1) as { name: string } | undefined - - const member2 = db.prepare(` - SELECT COALESCE(group_nickname, account_name, platform_id) as name - FROM member WHERE id = ? - `).get(memberId2) as { name: string } | undefined - - if (!member1 || !member2) { - return { messages: [], total: 0, member1Name: '', member2Name: '' } - } - - // 构建时间过滤条件 - const { clause: timeClause, params: timeParams } = buildTimeFilter(filter) - const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' - - // 查询两人之间的所有消息 - const countSql = ` - SELECT COUNT(*) as total - FROM message msg - JOIN member m ON msg.sender_id = m.id - WHERE msg.sender_id IN (?, ?) - ${timeCondition} - AND msg.content IS NOT NULL AND msg.content != '' - ` - const totalRow = db.prepare(countSql).get(memberId1, memberId2, ...timeParams) as { total: number } - const total = totalRow?.total || 0 - - // 查询消息 - const sql = ` - SELECT - msg.id, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, - m.platform_id as senderPlatformId, - m.aliases, - m.avatar, - msg.content, - msg.ts as timestamp, - msg.type, - msg.reply_to_message_id, - reply_msg.content as replyToContent, - COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName - FROM message msg - JOIN member m ON msg.sender_id = m.id - LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id - LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id - WHERE msg.sender_id IN (?, ?) - ${timeCondition} - AND msg.content IS NOT NULL AND msg.content != '' - ORDER BY msg.ts DESC - LIMIT ? - ` - - const rows = db.prepare(sql).all(memberId1, memberId2, ...timeParams, limit) as DbMessageRow[] - - // 返回时按时间正序排列(便于阅读对话) - return { - messages: rows.map(sanitizeMessageRow).reverse(), - total, - member1Name: member1.name, - member2Name: member2.name, - } -} - diff --git a/electron/main/worker/query/sql.ts b/electron/main/worker/query/sql.ts deleted file mode 100644 index 94c610431..000000000 --- a/electron/main/worker/query/sql.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * SQL 实验室查询模块 - * 提供用户自定义 SQL 查询功能 - */ - -import { openDatabase } from '../core' - -// 查询超时时间(毫秒) -const QUERY_TIMEOUT_MS = 10000 - -/** - * SQL 执行结果 - */ -export interface SQLResult { - columns: string[] - rows: any[][] - rowCount: number - duration: number - limited: boolean // 是否被截断 -} - -/** - * 表结构信息 - */ -export interface TableSchema { - name: string - columns: { - name: string - type: string - notnull: boolean - pk: boolean - }[] -} - -/** - * 获取数据库 Schema - */ -export function getSchema(sessionId: string): TableSchema[] { - const db = openDatabase(sessionId) - if (!db) { - throw new Error('数据库不存在') - } - - // 获取所有表名 - const tables = db - .prepare( - `SELECT name FROM sqlite_master - WHERE type='table' AND name NOT LIKE 'sqlite_%' - ORDER BY name` - ) - .all() as { name: string }[] - - const schema: TableSchema[] = [] - - for (const table of tables) { - // 获取表的列信息 - const columns = db.prepare(`PRAGMA table_info('${table.name}')`).all() as { - cid: number - name: string - type: string - notnull: number - dflt_value: any - pk: number - }[] - - schema.push({ - name: table.name, - columns: columns.map((col) => ({ - name: col.name, - type: col.type, - notnull: col.notnull === 1, - pk: col.pk === 1, - })), - }) - } - - return schema -} - -/** - * 检查 SQL 是否包含 LIMIT 子句 - */ -function hasLimit(sql: string): boolean { - return /\bLIMIT\s+\d+/i.test(sql) -} - -/** - * 执行用户 SQL 查询 - * - 只支持 SELECT 语句 - * - 不强制 LIMIT,由用户自行控制 - * - 带超时控制(由 Worker 管理器控制) - */ -export function executeRawSQL(sessionId: string, sql: string): SQLResult { - const db = openDatabase(sessionId) - if (!db) { - throw new Error('数据库不存在') - } - - const trimmedSQL = sql.trim() - - // 只允许 SELECT 语句 - if (!trimmedSQL.toUpperCase().startsWith('SELECT')) { - throw new Error('只支持 SELECT 查询语句') - } - - // 执行查询 - const startTime = Date.now() - - try { - // better-sqlite3 是同步的,超时由 Worker 管理器控制 - const stmt = db.prepare(trimmedSQL) - const rows = stmt.all() - const duration = Date.now() - startTime - - // 获取列名 - const columns = stmt.columns().map((col) => col.name) - - // 将结果转换为二维数组 - const rowData = rows.map((row: any) => columns.map((col) => row[col])) - - return { - columns, - rows: rowData, - rowCount: rows.length, - duration, - limited: false, // 不再强制限制 - } - } catch (error) { - if (error instanceof Error) { - // 美化错误信息 - const message = error.message - .replace(/^SQLITE_ERROR: /, '') - .replace(/^SQLITE_READONLY: /, '只读模式:') - throw new Error(message) - } - throw error - } -} - diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts deleted file mode 100644 index 502cd0931..000000000 --- a/electron/main/worker/workerManager.ts +++ /dev/null @@ -1,496 +0,0 @@ -/** - * Worker 管理器 - * 负责创建、管理 Worker 线程,并处理与主进程的通信 - */ - -import { Worker } from 'worker_threads' -import { app } from 'electron' -import * as path from 'path' -import type { ParseProgress } from '../parser' -import { getDatabaseDir, ensureDir } from '../paths' - -// Worker 实例 -let worker: Worker | null = null - -// 等待中的请求 Map -const pendingRequests = new Map< - string, - { - resolve: (value: any) => void - reject: (error: Error) => void - onProgress?: (progress: ParseProgress) => void // 进度回调 - } ->() - -// 请求 ID 计数器 -let requestIdCounter = 0 - -/** - * 获取数据库目录 - */ -function getDbDir(): string { - const dir = getDatabaseDir() - ensureDir(dir) - return dir -} - -/** - * 获取 Worker 文件路径 - * 开发环境和生产环境路径不同 - */ -function getWorkerPath(): string { - // 检查是否在开发环境 - const isDev = !app.isPackaged - - if (isDev) { - // 开发环境:编译后的 JS 文件在 out/main 目录 - return path.join(__dirname, 'worker', 'dbWorker.js') - } else { - // 生产环境:打包后的路径 - return path.join(__dirname, 'worker', 'dbWorker.js') - } -} - -/** - * 初始化 Worker - */ -export function initWorker(): void { - if (worker) { - console.log('[WorkerManager] Worker already initialized') - return - } - - const workerPath = getWorkerPath() - console.log('[WorkerManager] Initializing worker at:', workerPath) - - try { - worker = new Worker(workerPath, { - workerData: { - dbDir: getDbDir(), - }, - }) - - // 监听 Worker 消息 - worker.on('message', (message) => { - const { id, type, success, result, error, payload } = message - - const pending = pendingRequests.get(id) - if (!pending) return - - // 处理进度消息(不删除 pending,因为还没完成) - if (type === 'progress') { - if (pending.onProgress) { - pending.onProgress(payload) - } - return - } - - // 处理完成或错误消息 - pendingRequests.delete(id) - - if (success) { - pending.resolve(result) - } else { - pending.reject(new Error(error)) - } - }) - - // 监听 Worker 错误 - worker.on('error', (error) => { - console.error('[WorkerManager] Worker error:', error) - }) - - // 监听 Worker 退出 - worker.on('exit', (code) => { - console.log('[WorkerManager] Worker exited with code:', code) - worker = null - - // 拒绝所有等待中的请求 - for (const [id, pending] of pendingRequests.entries()) { - pending.reject(new Error('Worker exited unexpectedly')) - pendingRequests.delete(id) - } - }) - - console.log('[WorkerManager] Worker initialized successfully') - } catch (error) { - console.error('[WorkerManager] Failed to initialize worker:', error) - throw error - } -} - -/** - * 发送消息到 Worker 并等待响应 - */ -function sendToWorker(type: string, payload: any): Promise { - return new Promise((resolve, reject) => { - if (!worker) { - try { - initWorker() - } catch (error) { - reject(new Error('Worker not initialized')) - return - } - } - - const id = `req_${++requestIdCounter}` - - pendingRequests.set(id, { resolve, reject }) - - worker!.postMessage({ id, type, payload }) - - // 设置超时(30秒) - setTimeout(() => { - if (pendingRequests.has(id)) { - pendingRequests.delete(id) - reject(new Error(`Worker request timeout: ${type}`)) - } - }, 30000) - }) -} - -/** - * 发送消息到 Worker 并等待响应(带进度回调) - * 用于流式导入等长时间操作 - */ -function sendToWorkerWithProgress( - type: string, - payload: any, - onProgress?: (progress: ParseProgress) => void, - timeoutMs: number = 600000 // 默认 10 分钟超时 -): Promise { - return new Promise((resolve, reject) => { - if (!worker) { - try { - initWorker() - } catch (error) { - reject(new Error('Worker not initialized')) - return - } - } - - const id = `req_${++requestIdCounter}` - - pendingRequests.set(id, { resolve, reject, onProgress }) - - worker!.postMessage({ id, type, payload }) - - // 设置超时 - setTimeout(() => { - if (pendingRequests.has(id)) { - pendingRequests.delete(id) - reject(new Error(`Worker request timeout: ${type}`)) - } - }, timeoutMs) - }) -} - -/** - * 关闭 Worker - */ -export function closeWorker(): void { - if (worker) { - // 先关闭所有数据库连接 - sendToWorker('closeAll', {}).catch(() => {}) - - worker.terminate() - worker = null - console.log('[WorkerManager] Worker terminated') - } -} - -// ==================== 导出的异步 API ==================== - -export async function getAvailableYears(sessionId: string): Promise { - return sendToWorker('getAvailableYears', { sessionId }) -} - -export async function getMemberActivity(sessionId: string, filter?: any): Promise { - return sendToWorker('getMemberActivity', { sessionId, filter }) -} - -export async function getHourlyActivity(sessionId: string, filter?: any): Promise { - return sendToWorker('getHourlyActivity', { sessionId, filter }) -} - -export async function getDailyActivity(sessionId: string, filter?: any): Promise { - return sendToWorker('getDailyActivity', { sessionId, filter }) -} - -export async function getWeekdayActivity(sessionId: string, filter?: any): Promise { - return sendToWorker('getWeekdayActivity', { sessionId, filter }) -} - -export async function getMonthlyActivity(sessionId: string, filter?: any): Promise { - return sendToWorker('getMonthlyActivity', { sessionId, filter }) -} - -export async function getMessageTypeDistribution(sessionId: string, filter?: any): Promise { - return sendToWorker('getMessageTypeDistribution', { sessionId, filter }) -} - -export async function getTimeRange(sessionId: string): Promise<{ start: number; end: number } | null> { - return sendToWorker('getTimeRange', { sessionId }) -} - -export async function getMemberNameHistory(sessionId: string, memberId: number): Promise { - return sendToWorker('getMemberNameHistory', { sessionId, memberId }) -} - -export async function getRepeatAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getRepeatAnalysis', { sessionId, filter }) -} - -export async function getCatchphraseAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getCatchphraseAnalysis', { sessionId, filter }) -} - -export async function getNightOwlAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getNightOwlAnalysis', { sessionId, filter }) -} - -export async function getDragonKingAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getDragonKingAnalysis', { sessionId, filter }) -} - -export async function getDivingAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getDivingAnalysis', { sessionId, filter }) -} - -export async function getMonologueAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getMonologueAnalysis', { sessionId, filter }) -} - -export async function getMentionAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getMentionAnalysis', { sessionId, filter }) -} - -export async function getLaughAnalysis(sessionId: string, filter?: any, keywords?: string[]): Promise { - return sendToWorker('getLaughAnalysis', { sessionId, filter, keywords }) -} - -export async function getMemeBattleAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getMemeBattleAnalysis', { sessionId, filter }) -} - -export async function getCheckInAnalysis(sessionId: string, filter?: any): Promise { - return sendToWorker('getCheckInAnalysis', { sessionId, filter }) -} - -export async function getAllSessions(): Promise { - return sendToWorker('getAllSessions', {}) -} - -export async function getSession(sessionId: string): Promise { - return sendToWorker('getSession', { sessionId }) -} - -export async function closeDatabase(sessionId: string): Promise { - return sendToWorker('closeDatabase', { sessionId }) -} - -// ==================== 成员管理 API ==================== - -export interface MemberWithStats { - id: number - platformId: string - name: string - nickname: string | null - aliases: string[] - messageCount: number -} - -/** - * 获取所有成员列表(含消息数和别名) - */ -export async function getMembers(sessionId: string): Promise { - return sendToWorker('getMembers', { sessionId }) -} - -/** - * 更新成员别名 - */ -export async function updateMemberAliases(sessionId: string, memberId: number, aliases: string[]): Promise { - return sendToWorker('updateMemberAliases', { sessionId, memberId, aliases }) -} - -/** - * 删除成员及其所有消息 - */ -export async function deleteMember(sessionId: string, memberId: number): Promise { - return sendToWorker('deleteMember', { sessionId, memberId }) -} - -/** - * 流式解析文件,写入临时数据库(用于合并功能) - * 返回基本信息和临时数据库路径 - */ -export async function streamParseFileInfo( - filePath: string, - onProgress?: (progress: ParseProgress) => void -): Promise<{ - name: string - format: string - platform: string - messageCount: number - memberCount: number - fileSize: number - tempDbPath: string -}> { - return sendToWorkerWithProgress('streamParseFileInfo', { filePath }, onProgress) -} - -/** - * 流式导入聊天记录 - * @param filePath 文件路径 - * @param onProgress 进度回调 - */ -export async function streamImport( - filePath: string, - onProgress?: (progress: ParseProgress) => void -): Promise<{ success: boolean; sessionId?: string; error?: string }> { - return sendToWorkerWithProgress('streamImport', { filePath }, onProgress) -} - -/** - * 获取数据库目录(供外部使用) - */ -export function getDbDirectory(): string { - return getDbDir() -} - -// ==================== AI 查询 API ==================== - -export interface SearchMessageResult { - id: number - senderName: string - senderPlatformId: string - senderAliases: string[] - senderAvatar: string | null - content: string - timestamp: number - type: number -} - -/** - * 关键词搜索消息 - */ -export async function searchMessages( - sessionId: string, - keywords: string[], - filter?: any, - limit?: number, - offset?: number, - senderId?: number -): Promise<{ messages: SearchMessageResult[]; total: number }> { - return sendToWorker('searchMessages', { sessionId, keywords, filter, limit, offset, senderId }) -} - -/** - * 获取消息上下文 - * 支持单个或批量消息 ID,返回合并去重后的上下文消息 - */ -export async function getMessageContext( - sessionId: string, - messageIds: number | number[], - contextSize?: number -): Promise { - return sendToWorker('getMessageContext', { sessionId, messageIds, contextSize }) -} - -/** - * 获取最近消息(用于概览性问题) - */ -export async function getRecentMessages( - sessionId: string, - filter?: any, - limit?: number -): Promise<{ messages: SearchMessageResult[]; total: number }> { - return sendToWorker('getRecentMessages', { sessionId, filter, limit }) -} - -/** - * 获取所有最近消息(消息查看器专用,包含所有类型消息) - */ -export async function getAllRecentMessages( - sessionId: string, - filter?: any, - limit?: number -): Promise<{ messages: SearchMessageResult[]; total: number }> { - return sendToWorker('getAllRecentMessages', { sessionId, filter, limit }) -} - -/** - * 获取两个成员之间的对话 - */ -export async function getConversationBetween( - sessionId: string, - memberId1: number, - memberId2: number, - filter?: any, - limit?: number -): Promise<{ messages: SearchMessageResult[]; total: number; member1Name: string; member2Name: string }> { - return sendToWorker('getConversationBetween', { sessionId, memberId1, memberId2, filter, limit }) -} - -/** - * 获取指定消息之前的 N 条消息(用于向上无限滚动) - */ -export async function getMessagesBefore( - sessionId: string, - beforeId: number, - limit?: number, - filter?: any, - senderId?: number, - keywords?: string[] -): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> { - return sendToWorker('getMessagesBefore', { sessionId, beforeId, limit, filter, senderId, keywords }) -} - -/** - * 获取指定消息之后的 N 条消息(用于向下无限滚动) - */ -export async function getMessagesAfter( - sessionId: string, - afterId: number, - limit?: number, - filter?: any, - senderId?: number, - keywords?: string[] -): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> { - return sendToWorker('getMessagesAfter', { sessionId, afterId, limit, filter, senderId, keywords }) -} - -// ==================== SQL 实验室 API ==================== - -export interface SQLResult { - columns: string[] - rows: any[][] - rowCount: number - duration: number - limited: boolean -} - -export interface TableSchema { - name: string - columns: { - name: string - type: string - notnull: boolean - pk: boolean - }[] -} - -/** - * 执行用户 SQL 查询 - */ -export async function executeRawSQL(sessionId: string, sql: string): Promise { - return sendToWorker('executeRawSQL', { sessionId, sql }) -} - -/** - * 获取数据库 Schema - */ -export async function getSchema(sessionId: string): Promise { - return sendToWorker('getSchema', { sessionId }) -} diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts deleted file mode 100644 index abd623227..000000000 --- a/electron/preload/index.d.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { ElectronAPI } from '@electron-toolkit/preload' -import type { AnalysisSession, MessageType, ImportProgress } from '../../src/types/base' -import type { - MemberActivity, - MemberNameHistory, - HourlyActivity, - DailyActivity, - WeekdayActivity, - MonthlyActivity, - RepeatAnalysis, - CatchphraseAnalysis, - NightOwlAnalysis, - DragonKingAnalysis, - DivingAnalysis, - MonologueAnalysis, - MentionAnalysis, - LaughAnalysis, - MemeBattleAnalysis, - CheckInAnalysis, - MemberWithStats, -} from '../../src/types/analysis' -import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../src/types/format' -import type { TableSchema, SQLResult } from '../../src/components/analysis/SQLLab/types' - -interface TimeFilter { - startTs?: number - endTs?: number -} - -// 迁移相关类型 -interface MigrationInfo { - version: number - description: string - userMessage: string -} - -interface MigrationCheckResult { - needsMigration: boolean - count: number - currentVersion: number - pendingMigrations: MigrationInfo[] -} - -// 格式诊断信息(简化版,用于前端显示) -interface FormatDiagnosisSimple { - suggestion: string - partialMatches: Array<{ - formatName: string - missingFields: string[] - }> -} - -interface ChatApi { - selectFile: () => Promise<{ - filePath?: string - format?: string - error?: string - diagnosis?: FormatDiagnosisSimple - } | null> - import: (filePath: string) => Promise<{ - success: boolean - sessionId?: string - error?: string - diagnosis?: FormatDiagnosisSimple - }> - getSessions: () => Promise - getSession: (sessionId: string) => Promise - deleteSession: (sessionId: string) => Promise - renameSession: (sessionId: string, newName: string) => Promise - // 迁移相关 - checkMigration: () => Promise - runMigration: () => Promise<{ success: boolean; error?: string }> - // 会话所有者 - updateSessionOwnerId: (sessionId: string, ownerId: string | null) => Promise - getAvailableYears: (sessionId: string) => Promise - getMemberActivity: (sessionId: string, filter?: TimeFilter) => Promise - getMemberNameHistory: (sessionId: string, memberId: number) => Promise - getHourlyActivity: (sessionId: string, filter?: TimeFilter) => Promise - getDailyActivity: (sessionId: string, filter?: TimeFilter) => Promise - getWeekdayActivity: (sessionId: string, filter?: TimeFilter) => Promise - getMonthlyActivity: (sessionId: string, filter?: TimeFilter) => Promise - getMessageTypeDistribution: ( - sessionId: string, - filter?: TimeFilter - ) => Promise> - getTimeRange: (sessionId: string) => Promise<{ start: number; end: number } | null> - getDbDirectory: () => Promise - getSupportedFormats: () => Promise> - onImportProgress: (callback: (progress: ImportProgress) => void) => () => void - getRepeatAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getCatchphraseAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getNightOwlAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getDragonKingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getDivingAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getMonologueAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getLaughAnalysis: (sessionId: string, filter?: TimeFilter, keywords?: string[]) => Promise - getMemeBattleAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - getCheckInAnalysis: (sessionId: string, filter?: TimeFilter) => Promise - // 成员管理 - getMembers: (sessionId: string) => Promise - updateMemberAliases: (sessionId: string, memberId: number, aliases: string[]) => Promise - deleteMember: (sessionId: string, memberId: number) => Promise - // SQL 实验室 - getSchema: (sessionId: string) => Promise - executeSQL: (sessionId: string, sql: string) => Promise -} - -interface Api { - send: (channel: string, data?: unknown) => void - receive: (channel: string, func: (...args: unknown[]) => void) => void - removeListener: (channel: string, func: (...args: unknown[]) => void) => void - dialog: { - showOpenDialog: (options: Electron.OpenDialogOptions) => Promise - } - clipboard: { - copyImage: (dataUrl: string) => Promise<{ success: boolean; error?: string }> - } - app: { - getVersion: () => Promise - checkUpdate: () => void - simulateUpdate: () => void - fetchRemoteConfig: (url: string) => Promise<{ success: boolean; data?: unknown; error?: string }> - getAnalyticsEnabled: () => Promise - setAnalyticsEnabled: (enabled: boolean) => Promise<{ success: boolean }> - } -} - -interface MergeApi { - parseFileInfo: (filePath: string) => Promise - checkConflicts: (filePaths: string[]) => Promise - mergeFiles: (params: MergeParams) => Promise - clearCache: (filePath?: string) => Promise - onParseProgress: (callback: (data: { filePath: string; progress: ImportProgress }) => void) => () => void -} - -// AI 相关类型 -interface SearchMessageResult { - id: number - senderName: string - senderPlatformId: string - senderAliases: string[] - senderAvatar: string | null - content: string - timestamp: number - type: number -} - -interface AIConversation { - id: string - sessionId: string - title: string | null - createdAt: number - updatedAt: number -} - -// 内容块类型(用于 AI 消息的混合渲染) -type AIContentBlock = - | { type: 'text'; text: string } - | { - type: 'tool' - tool: { - name: string - displayName: string - status: 'running' | 'done' | 'error' - params?: Record - } - } - -interface AIMessage { - id: string - conversationId: string - role: 'user' | 'assistant' - content: string - timestamp: number - dataKeywords?: string[] - dataMessageCount?: number - contentBlocks?: AIContentBlock[] -} - -interface AiApi { - searchMessages: ( - sessionId: string, - keywords: string[], - filter?: TimeFilter, - limit?: number, - offset?: number, - senderId?: number - ) => Promise<{ messages: SearchMessageResult[]; total: number }> - getMessageContext: ( - sessionId: string, - messageIds: number | number[], - contextSize?: number - ) => Promise - getRecentMessages: ( - sessionId: string, - filter?: TimeFilter, - limit?: number - ) => Promise<{ messages: SearchMessageResult[]; total: number }> - getAllRecentMessages: ( - sessionId: string, - filter?: TimeFilter, - limit?: number - ) => Promise<{ messages: SearchMessageResult[]; total: number }> - getConversationBetween: ( - sessionId: string, - memberId1: number, - memberId2: number, - filter?: TimeFilter, - limit?: number - ) => Promise<{ messages: SearchMessageResult[]; total: number; member1Name: string; member2Name: string }> - getMessagesBefore: ( - sessionId: string, - beforeId: number, - limit?: number, - filter?: TimeFilter, - senderId?: number, - keywords?: string[] - ) => Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> - getMessagesAfter: ( - sessionId: string, - afterId: number, - limit?: number, - filter?: TimeFilter, - senderId?: number, - keywords?: string[] - ) => Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> - createConversation: (sessionId: string, title?: string) => Promise - getConversations: (sessionId: string) => Promise - getConversation: (conversationId: string) => Promise - updateConversationTitle: (conversationId: string, title: string) => Promise - deleteConversation: (conversationId: string) => Promise - addMessage: ( - conversationId: string, - role: 'user' | 'assistant', - content: string, - dataKeywords?: string[], - dataMessageCount?: number, - contentBlocks?: AIContentBlock[] - ) => Promise - getMessages: (conversationId: string) => Promise - deleteMessage: (messageId: string) => Promise -} - -// LLM 相关类型 -interface LLMProviderInfo { - id: string - name: string - description: string - defaultBaseUrl: string - models: Array<{ id: string; name: string; description?: string }> -} - -// 单个 AI 服务配置(前端显示用,API Key 已脱敏) -interface AIServiceConfigDisplay { - id: string - name: string - provider: string - apiKey: string // 脱敏后的 API Key - apiKeySet: boolean - model?: string - baseUrl?: string - maxTokens?: number - createdAt: number - updatedAt: number -} - -interface LLMChatMessage { - role: 'system' | 'user' | 'assistant' - content: string -} - -interface LLMChatOptions { - temperature?: number - maxTokens?: number -} - -interface LLMChatStreamChunk { - content: string - isFinished: boolean - finishReason?: 'stop' | 'length' | 'error' -} - -interface LlmApi { - // 提供商 - getProviders: () => Promise - - // 多配置管理 API - getAllConfigs: () => Promise - getActiveConfigId: () => Promise - addConfig: (config: { - name: string - provider: string - apiKey: string - model?: string - baseUrl?: string - maxTokens?: number - disableThinking?: boolean - }) => Promise<{ success: boolean; config?: AIServiceConfigDisplay; error?: string }> - updateConfig: ( - id: string, - updates: { - name?: string - provider?: string - apiKey?: string - model?: string - baseUrl?: string - maxTokens?: number - disableThinking?: boolean - } - ) => Promise<{ success: boolean; error?: string }> - deleteConfig: (id?: string) => Promise<{ success: boolean; error?: string }> - setActiveConfig: (id: string) => Promise<{ success: boolean; error?: string }> - - // 验证和检查 - validateApiKey: ( - provider: string, - apiKey: string, - baseUrl?: string, - model?: string - ) => Promise<{ success: boolean; error?: string }> - hasConfig: () => Promise - - // 聊天功能 - chat: ( - messages: LLMChatMessage[], - options?: LLMChatOptions - ) => Promise<{ success: boolean; content?: string; error?: string }> - chatStream: ( - messages: LLMChatMessage[], - options?: LLMChatOptions, - onChunk?: (chunk: LLMChatStreamChunk) => void - ) => Promise<{ success: boolean; error?: string }> -} - -// Token 使用量类型 -interface TokenUsage { - promptTokens: number - completionTokens: number - totalTokens: number -} - -// Agent 相关类型 -interface AgentStreamChunk { - type: 'content' | 'tool_start' | 'tool_result' | 'done' | 'error' - content?: string - toolName?: string - toolParams?: Record - toolResult?: unknown - error?: string - isFinished?: boolean - /** Token 使用量(type=done 时返回累计值) */ - usage?: TokenUsage -} - -interface AgentResult { - content: string - toolsUsed: string[] - toolRounds: number - /** 总 Token 使用量(累计所有 LLM 调用) */ - totalUsage?: TokenUsage -} - -/** Owner 信息(当前用户在对话中的身份) */ -interface OwnerInfo { - /** Owner 的 platformId */ - platformId: string - /** Owner 的显示名称 */ - displayName: string -} - -interface ToolContext { - sessionId: string - timeFilter?: { startTs: number; endTs: number } - /** 用户配置:每次发送给 AI 的最大消息条数 */ - maxMessagesLimit?: number - /** Owner 信息(当前用户在对话中的身份) */ - ownerInfo?: OwnerInfo -} - -// 用户自定义提示词配置 -interface PromptConfig { - roleDefinition: string - responseRules: string -} - -interface AgentApi { - runStream: ( - userMessage: string, - context: ToolContext, - onChunk?: (chunk: AgentStreamChunk) => void, - historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, - chatType?: 'group' | 'private', - promptConfig?: PromptConfig, - locale?: string - ) => { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: string }> } - abort: (requestId: string) => Promise<{ success: boolean; error?: string }> -} - -// Cache API 类型 -interface CacheDirectoryInfo { - id: string - name: string - description: string - path: string - icon: string - canClear: boolean - size: number - fileCount: number - exists: boolean -} - -interface CacheInfo { - baseDir: string - directories: CacheDirectoryInfo[] - totalSize: number -} - -interface CacheApi { - getInfo: () => Promise - clear: (cacheId: string) => Promise<{ success: boolean; error?: string; message?: string }> - openDir: (cacheId: string) => Promise<{ success: boolean; error?: string }> - saveToDownloads: ( - filename: string, - dataUrl: string - ) => Promise<{ success: boolean; filePath?: string; error?: string }> - getLatestImportLog: () => Promise<{ success: boolean; path?: string; name?: string; error?: string }> - showInFolder: (filePath: string) => Promise<{ success: boolean; error?: string }> -} - -// Network API 类型 - 网络代理配置 -interface ProxyConfig { - enabled: boolean - url: string -} - -interface NetworkApi { - getProxyConfig: () => Promise - saveProxyConfig: (config: ProxyConfig) => Promise<{ success: boolean; error?: string }> - testProxyConnection: (proxyUrl: string) => Promise<{ success: boolean; error?: string }> -} - -declare global { - interface Window { - electron: ElectronAPI - api: Api - chatApi: ChatApi - mergeApi: MergeApi - aiApi: AiApi - llmApi: LlmApi - agentApi: AgentApi - cacheApi: CacheApi - networkApi: NetworkApi - } -} - -export { - ChatApi, - Api, - MergeApi, - AiApi, - LlmApi, - AgentApi, - CacheApi, - NetworkApi, - ProxyConfig, - SearchMessageResult, - AIConversation, - AIMessage, - LLMProviderInfo, - AIServiceConfigDisplay, - LLMChatMessage, - LLMChatOptions, - LLMChatStreamChunk, - AgentStreamChunk, - AgentResult, - ToolContext, - PromptConfig, - TokenUsage, - CacheDirectoryInfo, - CacheInfo, -} diff --git a/electron/preload/index.ts b/electron/preload/index.ts deleted file mode 100644 index 35f63ed97..000000000 --- a/electron/preload/index.ts +++ /dev/null @@ -1,1147 +0,0 @@ -import { contextBridge, ipcRenderer } from 'electron' -import { electronAPI } from '@electron-toolkit/preload' -import type { AnalysisSession, MessageType, ImportProgress } from '../../src/types/base' -import type { - MemberActivity, - MemberNameHistory, - HourlyActivity, - DailyActivity, - WeekdayActivity, - MonthlyActivity, - RepeatAnalysis, - CatchphraseAnalysis, - NightOwlAnalysis, - DragonKingAnalysis, - DivingAnalysis, - MonologueAnalysis, - MentionAnalysis, - LaughAnalysis, - CheckInAnalysis, - MemeBattleAnalysis, - MemberWithStats, -} from '../../src/types/analysis' -import type { FileParseInfo, ConflictCheckResult, MergeParams, MergeResult } from '../../src/types/format' - -// Custom APIs for renderer -const api = { - send: (channel: string, data?: unknown) => { - // whitelist channels - const validChannels = [ - 'show-message', - 'check-update', - 'simulate-update', - 'get-gpu-acceleration', - 'set-gpu-acceleration', - 'save-gpu-acceleration', - 'window-close', // 用户协议拒绝时退出应用 - ] - if (validChannels.includes(channel)) { - ipcRenderer.send(channel, data) - } - }, - receive: (channel: string, func: (...args: unknown[]) => void) => { - const validChannels = [ - 'show-message', - 'chat:importProgress', - 'merge:parseProgress', - 'llm:streamChunk', - 'agent:streamChunk', - 'agent:complete', - ] - if (validChannels.includes(channel)) { - // Deliberately strip event as it includes `sender` - ipcRenderer.on(channel, (_event, ...args) => func(...args)) - } - }, - removeListener: (channel: string, func: (...args: unknown[]) => void) => { - ipcRenderer.removeListener(channel, func) - }, -} - -// Chat Analysis API -const chatApi = { - // ==================== 数据库迁移 ==================== - - /** - * 检查是否需要数据库迁移 - */ - checkMigration: (): Promise<{ - needsMigration: boolean - count: number - currentVersion: number - pendingMigrations: Array<{ version: number; userMessage: string }> - }> => { - return ipcRenderer.invoke('chat:checkMigration') - }, - - /** - * 执行数据库迁移 - */ - runMigration: (): Promise<{ success: boolean; migratedCount: number; error?: string }> => { - return ipcRenderer.invoke('chat:runMigration') - }, - - // ==================== 聊天记录导入与分析 ==================== - - /** - * 选择聊天记录文件 - */ - selectFile: (): Promise<{ filePath?: string; format?: string; error?: string } | null> => { - return ipcRenderer.invoke('chat:selectFile') - }, - - /** - * 导入聊天记录 - */ - import: (filePath: string): Promise<{ success: boolean; sessionId?: string; error?: string }> => { - return ipcRenderer.invoke('chat:import', filePath) - }, - - /** - * 获取所有分析会话列表 - */ - getSessions: (): Promise => { - return ipcRenderer.invoke('chat:getSessions') - }, - - /** - * 获取单个会话信息 - */ - getSession: (sessionId: string): Promise => { - return ipcRenderer.invoke('chat:getSession', sessionId) - }, - - /** - * 删除会话 - */ - deleteSession: (sessionId: string): Promise => { - return ipcRenderer.invoke('chat:deleteSession', sessionId) - }, - - /** - * 重命名会话 - */ - renameSession: (sessionId: string, newName: string): Promise => { - return ipcRenderer.invoke('chat:renameSession', sessionId, newName) - }, - - /** - * 获取可用年份列表 - */ - getAvailableYears: (sessionId: string): Promise => { - return ipcRenderer.invoke('chat:getAvailableYears', sessionId) - }, - - /** - * 获取成员活跃度排行 - */ - getMemberActivity: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { - return ipcRenderer.invoke('chat:getMemberActivity', sessionId, filter) - }, - - /** - * 获取成员历史昵称 - */ - getMemberNameHistory: (sessionId: string, memberId: number): Promise => { - return ipcRenderer.invoke('chat:getMemberNameHistory', sessionId, memberId) - }, - - /** - * 获取每小时活跃度分布 - */ - getHourlyActivity: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { - return ipcRenderer.invoke('chat:getHourlyActivity', sessionId, filter) - }, - - /** - * 获取每日活跃度趋势 - */ - getDailyActivity: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { - return ipcRenderer.invoke('chat:getDailyActivity', sessionId, filter) - }, - - /** - * 获取星期活跃度分布 - */ - getWeekdayActivity: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { - return ipcRenderer.invoke('chat:getWeekdayActivity', sessionId, filter) - }, - - /** - * 获取月份活跃度分布 - */ - getMonthlyActivity: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { - return ipcRenderer.invoke('chat:getMonthlyActivity', sessionId, filter) - }, - - /** - * 获取消息类型分布 - */ - getMessageTypeDistribution: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise> => { - return ipcRenderer.invoke('chat:getMessageTypeDistribution', sessionId, filter) - }, - - /** - * 获取时间范围 - */ - getTimeRange: (sessionId: string): Promise<{ start: number; end: number } | null> => { - return ipcRenderer.invoke('chat:getTimeRange', sessionId) - }, - - /** - * 获取数据库存储目录 - */ - getDbDirectory: (): Promise => { - return ipcRenderer.invoke('chat:getDbDirectory') - }, - - /** - * 获取支持的格式列表 - */ - getSupportedFormats: (): Promise> => { - return ipcRenderer.invoke('chat:getSupportedFormats') - }, - - /** - * 监听导入进度 - */ - onImportProgress: (callback: (progress: ImportProgress) => void) => { - const handler = (_event: Electron.IpcRendererEvent, progress: ImportProgress) => { - callback(progress) - } - ipcRenderer.on('chat:importProgress', handler) - return () => { - ipcRenderer.removeListener('chat:importProgress', handler) - } - }, - - /** - * 获取复读分析数据 - */ - getRepeatAnalysis: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { - return ipcRenderer.invoke('chat:getRepeatAnalysis', sessionId, filter) - }, - - /** - * 获取口头禅分析数据 - */ - getCatchphraseAnalysis: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { - return ipcRenderer.invoke('chat:getCatchphraseAnalysis', sessionId, filter) - }, - - /** - * 获取夜猫分析数据 - */ - getNightOwlAnalysis: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { - return ipcRenderer.invoke('chat:getNightOwlAnalysis', sessionId, filter) - }, - - /** - * 获取龙王分析数据 - */ - getDragonKingAnalysis: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { - return ipcRenderer.invoke('chat:getDragonKingAnalysis', sessionId, filter) - }, - - /** - * 获取潜水分析数据 - */ - getDivingAnalysis: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { - return ipcRenderer.invoke('chat:getDivingAnalysis', sessionId, filter) - }, - - /** - * 获取自言自语分析数据 - */ - getMonologueAnalysis: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { - return ipcRenderer.invoke('chat:getMonologueAnalysis', sessionId, filter) - }, - - /** - * 获取 @ 互动分析数据 - */ - getMentionAnalysis: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { - return ipcRenderer.invoke('chat:getMentionAnalysis', sessionId, filter) - }, - - /** - * 获取含笑量分析数据 - */ - getLaughAnalysis: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number }, - keywords?: string[] - ): Promise => { - return ipcRenderer.invoke('chat:getLaughAnalysis', sessionId, filter, keywords) - }, - - /** - * 获取斗图分析数据 - */ - getMemeBattleAnalysis: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number } - ): Promise => { - return ipcRenderer.invoke('chat:getMemeBattleAnalysis', sessionId, filter) - }, - - /** - * 获取打卡分析数据(火花榜 + 忠臣榜) - */ - getCheckInAnalysis: (sessionId: string, filter?: { startTs?: number; endTs?: number }): Promise => { - return ipcRenderer.invoke('chat:getCheckInAnalysis', sessionId, filter) - }, - - // ==================== 成员管理 ==================== - - /** - * 获取所有成员列表(含消息数和别名) - */ - getMembers: (sessionId: string): Promise => { - return ipcRenderer.invoke('chat:getMembers', sessionId) - }, - - /** - * 更新成员别名 - */ - updateMemberAliases: (sessionId: string, memberId: number, aliases: string[]): Promise => { - return ipcRenderer.invoke('chat:updateMemberAliases', sessionId, memberId, aliases) - }, - - /** - * 删除成员及其所有消息 - */ - deleteMember: (sessionId: string, memberId: number): Promise => { - return ipcRenderer.invoke('chat:deleteMember', sessionId, memberId) - }, - - /** - * 更新会话的所有者(ownerId) - * @param ownerId 成员的 platformId,设置为 null 则清除 - */ - updateSessionOwnerId: (sessionId: string, ownerId: string | null): Promise => { - return ipcRenderer.invoke('chat:updateSessionOwnerId', sessionId, ownerId) - }, - - // ==================== SQL 实验室 ==================== - - /** - * 执行用户 SQL 查询 - */ - executeSQL: ( - sessionId: string, - sql: string - ): Promise<{ - columns: string[] - rows: any[][] - rowCount: number - duration: number - limited: boolean - }> => { - return ipcRenderer.invoke('chat:executeSQL', sessionId, sql) - }, - - /** - * 获取数据库 Schema - */ - getSchema: ( - sessionId: string - ): Promise< - Array<{ - name: string - columns: Array<{ - name: string - type: string - notnull: boolean - pk: boolean - }> - }> - > => { - return ipcRenderer.invoke('chat:getSchema', sessionId) - }, -} - -// Merge API - 合并功能 -const mergeApi = { - /** - * 解析文件获取基本信息(用于合并预览) - * 解析后结果会被缓存,后续合并时无需再次读取原始文件 - */ - parseFileInfo: (filePath: string): Promise => { - return ipcRenderer.invoke('merge:parseFileInfo', filePath) - }, - - /** - * 检测合并冲突 - */ - checkConflicts: (filePaths: string[]): Promise => { - return ipcRenderer.invoke('merge:checkConflicts', filePaths) - }, - - /** - * 执行合并 - */ - mergeFiles: (params: MergeParams): Promise => { - return ipcRenderer.invoke('merge:mergeFiles', params) - }, - - /** - * 清理解析缓存 - * @param filePath 可选,指定文件路径则清理该文件的缓存,否则清理所有缓存 - */ - clearCache: (filePath?: string): Promise => { - return ipcRenderer.invoke('merge:clearCache', filePath) - }, - - /** - * 监听解析进度(用于大文件) - */ - onParseProgress: (callback: (data: { filePath: string; progress: ImportProgress }) => void) => { - const handler = (_event: Electron.IpcRendererEvent, data: { filePath: string; progress: ImportProgress }) => { - callback(data) - } - ipcRenderer.on('merge:parseProgress', handler) - return () => { - ipcRenderer.removeListener('merge:parseProgress', handler) - } - }, -} - -// AI API - AI 功能 -interface SearchMessageResult { - id: number - senderName: string - senderPlatformId: string - senderAliases: string[] - senderAvatar: string | null - content: string - timestamp: number - type: number -} - -interface AIConversation { - id: string - sessionId: string - title: string | null - createdAt: number - updatedAt: number -} - -// 内容块类型(用于 AI 消息的混合渲染) -type ContentBlock = - | { type: 'text'; text: string } - | { - type: 'tool' - tool: { - name: string - displayName: string - status: 'running' | 'done' | 'error' - params?: Record - } - } - -interface AIMessage { - id: string - conversationId: string - role: 'user' | 'assistant' - content: string - timestamp: number - dataKeywords?: string[] - dataMessageCount?: number - contentBlocks?: ContentBlock[] -} - -const aiApi = { - /** - * 搜索消息(关键词搜索) - * @param senderId 可选的发送者成员 ID,用于筛选特定成员的消息 - */ - searchMessages: ( - sessionId: string, - keywords: string[], - filter?: { startTs?: number; endTs?: number }, - limit?: number, - offset?: number, - senderId?: number - ): Promise<{ messages: SearchMessageResult[]; total: number }> => { - return ipcRenderer.invoke('ai:searchMessages', sessionId, keywords, filter, limit, offset, senderId) - }, - - /** - * 获取消息上下文 - * @param messageIds 支持单个或批量消息 ID - */ - getMessageContext: ( - sessionId: string, - messageIds: number | number[], - contextSize?: number - ): Promise => { - return ipcRenderer.invoke('ai:getMessageContext', sessionId, messageIds, contextSize) - }, - - /** - * 获取最近消息(AI Agent 专用) - */ - getRecentMessages: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number }, - limit?: number - ): Promise<{ messages: SearchMessageResult[]; total: number }> => { - return ipcRenderer.invoke('ai:getRecentMessages', sessionId, filter, limit) - }, - - /** - * 获取所有最近消息(消息查看器专用) - */ - getAllRecentMessages: ( - sessionId: string, - filter?: { startTs?: number; endTs?: number }, - limit?: number - ): Promise<{ messages: SearchMessageResult[]; total: number }> => { - return ipcRenderer.invoke('ai:getAllRecentMessages', sessionId, filter, limit) - }, - - /** - * 获取两人之间的对话 - */ - getConversationBetween: ( - sessionId: string, - memberId1: number, - memberId2: number, - filter?: { startTs?: number; endTs?: number }, - limit?: number - ): Promise<{ messages: SearchMessageResult[]; total: number; member1Name: string; member2Name: string }> => { - return ipcRenderer.invoke('ai:getConversationBetween', sessionId, memberId1, memberId2, filter, limit) - }, - - /** - * 获取指定消息之前的 N 条(用于向上无限滚动) - */ - getMessagesBefore: ( - sessionId: string, - beforeId: number, - limit?: number, - filter?: { startTs?: number; endTs?: number }, - senderId?: number, - keywords?: string[] - ): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> => { - return ipcRenderer.invoke('ai:getMessagesBefore', sessionId, beforeId, limit, filter, senderId, keywords) - }, - - /** - * 获取指定消息之后的 N 条(用于向下无限滚动) - */ - getMessagesAfter: ( - sessionId: string, - afterId: number, - limit?: number, - filter?: { startTs?: number; endTs?: number }, - senderId?: number, - keywords?: string[] - ): Promise<{ messages: SearchMessageResult[]; hasMore: boolean }> => { - return ipcRenderer.invoke('ai:getMessagesAfter', sessionId, afterId, limit, filter, senderId, keywords) - }, - - /** - * 创建 AI 对话 - */ - createConversation: (sessionId: string, title?: string): Promise => { - return ipcRenderer.invoke('ai:createConversation', sessionId, title) - }, - - /** - * 获取会话的所有 AI 对话列表 - */ - getConversations: (sessionId: string): Promise => { - return ipcRenderer.invoke('ai:getConversations', sessionId) - }, - - /** - * 获取单个 AI 对话 - */ - getConversation: (conversationId: string): Promise => { - return ipcRenderer.invoke('ai:getConversation', conversationId) - }, - - /** - * 更新 AI 对话标题 - */ - updateConversationTitle: (conversationId: string, title: string): Promise => { - return ipcRenderer.invoke('ai:updateConversationTitle', conversationId, title) - }, - - /** - * 删除 AI 对话 - */ - deleteConversation: (conversationId: string): Promise => { - return ipcRenderer.invoke('ai:deleteConversation', conversationId) - }, - - /** - * 添加 AI 消息 - */ - addMessage: ( - conversationId: string, - role: 'user' | 'assistant', - content: string, - dataKeywords?: string[], - dataMessageCount?: number, - contentBlocks?: ContentBlock[] - ): Promise => { - return ipcRenderer.invoke( - 'ai:addMessage', - conversationId, - role, - content, - dataKeywords, - dataMessageCount, - contentBlocks - ) - }, - - /** - * 获取 AI 对话的所有消息 - */ - getMessages: (conversationId: string): Promise => { - return ipcRenderer.invoke('ai:getMessages', conversationId) - }, - - /** - * 删除 AI 消息 - */ - deleteMessage: (messageId: string): Promise => { - return ipcRenderer.invoke('ai:deleteMessage', messageId) - }, -} - -// LLM API - LLM 服务功能 -interface LLMProvider { - id: string - name: string - description: string - defaultBaseUrl: string - models: Array<{ id: string; name: string; description?: string }> -} - -interface ChatMessage { - role: 'system' | 'user' | 'assistant' - content: string -} - -interface ChatOptions { - temperature?: number - maxTokens?: number -} - -interface ChatStreamChunk { - content: string - isFinished: boolean - finishReason?: 'stop' | 'length' | 'error' -} - -// Agent API 类型定义 -interface AgentStreamChunk { - type: 'content' | 'tool_start' | 'tool_result' | 'done' | 'error' - content?: string - toolName?: string - toolParams?: Record - toolResult?: unknown - error?: string - isFinished?: boolean -} - -interface AgentResult { - content: string - toolsUsed: string[] - toolRounds: number -} - -interface ToolContext { - sessionId: string - timeFilter?: { startTs: number; endTs: number } -} - -// AI 服务配置类型(前端用) -interface AIServiceConfigDisplay { - id: string - name: string - provider: string - apiKey: string // 脱敏后的 API Key - apiKeySet: boolean - model?: string - baseUrl?: string - maxTokens?: number - createdAt: number - updatedAt: number -} - -const llmApi = { - /** - * 获取所有支持的 LLM 提供商 - */ - getProviders: (): Promise => { - return ipcRenderer.invoke('llm:getProviders') - }, - - // ==================== 多配置管理 API ==================== - - /** - * 获取所有配置列表 - */ - getAllConfigs: (): Promise => { - return ipcRenderer.invoke('llm:getAllConfigs') - }, - - /** - * 获取当前激活的配置 ID - */ - getActiveConfigId: (): Promise => { - return ipcRenderer.invoke('llm:getActiveConfigId') - }, - - /** - * 添加新配置 - */ - addConfig: (config: { - name: string - provider: string - apiKey: string - model?: string - baseUrl?: string - maxTokens?: number - }): Promise<{ success: boolean; config?: AIServiceConfigDisplay; error?: string }> => { - return ipcRenderer.invoke('llm:addConfig', config) - }, - - /** - * 更新配置 - */ - updateConfig: ( - id: string, - updates: { - name?: string - provider?: string - apiKey?: string - model?: string - baseUrl?: string - maxTokens?: number - } - ): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('llm:updateConfig', id, updates) - }, - - /** - * 删除配置 - */ - deleteConfig: (id?: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('llm:deleteConfig', id) - }, - - /** - * 设置激活的配置 - */ - setActiveConfig: (id: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('llm:setActiveConfig', id) - }, - - /** - * 验证 API Key(支持自定义 baseUrl 和 model) - */ - validateApiKey: (provider: string, apiKey: string, baseUrl?: string, model?: string): Promise => { - return ipcRenderer.invoke('llm:validateApiKey', provider, apiKey, baseUrl, model) - }, - - /** - * 检查是否已配置 LLM(是否有激活的配置) - */ - hasConfig: (): Promise => { - return ipcRenderer.invoke('llm:hasConfig') - }, - - /** - * 发送 LLM 聊天请求(非流式) - */ - chat: ( - messages: ChatMessage[], - options?: ChatOptions - ): Promise<{ success: boolean; content?: string; error?: string }> => { - return ipcRenderer.invoke('llm:chat', messages, options) - }, - - /** - * 发送 LLM 聊天请求(流式) - * 返回一个 Promise,该 Promise 在流完成后才 resolve - */ - chatStream: ( - messages: ChatMessage[], - options?: ChatOptions, - onChunk?: (chunk: ChatStreamChunk) => void - ): Promise<{ success: boolean; error?: string }> => { - return new Promise((resolve) => { - const requestId = `llm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - console.log('[preload] chatStream 开始,requestId:', requestId) - - const handler = ( - _event: Electron.IpcRendererEvent, - data: { requestId: string; chunk: ChatStreamChunk; error?: string } - ) => { - if (data.requestId === requestId) { - if (data.error) { - console.log('[preload] chatStream 收到错误:', data.error) - if (onChunk) { - onChunk({ content: '', isFinished: true, finishReason: 'error' }) - } - ipcRenderer.removeListener('llm:streamChunk', handler) - resolve({ success: false, error: data.error }) - } else { - if (onChunk) { - onChunk(data.chunk) - } - - // 如果已完成,移除监听器并 resolve - if (data.chunk.isFinished) { - console.log('[preload] chatStream 完成,requestId:', requestId) - ipcRenderer.removeListener('llm:streamChunk', handler) - resolve({ success: true }) - } - } - } - } - - ipcRenderer.on('llm:streamChunk', handler) - - // 发起请求 - ipcRenderer - .invoke('llm:chatStream', requestId, messages, options) - .then((result) => { - console.log('[preload] chatStream invoke 返回:', result) - if (!result.success) { - ipcRenderer.removeListener('llm:streamChunk', handler) - resolve(result) - } - // 如果 success,等待流完成(由 handler 处理 resolve) - }) - .catch((error) => { - console.error('[preload] chatStream invoke 错误:', error) - ipcRenderer.removeListener('llm:streamChunk', handler) - resolve({ success: false, error: String(error) }) - }) - }) - }, -} - -// 用户自定义提示词配置 -interface PromptConfig { - roleDefinition: string - responseRules: string -} - -// Agent API - AI Agent 功能(带 Function Calling) -const agentApi = { - /** - * 执行 Agent 对话(流式) - * Agent 会自动调用工具获取数据并生成回答 - * @param historyMessages 对话历史(可选,用于上下文关联) - * @param chatType 聊天类型('group' | 'private') - * @param promptConfig 用户自定义提示词配置(可选) - * @param locale 语言设置(可选,默认 'zh-CN') - * @returns 返回 { requestId, promise },requestId 可用于中止请求 - */ - runStream: ( - userMessage: string, - context: ToolContext, - onChunk?: (chunk: AgentStreamChunk) => void, - historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, - chatType?: 'group' | 'private', - promptConfig?: PromptConfig, - locale?: string - ): { requestId: string; promise: Promise<{ success: boolean; result?: AgentResult; error?: string }> } => { - const requestId = `agent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - console.log( - '[preload] Agent runStream 开始,requestId:', - requestId, - 'historyLength:', - historyMessages?.length ?? 0, - 'chatType:', - chatType ?? 'group', - 'hasPromptConfig:', - !!promptConfig - ) - - const promise = new Promise<{ success: boolean; result?: AgentResult; error?: string }>((resolve) => { - // 监听流式 chunks - const chunkHandler = ( - _event: Electron.IpcRendererEvent, - data: { requestId: string; chunk: AgentStreamChunk } - ) => { - if (data.requestId === requestId) { - if (onChunk) { - onChunk(data.chunk) - } - } - } - - // 监听完成事件 - const completeHandler = ( - _event: Electron.IpcRendererEvent, - data: { requestId: string; result: AgentResult & { error?: string } } - ) => { - if (data.requestId === requestId) { - console.log('[preload] Agent 完成,requestId:', requestId, 'hasError:', !!data.result?.error) - ipcRenderer.removeListener('agent:streamChunk', chunkHandler) - ipcRenderer.removeListener('agent:complete', completeHandler) - // 如果 result 中包含 error,返回失败状态 - if (data.result?.error) { - resolve({ success: false, error: data.result.error }) - } else { - resolve({ success: true, result: data.result }) - } - } - } - - ipcRenderer.on('agent:streamChunk', chunkHandler) - ipcRenderer.on('agent:complete', completeHandler) - - // 发起请求(传递历史消息、聊天类型、提示词配置和语言设置) - ipcRenderer - .invoke('agent:runStream', requestId, userMessage, context, historyMessages, chatType, promptConfig, locale) - .then((result) => { - console.log('[preload] Agent invoke 返回:', result) - if (!result.success) { - ipcRenderer.removeListener('agent:streamChunk', chunkHandler) - ipcRenderer.removeListener('agent:complete', completeHandler) - resolve(result) - } - // 如果 success,等待完成(由 completeHandler 处理 resolve) - }) - .catch((error) => { - console.error('[preload] Agent invoke 错误:', error) - ipcRenderer.removeListener('agent:streamChunk', chunkHandler) - ipcRenderer.removeListener('agent:complete', completeHandler) - resolve({ success: false, error: String(error) }) - }) - }) - - return { requestId, promise } - }, - - /** - * 中止 Agent 请求 - * @param requestId 请求 ID - */ - abort: (requestId: string): Promise<{ success: boolean; error?: string }> => { - console.log('[preload] Agent abort 请求,requestId:', requestId) - return ipcRenderer.invoke('agent:abort', requestId) - }, -} - -// Network API - 网络设置 -interface ProxyConfig { - enabled: boolean - url: string -} - -const networkApi = { - /** - * 获取代理配置 - */ - getProxyConfig: (): Promise => { - return ipcRenderer.invoke('network:getProxyConfig') - }, - - /** - * 保存代理配置 - */ - saveProxyConfig: (config: ProxyConfig): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('network:saveProxyConfig', config) - }, - - /** - * 测试代理连接 - */ - testProxyConnection: (proxyUrl: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('network:testProxyConnection', proxyUrl) - }, -} - -// Cache API - 缓存管理 -interface CacheDirectoryInfo { - id: string - name: string - description: string - path: string - icon: string - canClear: boolean - size: number - fileCount: number - exists: boolean -} - -interface CacheInfo { - baseDir: string - directories: CacheDirectoryInfo[] - totalSize: number -} - -const cacheApi = { - /** - * 获取所有缓存目录信息 - */ - getInfo: (): Promise => { - return ipcRenderer.invoke('cache:getInfo') - }, - - /** - * 清理指定缓存目录 - */ - clear: (cacheId: string): Promise<{ success: boolean; error?: string; message?: string }> => { - return ipcRenderer.invoke('cache:clear', cacheId) - }, - - /** - * 在文件管理器中打开缓存目录 - */ - openDir: (cacheId: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('cache:openDir', cacheId) - }, - - /** - * 保存文件到下载目录 - */ - saveToDownloads: ( - filename: string, - dataUrl: string - ): Promise<{ success: boolean; filePath?: string; error?: string }> => { - return ipcRenderer.invoke('cache:saveToDownloads', filename, dataUrl) - }, - - /** - * 获取最新的导入日志文件路径 - */ - getLatestImportLog: (): Promise<{ success: boolean; path?: string; name?: string; error?: string }> => { - return ipcRenderer.invoke('cache:getLatestImportLog') - }, - - /** - * 在文件管理器中显示并高亮文件 - */ - showInFolder: (filePath: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('cache:showInFolder', filePath) - }, -} - -// 扩展 api,添加 dialog、clipboard 和应用功能 -const extendedApi = { - ...api, - dialog: { - showOpenDialog: (options: Electron.OpenDialogOptions): Promise => { - return ipcRenderer.invoke('dialog:showOpenDialog', options) - }, - }, - clipboard: { - /** - * 复制图片到系统剪贴板 - * @param dataUrl 图片的 base64 data URL - */ - copyImage: (dataUrl: string): Promise<{ success: boolean; error?: string }> => { - return ipcRenderer.invoke('copyImage', dataUrl) - }, - }, - app: { - /** - * 获取应用版本号 - */ - getVersion: (): Promise => { - return ipcRenderer.invoke('app:getVersion') - }, - /** - * 检查更新 - */ - checkUpdate: (): void => { - ipcRenderer.send('check-update') - }, - /** - * 模拟更新弹窗(仅开发模式) - */ - simulateUpdate: (): void => { - ipcRenderer.send('simulate-update') - }, - /** - * 获取远程配置(通过主进程请求,绕过 CORS) - */ - fetchRemoteConfig: (url: string): Promise<{ success: boolean; data?: unknown; error?: string }> => { - return ipcRenderer.invoke('app:fetchRemoteConfig', url) - }, - /** - * 获取匿名统计开关状态 - */ - getAnalyticsEnabled: (): Promise => { - return ipcRenderer.invoke('analytics:getEnabled') - }, - /** - * 设置匿名统计开关状态 - */ - setAnalyticsEnabled: (enabled: boolean): Promise<{ success: boolean }> => { - return ipcRenderer.invoke('analytics:setEnabled', enabled) - }, - }, -} - -// Use `contextBridge` APIs to expose Electron APIs to -// renderer only if context isolation is enabled, otherwise -// just add to the DOM global. -if (process.contextIsolated) { - try { - contextBridge.exposeInMainWorld('electron', electronAPI) - contextBridge.exposeInMainWorld('api', extendedApi) - contextBridge.exposeInMainWorld('chatApi', chatApi) - contextBridge.exposeInMainWorld('mergeApi', mergeApi) - contextBridge.exposeInMainWorld('aiApi', aiApi) - contextBridge.exposeInMainWorld('llmApi', llmApi) - contextBridge.exposeInMainWorld('agentApi', agentApi) - contextBridge.exposeInMainWorld('cacheApi', cacheApi) - contextBridge.exposeInMainWorld('networkApi', networkApi) - } catch (error) { - console.error(error) - } -} else { - // @ts-ignore (define in dts) - window.electron = electronAPI - // @ts-ignore (define in dts) - window.api = extendedApi - // @ts-ignore (define in dts) - window.chatApi = chatApi - // @ts-ignore (define in dts) - window.mergeApi = mergeApi - // @ts-ignore (define in dts) - window.aiApi = aiApi - // @ts-ignore (define in dts) - window.llmApi = llmApi - // @ts-ignore (define in dts) - window.agentApi = agentApi - // @ts-ignore (define in dts) - window.cacheApi = cacheApi - // @ts-ignore (define in dts) - window.networkApi = networkApi -} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 000000000..d0359525e --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,71 @@ +import js from '@eslint/js' +import pluginVue from 'eslint-plugin-vue' +import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' +import vuePrettierConfig from '@vue/eslint-config-prettier' +import globals from 'globals' + +export default defineConfigWithVueTs( + { + ignores: ['node_modules', 'dist', 'out', '.gitignore'], + }, + + // ESLint 推荐规则 + js.configs.recommended, + + // Vue 3 推荐规则(flat config 格式) + ...pluginVue.configs['flat/recommended'], + + // Vue + TypeScript 推荐规则 + vueTsConfigs.recommended, + + // 全局环境变量(替代 @electron-toolkit 基础配置中的 env 设置) + { + languageOptions: { + ecmaVersion: 2021, + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.node, + ...globals.commonjs, + ...globals.es2021, + }, + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + }, + + // Prettier(@vue/eslint-config-prettier v10+ 已是 flat config 格式) + vuePrettierConfig, + + // 自定义规则 + { + rules: { + // Vue 规则放宽 + 'vue/require-default-prop': 'off', + 'vue/multi-word-component-names': 'off', + // 项目中有受控的 HTML 渲染场景(如 Markdown/高亮结果),统一关闭该告警。 + 'vue/no-v-html': 'off', + + // TypeScript 规则放宽(项目约定) + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + + // 来自 @electron-toolkit/eslint-config-ts/eslint-recommended + '@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': 'allow-with-description' }], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-empty-function': ['error', { allow: ['arrowFunctions'] }], + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-var-requires': 'off', + }, + }, + + // E2E Node helper/test 使用 CommonJS,允许 require + { + files: ['tests/e2e/**/*.js'], + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + } +) diff --git a/package.json b/package.json index 1c3544ed7..32da64a6e 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,18 @@ { "name": "ChatLab", - "version": "0.3.1", - "description": "本地聊天分析实验室", + "version": "0.29.0", + "private": true, + "description": "本地化的聊天记录分析工具,通过 SQL 和 AI Agent 回顾你的社交记忆", "repository": { "type": "git", - "url": "git+https://github.com/hellodigua/ChatLab.git" + "url": "git+https://github.com/ChatLab/ChatLab.git" + }, + "author": "digua ", + "packageManager": "pnpm@9.15.9", + "engines": { + "node": ">=24 <25", + "pnpm": ">=9 <10" }, - "author": "", - "main": "./out/main/index.js", "pnpm": { "onlyBuiltDependencies": [ "better-sqlite3", @@ -15,59 +20,62 @@ ] }, "scripts": { - "dev": "electron-vite dev", - "preview": "electron-vite preview", + "dev": "node scripts/dev-select.mjs", + "dev:desktop": "pnpm --filter @openchatlab/desktop dev", + "build:desktop": "pnpm --filter @openchatlab/desktop build", + "build:mac": "pnpm --filter @openchatlab/desktop build:mac", + "build:win": "pnpm --filter @openchatlab/desktop build:win", + "dev:serve": "bash scripts/dev-serve.sh", + "dev:web": "pnpm run ensure:server-native && CHATLAB_AUTO_SERVE=1 vite --config vite.web.config.mts", + "ensure:server-native": "pnpm --filter chatlab-cli run ensure-native", + "build:web": "vite build --config vite.web.config.mts", + "docs:dev": "pnpm --filter @openchatlab/docs dev", + "docs:build": "pnpm --filter @openchatlab/docs build", + "docs:preview": "pnpm --filter @openchatlab/docs preview", "format": "prettier --write .", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", - "build": "electron-vite build", - "build:mac": "npm run build && electron-builder --mac --config electron-builder.yml -p never", - "build:win": "npm run build && electron-builder --win --config electron-builder.yml -p never", + "type-check:web": "vue-tsc --noEmit -p tsconfig.web.json", + "type-check:node": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p apps/desktop/tsconfig.node.json", + "type-check:all": "pnpm run type-check:web && pnpm run type-check:node", "type-check": "vue-tsc --noEmit -p tsconfig.web.json", - "postinstall": "electron-rebuild" + "test": "node scripts/run-tests.mjs", + "test:unit": "node scripts/run-tests.mjs", + "test:auth": "tsx --test apps/cli/src/http/auth.test.ts src/services/utils/http.test.ts", + "test:agent-context": "node --experimental-strip-types --test apps/desktop/main/ai/context/sessionLog.test.mjs", + "test:e2e:launcher": "node --test tests/e2e/helpers/app-launcher.test.js", + "test:e2e:smoke": "node tests/e2e/run-smoke.js" }, "dependencies": { - "@aptabase/electron": "^0.3.1", - "@electron-toolkit/preload": "^3.0.1", - "@electron-toolkit/utils": "^4.0.0", - "@types/markdown-it": "^14.1.2", + "@tanstack/vue-virtual": "^3.13.18", "@zumer/snapdom": "^2.0.1", - "better-sqlite3": "^12.4.6", - "electron-updater": "^6.6.2", + "echarts": "^6.0.0", + "echarts-wordcloud": "^2.1.0", "markdown-it": "^14.1.0", - "stream-json": "^1.9.1", + "pixi-viewport": "^6.0.3", + "pixi.js": "^8.19.0", + "three": "^0.185.0", "vue-i18n": "^11.2.8" }, "devDependencies": { - "@electron-toolkit/eslint-config": "^1.0.2", - "@electron-toolkit/eslint-config-ts": "^2.0.0", "@electron-toolkit/tsconfig": "^1.0.1", - "@electron/rebuild": "^4.0.1", - "@intlify/unplugin-vue-i18n": "^11.0.3", "@nuxt/ui": "^4.2.1", - "@rushstack/eslint-patch": "^1.15.0", "@tailwindcss/vite": "^4.0.0", - "@types/better-sqlite3": "^7.6.13", - "@types/stream-json": "^1.7.8", - "@vitejs/plugin-vue": "^5.2.3", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^6.0.0", "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.6.0", "@vueuse/core": "^13.9.0", - "axios": "^1.13.2", - "chart.js": "^4.5.1", - "cross-env": "^7.0.3", "dayjs": "^1.11.19", - "electron": "^35.0.0", - "electron-builder": "^26.0.12", - "electron-vite": "^3.0.0", "eslint": "^9.39.1", "eslint-plugin-vue": "^9.33.0", - "mitt": "^3.0.1", "pinia": "^3.0.4", "pinia-plugin-persistedstate": "^4.7.1", "prettier": "^3.5.3", "tailwindcss": "^4.0.0", - "vite": "^6.3.5", + "typescript": "^5.8.3", + "vite": "^7.0.0", "vue": "^3.5.25", - "vue-chartjs": "^5.3.3", - "vue-router": "^4.6.3" + "vue-router": "^4.6.3", + "vue-tsc": "^3.1.1" } } diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 000000000..332f06737 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,13 @@ +{ + "name": "@openchatlab/config", + "version": "0.0.0", + "private": true, + "description": "ChatLab 配置读取层:TOML/JSON 文件 + 环境变量 + Zod 校验", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "smol-toml": "^1.3.1", + "zod": "^3.24.4" + } +} diff --git a/packages/config/src/auth-profiles.ts b/packages/config/src/auth-profiles.ts new file mode 100644 index 000000000..5df9bf355 --- /dev/null +++ b/packages/config/src/auth-profiles.ts @@ -0,0 +1,110 @@ +/** + * auth-profiles.json 凭证管理 + * + * 存储位置:~/.chatlab/auth-profiles.json + * 用途:将 API Key 等敏感凭证与主配置文件分离 + */ + +import * as fs from 'fs' +import * as path from 'path' +import { getConfigDir } from './loader' + +export interface AuthProfile { + type: 'api_key' + provider: string + key: string +} + +export interface AuthProfilesData { + version: number + profiles: Record +} + +const AUTH_PROFILES_FILE = 'auth-profiles.json' + +function getAuthProfilesPath(): string { + return path.join(getConfigDir(), AUTH_PROFILES_FILE) +} + +/** + * 加载 auth-profiles.json + */ +export function loadAuthProfiles(): AuthProfilesData { + const filePath = getAuthProfilesPath() + if (!fs.existsSync(filePath)) { + return { version: 1, profiles: {} } + } + + try { + const content = fs.readFileSync(filePath, 'utf-8') + const data = JSON.parse(content) as AuthProfilesData + if (!data.profiles || typeof data.profiles !== 'object') { + return { version: 1, profiles: {} } + } + return data + } catch { + return { version: 1, profiles: {} } + } +} + +/** + * 按 profile 名称获取 API Key + */ +export function getApiKeyByProfile(profileName: string): string { + const data = loadAuthProfiles() + const profile = data.profiles[profileName] + return profile?.key || '' +} + +/** + * 按 provider 名称获取 API Key(模糊匹配,取第一个匹配的 profile) + */ +export function getApiKeyByProvider(provider: string): string { + const data = loadAuthProfiles() + for (const profile of Object.values(data.profiles)) { + if (profile.provider === provider) { + return profile.key || '' + } + } + return '' +} + +/** + * 查找 API Key:先按 authProfile 精确匹配,再按 provider 兜底 + */ +export function resolveApiKey(provider: string, authProfile?: string): string { + if (authProfile) { + const key = getApiKeyByProfile(authProfile) + if (key) return key + } + return getApiKeyByProvider(provider) +} + +/** + * 写入/更新一个 auth profile + */ +export function writeAuthProfile(name: string, profile: AuthProfile): void { + const configDir = getConfigDir() + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }) + } + + const data = loadAuthProfiles() + data.profiles[name] = profile + + const filePath = getAuthProfilesPath() + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 }) +} + +/** + * 删除一个 auth profile + */ +export function deleteAuthProfile(name: string): boolean { + const data = loadAuthProfiles() + if (!(name in data.profiles)) return false + + delete data.profiles[name] + const filePath = getAuthProfilesPath() + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 }) + return true +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts new file mode 100644 index 000000000..bd13fa4b0 --- /dev/null +++ b/packages/config/src/index.ts @@ -0,0 +1,20 @@ +/** + * @openchatlab/config + * + * ChatLab 配置管理:TOML/JSON 文件读取 + CHATLAB_* 环境变量覆盖 + Zod 校验。 + */ + +export { loadConfig, getConfigPath, getConfigDir, writeConfigField } from './loader' +export { configSchema, DEFAULT_API_PORT } from './schema' +export type { ChatLabConfig, LlmConfig, DataConfig, ApiConfig, LocaleConfig, UiConfig } from './schema' +export { + loadAuthProfiles, + getApiKeyByProfile, + getApiKeyByProvider, + resolveApiKey, + writeAuthProfile, + deleteAuthProfile, +} from './auth-profiles' +export type { AuthProfile, AuthProfilesData } from './auth-profiles' +export { MigrationRunner, ALL_MIGRATIONS } from './migrations' +export type { Migration, MigrationContext, Logger as MigrationLogger } from './migrations' diff --git a/packages/config/src/loader.ts b/packages/config/src/loader.ts new file mode 100644 index 000000000..fba0ea32b --- /dev/null +++ b/packages/config/src/loader.ts @@ -0,0 +1,161 @@ +/** + * 配置加载器 + * + * 优先级(从高到低): + * 1. CHATLAB_* 环境变量 + * 2. ~/.chatlab/config.toml(或 config.json) + * 3. Zod schema 默认值 + */ + +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { parse as parseToml } from 'smol-toml' +import { configSchema, type ChatLabConfig } from './schema' + +const CONFIG_DIR = path.join(os.homedir(), '.chatlab') +const CONFIG_TOML = path.join(CONFIG_DIR, 'config.toml') +const CONFIG_JSON = path.join(CONFIG_DIR, 'config.json') + +/** + * 加载完整配置 + */ +export function loadConfig(overrides?: Partial): ChatLabConfig { + const fileConfig = loadConfigFile() + const envConfig = loadEnvConfig() + + const merged = deepMerge(deepMerge(fileConfig, envConfig), overrides ?? {}) + return configSchema.parse(merged) +} + +/** + * 获取配置文件路径(返回实际存在的路径或默认 TOML 路径) + */ +export function getConfigPath(): string { + if (fs.existsSync(CONFIG_TOML)) return CONFIG_TOML + if (fs.existsSync(CONFIG_JSON)) return CONFIG_JSON + return CONFIG_TOML +} + +/** + * 获取配置目录路径 + */ +export function getConfigDir(): string { + return CONFIG_DIR +} + +/** + * 从文件加载配置 + */ +function loadConfigFile(): Record { + if (fs.existsSync(CONFIG_TOML)) { + try { + const content = fs.readFileSync(CONFIG_TOML, 'utf-8') + return parseToml(content) as Record + } catch (err) { + console.warn(`[Config] Failed to parse ${CONFIG_TOML}:`, err) + } + } + + if (fs.existsSync(CONFIG_JSON)) { + try { + const content = fs.readFileSync(CONFIG_JSON, 'utf-8') + return JSON.parse(content) as Record + } catch (err) { + console.warn(`[Config] Failed to parse ${CONFIG_JSON}:`, err) + } + } + + return {} +} + +/** + * 从 CHATLAB_* 环境变量加载配置 + * + * 映射规则: + * - CHATLAB_DATA_DIR -> data.user_data_dir + * - CHATLAB_API_PORT -> api.port + * - CHATLAB_API_HOST -> api.host + * - CHATLAB_LLM_PROVIDER -> llm.provider + * - CHATLAB_LLM_MODEL -> llm.model + * - CHATLAB_LLM_BASE_URL -> llm.base_url + * - CHATLAB_LOCALE_LANG -> locale.lang + */ +function loadEnvConfig(): Record { + const result: Record> = {} + + const envMap: Array<{ env: string; section: string; key: string; transform?: (v: string) => unknown }> = [ + { env: 'CHATLAB_DATA_DIR', section: 'data', key: 'user_data_dir' }, + { env: 'CHATLAB_API_PORT', section: 'api', key: 'port', transform: (v) => parseInt(v, 10) }, + { env: 'CHATLAB_API_HOST', section: 'api', key: 'host' }, + { env: 'CHATLAB_LLM_PROVIDER', section: 'llm', key: 'provider' }, + { env: 'CHATLAB_LLM_MODEL', section: 'llm', key: 'model' }, + { env: 'CHATLAB_LLM_BASE_URL', section: 'llm', key: 'base_url' }, + { env: 'CHATLAB_LOCALE_LANG', section: 'locale', key: 'lang' }, + ] + + for (const { env, section, key, transform } of envMap) { + const value = process.env[env] + if (value !== undefined && value !== '') { + if (!result[section]) result[section] = {} + result[section][key] = transform ? transform(value) : value + } + } + + return result +} + +/** + * 写入 config.toml 的某个字段 + * + * 如果文件不存在则创建,如果已存在则保留其他内容,只更新指定字段。 + * 采用简单的 TOML 序列化:仅支持一级 section + 字符串/数字值。 + */ +export function writeConfigField(section: string, key: string, value: string | number | boolean): void { + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }) + } + + let existing: Record> = {} + if (fs.existsSync(CONFIG_TOML)) { + try { + const content = fs.readFileSync(CONFIG_TOML, 'utf-8') + existing = parseToml(content) as Record> + } catch { + // 解析失败时从空开始,旧文件会被覆盖 + } + } + + if (!existing[section] || typeof existing[section] !== 'object') { + existing[section] = {} + } + existing[section][key] = value + + const lines: string[] = [] + for (const [sec, entries] of Object.entries(existing)) { + if (typeof entries !== 'object' || entries === null) continue + lines.push(`[${sec}]`) + for (const [k, v] of Object.entries(entries as Record)) { + if (typeof v === 'string') { + lines.push(`${k} = ${JSON.stringify(v)}`) + } else if (typeof v === 'number' || typeof v === 'boolean') { + lines.push(`${k} = ${v}`) + } + } + lines.push('') + } + + fs.writeFileSync(CONFIG_TOML, lines.join('\n'), 'utf-8') +} + +function deepMerge(base: Record, override: Record): Record { + const result = { ...base } + for (const [key, value] of Object.entries(override)) { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + result[key] = { ...(result[key] as object), ...(value as object) } + } else if (value !== undefined) { + result[key] = value + } + } + return result +} diff --git a/packages/config/src/migrations/auth-profiles.test.ts b/packages/config/src/migrations/auth-profiles.test.ts new file mode 100644 index 000000000..b044a2d03 --- /dev/null +++ b/packages/config/src/migrations/auth-profiles.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict' +import { createCipheriv, createHash, randomBytes } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { after, beforeEach, describe, it } from 'node:test' + +const tempHome = mkdtempSync(join(tmpdir(), 'chatlab-config-migration-')) +const originalHome = process.env.HOME +process.env.HOME = tempHome + +const [{ m004EncryptedKeysToAuthProfiles }, { MigrationRunner }, { getApiKeyByProfile, loadAuthProfiles }] = + await Promise.all([import('./m004-encrypted-keys-to-auth-profiles'), import('./runner'), import('../auth-profiles')]) + +const chatlabDir = join(tempHome, '.chatlab') +const aiDataDir = join(chatlabDir, 'ai') + +function createLogger() { + const infos: string[] = [] + const warnings: string[] = [] + const errors: string[] = [] + + return { + infos, + warnings, + errors, + logger: { + info(_category: string, message: string) { + infos.push(message) + }, + warn(_category: string, message: string) { + warnings.push(message) + }, + error(_category: string, message: string) { + errors.push(message) + }, + }, + } +} + +async function writeLlmConfig(configs: Array>): Promise { + await mkdir(aiDataDir, { recursive: true }) + await writeFile(join(aiDataDir, 'llm-config.json'), JSON.stringify({ version: 3, configs }, null, 2), 'utf-8') +} + +function readLlmConfig(): { configs: Array> } { + return JSON.parse(readFileSync(join(aiDataDir, 'llm-config.json'), 'utf-8')) as { + configs: Array> + } +} + +function encryptWithDeviceKey(plainText: string, deviceKey: string): string { + const key = createHash('sha256') + .update(deviceKey + 'chatlab-api-key-encryption-v1') + .digest() + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', key, iv) + let encrypted = cipher.update(plainText, 'utf8', 'base64') + encrypted += cipher.final('base64') + const authTag = cipher.getAuthTag() + return `enc:${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}` +} + +beforeEach(async () => { + await rm(chatlabDir, { recursive: true, force: true }) +}) + +after(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + rmSync(tempHome, { recursive: true, force: true }) +}) + +describe('m004 encrypted keys to auth profiles migration', () => { + it('migrates plaintext keys through the runner, deduplicates profile names, and clears old apiKey fields', async () => { + await writeLlmConfig([ + { name: 'DeepSeek Main', provider: 'deepseek', apiKey: 'plain-one' }, + { name: 'DeepSeek Backup', provider: 'deepseek', apiKey: 'plain-two' }, + { + name: 'OpenAI Compatible', + provider: 'openai-compatible', + baseUrl: 'https://api.example.com/v1', + apiKey: 'plain-three', + }, + ]) + + const { logger } = createLogger() + const runner = new MigrationRunner([m004EncryptedKeysToAuthProfiles], { + dataDir: chatlabDir, + aiDataDir, + logger, + }) + + assert.deepEqual(await runner.run(), { executed: 1, currentVersion: 4 }) + + const profiles = loadAuthProfiles().profiles + assert.equal(profiles.deepseek.key, 'plain-one') + assert.equal(profiles['deepseek-2'].key, 'plain-two') + assert.equal(profiles['api.example.com'].key, 'plain-three') + assert.equal(getApiKeyByProfile('deepseek'), 'plain-one') + assert.deepEqual( + readLlmConfig().configs.map((config) => config.apiKey), + ['', '', ''] + ) + assert.equal(readFileSync(join(chatlabDir, '.migration-version'), 'utf-8'), '4') + }) + + it('decrypts legacy device-key encrypted api keys into auth profiles', async () => { + const deviceKey = '0123456789abcdef0123456789abcdef' + await mkdir(chatlabDir, { recursive: true }) + await writeFile(join(chatlabDir, '.device-key'), deviceKey, 'utf-8') + await writeLlmConfig([ + { name: 'Anthropic', provider: 'anthropic', apiKey: encryptWithDeviceKey('secret-key', deviceKey) }, + ]) + + const { logger } = createLogger() + await m004EncryptedKeysToAuthProfiles.up({ dataDir: chatlabDir, aiDataDir, logger }) + + assert.equal(getApiKeyByProfile('anthropic'), 'secret-key') + assert.equal(readLlmConfig().configs[0].apiKey, '') + }) + + it('does not create an auth profile or clear apiKey when encrypted key decryption fails', async () => { + await writeLlmConfig([{ name: 'Google', provider: 'google', apiKey: 'enc:not:a:valid-key' }]) + + const { logger, warnings } = createLogger() + await m004EncryptedKeysToAuthProfiles.up({ dataDir: chatlabDir, aiDataDir, logger }) + + assert.equal(existsSync(join(chatlabDir, 'auth-profiles.json')), false) + assert.equal(loadAuthProfiles().profiles.google, undefined) + assert.equal(readLlmConfig().configs[0].apiKey, 'enc:not:a:valid-key') + assert.equal(warnings.length, 1) + }) +}) diff --git a/packages/config/src/migrations/crypto-legacy.ts b/packages/config/src/migrations/crypto-legacy.ts new file mode 100644 index 000000000..f3f790537 --- /dev/null +++ b/packages/config/src/migrations/crypto-legacy.ts @@ -0,0 +1,157 @@ +/** + * Legacy crypto 解密模块(仅用于迁移) + * + * 从 electron/main/ai/llm/crypto.ts + device-key.ts 提取, + * 不依赖 Electron API,可在 CLI 和 Electron 中共用。 + */ + +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { createDecipheriv, createHash } from 'crypto' +import { execSync } from 'child_process' + +const ENCRYPTED_PREFIX = 'enc:' +const ALGORITHM = 'aes-256-gcm' +const SALT = 'chatlab-api-key-encryption-v1' +const DEVICE_KEY_FILE = '.device-key' + +export function isEncrypted(value: string): boolean { + return value?.startsWith(ENCRYPTED_PREFIX) ?? false +} + +function readKeyFromFile(keyPath: string): string | null { + try { + if (fs.existsSync(keyPath)) { + const key = fs.readFileSync(keyPath, 'utf-8').trim() + if (key.length >= 32) return key + } + } catch { + // ignore + } + return null +} + +function getAllDeviceKeyPaths(): string[] { + const home = os.homedir() + const paths = [path.join(home, '.chatlab', DEVICE_KEY_FILE)] + + if (process.platform === 'darwin') { + paths.push(path.join(home, 'Library', 'Application Support', 'ChatLab', 'data', DEVICE_KEY_FILE)) + } else if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming') + paths.push(path.join(appData, 'ChatLab', 'data', DEVICE_KEY_FILE)) + } else { + paths.push(path.join(home, '.config', 'ChatLab', 'data', DEVICE_KEY_FILE)) + } + + return paths +} + +function getAllDeviceKeys(): string[] { + const keys: string[] = [] + const seen = new Set() + for (const p of getAllDeviceKeyPaths()) { + const k = readKeyFromFile(p) + if (k && !seen.has(k)) { + seen.add(k) + keys.push(k) + } + } + return keys +} + +function deriveLegacyKeys(): Buffer[] { + const keys: Buffer[] = [] + try { + let cmd: string | null = null + if (process.platform === 'linux') { + cmd = '( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname ) | head -n 1 || :' + } else if (process.platform === 'darwin') { + cmd = 'ioreg -rd1 -c IOPlatformExpertDevice' + } else if (process.platform === 'win32') { + cmd = 'REG.exe QUERY HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography /v MachineGuid' + } + + if (cmd) { + const raw = execSync(cmd).toString() + let machineId: string + if (process.platform === 'darwin') { + machineId = + raw + .split('IOPlatformUUID')[1] + ?.split('\n')[0] + ?.replace(/[=\s"]/g, '') + ?.toLowerCase() || '' + } else if (process.platform === 'win32') { + machineId = + raw + .split('REG_SZ')[1] + ?.replace(/[\r\n\s]/g, '') + ?.toLowerCase() || '' + } else { + machineId = raw.replace(/[\r\n\s]/g, '').toLowerCase() + } + + if (machineId) { + const hashed = createHash('sha256').update(machineId).digest('hex') + keys.push( + createHash('sha256') + .update(hashed + SALT) + .digest() + ) + } + } + } catch { + // ignore + } + + keys.push( + createHash('sha256') + .update('chatlab-fallback-key' + SALT) + .digest() + ) + return keys +} + +function tryDecryptWithKey(encrypted: string, key: Buffer): string | null { + try { + const parts = encrypted.slice(ENCRYPTED_PREFIX.length).split(':') + if (parts.length !== 3) return null + + const [ivBase64, authTagBase64, ciphertext] = parts + const iv = Buffer.from(ivBase64, 'base64') + const authTag = Buffer.from(authTagBase64, 'base64') + + const decipher = createDecipheriv(ALGORITHM, key, iv) + decipher.setAuthTag(authTag) + + let decrypted = decipher.update(ciphertext, 'base64', 'utf8') + decrypted += decipher.final('utf8') + return decrypted + } catch { + return null + } +} + +/** + * 解密旧加密 API Key,尝试所有可用的 device key 和 legacy machine-id 密钥 + */ +export function decryptApiKey(encrypted: string): string { + if (!encrypted || !isEncrypted(encrypted)) return encrypted || '' + + for (const dk of getAllDeviceKeys()) { + const derived = createHash('sha256') + .update(dk + SALT) + .digest() + const result = tryDecryptWithKey(encrypted, derived) + if (result !== null) return result + } + + for (const legacyKey of deriveLegacyKeys()) { + const result = tryDecryptWithKey(encrypted, legacyKey) + if (result !== null) return result + } + + return '' +} diff --git a/packages/config/src/migrations/index.ts b/packages/config/src/migrations/index.ts new file mode 100644 index 000000000..d5597c107 --- /dev/null +++ b/packages/config/src/migrations/index.ts @@ -0,0 +1,21 @@ +/** + * Migration Runner 导出 + * + * ALL_MIGRATIONS 按版本号排列,由 MigrationRunner 在应用启动时执行。 + */ + +export { MigrationRunner } from './runner' +export type { Migration, MigrationContext, Logger } from './types' + +import { m001LegacyToMultiConfig } from './m001-legacy-to-multi-config' +import { m002SchemaV2 } from './m002-schema-v2' +import { m003SchemaV3 } from './m003-schema-v3' +import { m004EncryptedKeysToAuthProfiles } from './m004-encrypted-keys-to-auth-profiles' +import type { Migration } from './types' + +export const ALL_MIGRATIONS: Migration[] = [ + m001LegacyToMultiConfig, + m002SchemaV2, + m003SchemaV3, + m004EncryptedKeysToAuthProfiles, +] diff --git a/packages/config/src/migrations/m001-legacy-to-multi-config.ts b/packages/config/src/migrations/m001-legacy-to-multi-config.ts new file mode 100644 index 000000000..daf346a1b --- /dev/null +++ b/packages/config/src/migrations/m001-legacy-to-multi-config.ts @@ -0,0 +1,70 @@ +/** + * Migration v0→v1: Legacy 单配置 → 多配置格式 + * + * 旧格式:{ provider, apiKey, model, ... }(扁平对象) + * 新格式:{ configs: [{ id, name, provider, apiKey, model, ... }], defaultAssistant, fastModel } + */ + +import * as fs from 'fs' +import * as path from 'path' +import { randomUUID } from 'crypto' +import type { Migration, MigrationContext } from './types' + +interface LegacyFlatConfig { + provider: string + apiKey: string + model?: string + maxTokens?: number + [key: string]: unknown +} + +function isLegacyFlatConfig(data: unknown): data is LegacyFlatConfig { + if (!data || typeof data !== 'object') return false + const obj = data as Record + return 'provider' in obj && 'apiKey' in obj && !('configs' in obj) +} + +export const m001LegacyToMultiConfig: Migration = { + version: 1, + name: 'legacy-to-multi-config', + description: 'Convert legacy flat LLM config to multi-config format', + + async up(ctx: MigrationContext) { + const configPath = path.join(ctx.aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) return + + const raw = fs.readFileSync(configPath, 'utf-8') + let data: unknown + try { + data = JSON.parse(raw) + } catch { + return + } + + if (!isLegacyFlatConfig(data)) return + + ctx.logger.info('Migration', 'Converting legacy flat config to multi-config format') + + const now = Date.now() + const newConfig = { + id: randomUUID(), + name: data.provider, + provider: data.provider, + apiKey: data.apiKey || '', + model: data.model || '', + maxTokens: data.maxTokens, + createdAt: now, + updatedAt: now, + } + + const migrated = { + schemaVersion: 1, + configs: [newConfig], + defaultAssistant: { configId: newConfig.id, modelId: newConfig.model }, + fastModel: null, + } + + fs.writeFileSync(configPath, JSON.stringify(migrated, null, 2), 'utf-8') + ctx.logger.info('Migration', 'Legacy config migrated to multi-config format') + }, +} diff --git a/packages/config/src/migrations/m002-schema-v2.ts b/packages/config/src/migrations/m002-schema-v2.ts new file mode 100644 index 000000000..f40f027a5 --- /dev/null +++ b/packages/config/src/migrations/m002-schema-v2.ts @@ -0,0 +1,48 @@ +/** + * Migration v1→v2: Schema v2 升级 + * + * - 移除 configs 中的 disableThinking / isReasoningModel 字段 + * - 更新 schemaVersion 为 2 + * + * 注意:原 Electron 版还包含自定义 provider/model 创建逻辑, + * 该逻辑依赖 Electron-specific 模块,在共享 migration 中跳过。 + * Electron 端在 loadConfigStore 中已有回退处理。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { Migration, MigrationContext } from './types' + +export const m002SchemaV2: Migration = { + version: 2, + name: 'schema-v2', + description: 'Upgrade LLM config to schema v2 (remove deprecated fields)', + + async up(ctx: MigrationContext) { + const configPath = path.join(ctx.aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) return + + let data: Record + try { + data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + } catch { + return + } + + const schemaVersion = (data.schemaVersion as number) || 0 + if (schemaVersion >= 2) return + + ctx.logger.info('Migration', 'Upgrading LLM config to schema v2') + + const configs = (data.configs as Record[]) || [] + const cleanedConfigs = configs.map((c) => { + const { disableThinking: _dt, isReasoningModel: _rm, ...rest } = c + return rest + }) + + data.configs = cleanedConfigs + data.schemaVersion = 2 + + fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8') + }, +} diff --git a/packages/config/src/migrations/m003-schema-v3.ts b/packages/config/src/migrations/m003-schema-v3.ts new file mode 100644 index 000000000..94e57ae48 --- /dev/null +++ b/packages/config/src/migrations/m003-schema-v3.ts @@ -0,0 +1,49 @@ +/** + * Migration v2→v3: Schema v3 升级 — 双 slot 模型选择 + * + * - 将 activeConfigId → defaultAssistant { configId, modelId } + * - 新增 fastModel slot(默认 null) + * - 更新 schemaVersion 为 3 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { Migration, MigrationContext } from './types' + +export const m003SchemaV3: Migration = { + version: 3, + name: 'schema-v3-dual-slot', + description: 'Upgrade LLM config to schema v3 (dual-slot model selection)', + + async up(ctx: MigrationContext) { + const configPath = path.join(ctx.aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) return + + let data: Record + try { + data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + } catch { + return + } + + const schemaVersion = (data.schemaVersion as number) || 0 + if (schemaVersion >= 3) return + + ctx.logger.info('Migration', 'Upgrading LLM config to schema v3 (dual-slot)') + + const configs = (data.configs as Array<{ id: string; model?: string }>) || [] + const activeConfigId = data.activeConfigId as string | null | undefined + + const resolvedConfig = + activeConfigId && configs.find((c) => c.id === activeConfigId) + ? configs.find((c) => c.id === activeConfigId)! + : (configs[0] ?? null) + + data.defaultAssistant = resolvedConfig ? { configId: resolvedConfig.id, modelId: resolvedConfig.model || '' } : null + data.fastModel = data.fastModel ?? null + data.schemaVersion = 3 + delete data.activeConfigId + + fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8') + }, +} diff --git a/packages/config/src/migrations/m004-encrypted-keys-to-auth-profiles.ts b/packages/config/src/migrations/m004-encrypted-keys-to-auth-profiles.ts new file mode 100644 index 000000000..e8919a189 --- /dev/null +++ b/packages/config/src/migrations/m004-encrypted-keys-to-auth-profiles.ts @@ -0,0 +1,101 @@ +/** + * Migration v3→v4: 加密 API Key → auth-profiles.json + * + * - 从 llm-config.json 读取加密的 apiKey(enc: 前缀) + * - 使用 device-key + legacy machine-id 尝试解密 + * - 解密成功的写入 auth-profiles.json + * - 清空 llm-config.json 中的 apiKey 字段 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { Migration, MigrationContext } from './types' +import { isEncrypted, decryptApiKey } from './crypto-legacy' +import { writeAuthProfile } from '../auth-profiles' + +/** + * Derive a human-readable profile name from config fields. + * Official providers → provider id (e.g. "deepseek") + * openai-compatible → hostname from baseUrl (e.g. "api.example.com") or config name + */ +function deriveProfileName(provider: string, config: Record): string { + if (provider !== 'openai-compatible') { + return provider.toLowerCase().replace(/\s+/g, '-') + } + + const baseUrl = config.baseUrl as string + if (baseUrl) { + try { + return new URL(baseUrl).hostname.toLowerCase() + } catch { + // invalid URL, fall through + } + } + + const name = config.name as string + if (name) return name.toLowerCase().replace(/\s+/g, '-') + + return 'custom' +} + +export const m004EncryptedKeysToAuthProfiles: Migration = { + version: 4, + name: 'encrypted-keys-to-auth-profiles', + description: 'Migrate encrypted API keys from llm-config.json to auth-profiles.json', + + async up(ctx: MigrationContext) { + const configPath = path.join(ctx.aiDataDir, 'llm-config.json') + if (!fs.existsSync(configPath)) return + + let data: Record + try { + data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + } catch { + return + } + + const configs = (data.configs as Array>) || [] + let migrated = false + const usedProfileNames = new Set() + + for (const config of configs) { + const apiKey = config.apiKey as string + if (!apiKey) continue + + const provider = (config.provider as string) || 'unknown' + let plainKey: string | null = null + + if (isEncrypted(apiKey)) { + plainKey = decryptApiKey(apiKey) + if (!plainKey) { + ctx.logger.warn('Migration', `Failed to decrypt API key for "${config.name}", skipping`) + continue + } + } else if (apiKey.length > 0) { + plainKey = apiKey + } + + if (plainKey) { + const baseName = deriveProfileName(provider, config) + + let profileName = baseName + if (usedProfileNames.has(profileName)) { + let i = 2 + while (usedProfileNames.has(`${profileName}-${i}`)) i++ + profileName = `${profileName}-${i}` + } + usedProfileNames.add(profileName) + + writeAuthProfile(profileName, { type: 'api_key', provider, key: plainKey }) + config.apiKey = '' + migrated = true + ctx.logger.info('Migration', `Migrated API key → profile "${profileName}"`) + } + } + + if (migrated) { + fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8') + ctx.logger.info('Migration', 'Cleared API keys from llm-config.json') + } + }, +} diff --git a/packages/config/src/migrations/runner.ts b/packages/config/src/migrations/runner.ts new file mode 100644 index 000000000..e5a6456c3 --- /dev/null +++ b/packages/config/src/migrations/runner.ts @@ -0,0 +1,78 @@ +/** + * MigrationRunner + * + * 在应用启动时执行所有待执行的数据迁移。 + * 版本号存储在 ~/.chatlab/.migration-version 文件中。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { Migration, MigrationContext } from './types' + +const VERSION_FILE = '.migration-version' + +export class MigrationRunner { + private migrations: Migration[] + private context: MigrationContext + + constructor(migrations: Migration[], context: MigrationContext) { + this.migrations = [...migrations].sort((a, b) => a.version - b.version) + this.context = context + } + + getCurrentVersion(): number { + const versionPath = path.join(this.context.dataDir, VERSION_FILE) + try { + if (fs.existsSync(versionPath)) { + const raw = fs.readFileSync(versionPath, 'utf-8').trim() + const ver = parseInt(raw, 10) + return Number.isFinite(ver) ? ver : 0 + } + } catch { + // version file unreadable, treat as 0 + } + return 0 + } + + private writeVersion(version: number): void { + const versionPath = path.join(this.context.dataDir, VERSION_FILE) + try { + if (!fs.existsSync(this.context.dataDir)) { + fs.mkdirSync(this.context.dataDir, { recursive: true }) + } + fs.writeFileSync(versionPath, String(version), 'utf-8') + } catch (err) { + this.context.logger.error('Migration', `Failed to write version file: ${err}`) + } + } + + async run(): Promise<{ executed: number; currentVersion: number }> { + const currentVersion = this.getCurrentVersion() + const pending = this.migrations.filter((m) => m.version > currentVersion) + + if (pending.length === 0) { + return { executed: 0, currentVersion } + } + + this.context.logger.info('Migration', `${pending.length} pending migration(s), current version: ${currentVersion}`) + + let lastVersion = currentVersion + let executed = 0 + + for (const migration of pending) { + this.context.logger.info('Migration', `Running: v${lastVersion}→v${migration.version} ${migration.name}`) + try { + await migration.up(this.context) + lastVersion = migration.version + this.writeVersion(lastVersion) + executed++ + this.context.logger.info('Migration', `Completed: ${migration.name}`) + } catch (err) { + this.context.logger.error('Migration', `Failed: ${migration.name}`, err) + break + } + } + + return { executed, currentVersion: lastVersion } + } +} diff --git a/packages/config/src/migrations/types.ts b/packages/config/src/migrations/types.ts new file mode 100644 index 000000000..cdb85465a --- /dev/null +++ b/packages/config/src/migrations/types.ts @@ -0,0 +1,28 @@ +/** + * Migration Runner 类型定义 + */ + +export interface Logger { + info(category: string, message: string, ...args: unknown[]): void + warn(category: string, message: string, ...args: unknown[]): void + error(category: string, message: string, ...args: unknown[]): void +} + +export interface MigrationContext { + /** ~/.chatlab */ + dataDir: string + /** ~/.chatlab/ai */ + aiDataDir: string + logger: Logger +} + +export interface Migration { + /** 迁移后的目标版本号(单调递增) */ + version: number + /** 迁移名称(用于日志) */ + name: string + /** 迁移描述 */ + description: string + /** 执行迁移(须幂等) */ + up(context: MigrationContext): Promise +} diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts new file mode 100644 index 000000000..a0f9f90e6 --- /dev/null +++ b/packages/config/src/schema.ts @@ -0,0 +1,52 @@ +/** + * 配置 Zod Schema 定义 + * + * 同时服务于 TOML 文件校验和环境变量解析。 + */ + +import { z } from 'zod' + +export const DEFAULT_API_PORT = 3110 + +export const llmConfigSchema = z.object({ + provider: z.string().default(''), + model: z.string().default(''), + base_url: z.string().default(''), +}) + +export const dataConfigSchema = z.object({ + user_data_dir: z.string().default(''), + electron_migration_done: z.boolean().default(false), +}) + +export const apiConfigSchema = z.object({ + port: z.number().int().min(1).max(65535).default(DEFAULT_API_PORT), + host: z.string().default('127.0.0.1'), + token: z.string().default(''), + require_auth: z.boolean().default(false), +}) + +export const localeConfigSchema = z.object({ + lang: z.string().default(''), +}) + +export const uiConfigSchema = z.object({ + default_session_tab: z.enum(['overview', 'ai-chat']).default('overview'), + session_gap_threshold: z.number().int().min(60).max(86400).default(1800), + summary_strategy: z.enum(['brief', 'standard']).default('standard'), +}) + +export const configSchema = z.object({ + llm: llmConfigSchema.default({}), + data: dataConfigSchema.default({}), + api: apiConfigSchema.default({}), + locale: localeConfigSchema.default({}), + ui: uiConfigSchema.default({}), +}) + +export type ChatLabConfig = z.infer +export type LlmConfig = z.infer +export type DataConfig = z.infer +export type ApiConfig = z.infer +export type LocaleConfig = z.infer +export type UiConfig = z.infer diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 000000000..05271a6a6 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,9 @@ +{ + "name": "@openchatlab/core", + "version": "0.0.0", + "private": true, + "description": "ChatLab 平台无关的共享核心:抽象接口、查询逻辑、分析算法", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts" +} diff --git a/packages/core/src/ai/__tests__/content-parser.test.ts b/packages/core/src/ai/__tests__/content-parser.test.ts new file mode 100644 index 000000000..c2507c6de --- /dev/null +++ b/packages/core/src/ai/__tests__/content-parser.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { extractThinkingContent, stripToolCallTags } from '../content-parser' + +describe('extractThinkingContent', () => { + it('returns empty strings for empty input', () => { + const result = extractThinkingContent('') + assert.equal(result.thinking, '') + assert.equal(result.cleanContent, '') + }) + + it('extracts tags', () => { + const input = 'reasoning hereFinal answer.' + const result = extractThinkingContent(input) + assert.equal(result.thinking, 'reasoning here') + assert.equal(result.cleanContent, 'Final answer.') + }) + + it('extracts multiple different thinking tags', () => { + const input = 'step 1 middle step 2 end' + const result = extractThinkingContent(input) + assert.equal(result.thinking, 'step 1\nstep 2') + assert.equal(result.cleanContent, 'middle end') + }) + + it('is case-insensitive', () => { + const input = 'upper casecontent' + const result = extractThinkingContent(input) + assert.equal(result.thinking, 'upper case') + assert.equal(result.cleanContent, 'content') + }) + + it('handles multiline thinking content', () => { + const input = '\nline1\nline2\ndone' + const result = extractThinkingContent(input) + assert.equal(result.thinking, 'line1\nline2') + assert.equal(result.cleanContent, 'done') + }) + + it('skips empty thinking tags', () => { + const input = ' only content' + const result = extractThinkingContent(input) + assert.equal(result.thinking, '') + assert.equal(result.cleanContent, 'only content') + }) + + it('returns original content when no thinking tags exist', () => { + const input = 'just plain text' + const result = extractThinkingContent(input) + assert.equal(result.thinking, '') + assert.equal(result.cleanContent, 'just plain text') + }) +}) + +describe('stripToolCallTags', () => { + it('removes tool_call tags', () => { + const input = 'before{"name":"search"}after' + assert.equal(stripToolCallTags(input), 'beforeafter') + }) + + it('removes multiple tool_call tags', () => { + const input = 'a text b' + assert.equal(stripToolCallTags(input), 'text') + }) + + it('handles multiline tool_call content', () => { + const input = 'start\n\n{\n"name": "x"\n}\n\nend' + assert.equal(stripToolCallTags(input), 'start\n\nend') + }) + + it('returns original text when no tool_call tags', () => { + assert.equal(stripToolCallTags('no tags here'), 'no tags here') + }) + + it('is case-insensitive', () => { + const input = 'datarest' + assert.equal(stripToolCallTags(input), 'rest') + }) +}) diff --git a/packages/core/src/ai/__tests__/streaming-think-parser.test.ts b/packages/core/src/ai/__tests__/streaming-think-parser.test.ts new file mode 100644 index 000000000..b37e0ee4c --- /dev/null +++ b/packages/core/src/ai/__tests__/streaming-think-parser.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { StreamingThinkTagParser, type StreamParserEvent } from '../streaming-think-parser' + +function collect(chunks: string[]): StreamParserEvent[] { + const events: StreamParserEvent[] = [] + const parser = new StreamingThinkTagParser((ev) => events.push(ev)) + for (const chunk of chunks) { + parser.feed(chunk) + } + parser.flush() + return events +} + +describe('StreamingThinkTagParser', () => { + it('passes through plain text as content', () => { + const events = collect(['Hello', ' world']) + assert.deepEqual(events, [ + { type: 'content', content: 'Hello' }, + { type: 'content', content: ' world' }, + ]) + }) + + it('detects tag in a single chunk', () => { + const events = collect(['reasoninganswer']) + assert.deepEqual(events, [ + { type: 'thinking_start', tag: 'think' }, + { type: 'thinking_delta', content: 'reasoning' }, + { type: 'thinking_end' }, + { type: 'content', content: 'answer' }, + ]) + }) + + it('handles tag split across chunks', () => { + const events = collect(['reasoninganswer']) + assert.deepEqual(events, [ + { type: 'thinking_start', tag: 'think' }, + { type: 'thinking_delta', content: 'reasoning' }, + { type: 'thinking_end' }, + { type: 'content', content: 'answer' }, + ]) + }) + + it('handles closing tag split across chunks', () => { + const events = collect(['reasoninganswer']) + assert.deepEqual(events, [ + { type: 'thinking_start', tag: 'think' }, + { type: 'thinking_delta', content: 'reasoning' }, + { type: 'thinking_end' }, + { type: 'content', content: 'answer' }, + ]) + }) + + it('handles content before and after think block', () => { + const events = collect(['beforemiddleafter']) + assert.deepEqual(events, [ + { type: 'content', content: 'before' }, + { type: 'thinking_start', tag: 'think' }, + { type: 'thinking_delta', content: 'middle' }, + { type: 'thinking_end' }, + { type: 'content', content: 'after' }, + ]) + }) + + it('handles < that is not a think tag', () => { + const events = collect(['a < b > c']) + assert.deepEqual(events, [ + { type: 'content', content: 'a ' }, + { type: 'content', content: '<' }, + { type: 'content', content: ' b > c' }, + ]) + }) + + it('handles other think tags like ', () => { + const events = collect(['step 1done']) + assert.deepEqual(events, [ + { type: 'thinking_start', tag: 'reasoning' }, + { type: 'thinking_delta', content: 'step 1' }, + { type: 'thinking_end' }, + { type: 'content', content: 'done' }, + ]) + }) + + it('flushes buffered thinking content on flush()', () => { + const events: StreamParserEvent[] = [] + const parser = new StreamingThinkTagParser((ev) => events.push(ev)) + parser.feed('partial thinking') + parser.flush() + assert.deepEqual(events, [ + { type: 'thinking_start', tag: 'think' }, + { type: 'thinking_delta', content: 'partial thinking' }, + ]) + }) + + it('flushes incomplete close tag as thinking content', () => { + const events: StreamParserEvent[] = [] + const parser = new StreamingThinkTagParser((ev) => events.push(ev)) + parser.feed('content { + const text = 'hiok' + const events = collect(text.split('')) + assert.deepEqual(events, [ + { type: 'thinking_start', tag: 'think' }, + { type: 'thinking_delta', content: 'h' }, + { type: 'thinking_delta', content: 'i' }, + { type: 'thinking_end' }, + { type: 'content', content: 'o' }, + { type: 'content', content: 'k' }, + ]) + }) + + it('handles < inside think block that is not closing tag', () => { + const events = collect(['a < bdone']) + assert.deepEqual(events, [ + { type: 'thinking_start', tag: 'think' }, + { type: 'thinking_delta', content: 'a ' }, + { type: 'thinking_delta', content: '<' }, + { type: 'thinking_delta', content: ' b' }, + { type: 'thinking_end' }, + { type: 'content', content: 'done' }, + ]) + }) +}) diff --git a/packages/core/src/ai/__tests__/thinking.test.ts b/packages/core/src/ai/__tests__/thinking.test.ts new file mode 100644 index 000000000..33eab4723 --- /dev/null +++ b/packages/core/src/ai/__tests__/thinking.test.ts @@ -0,0 +1,275 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { getSupportedThinkingLevels, getThinkingCompat, isReasoningModel } from '../thinking' + +describe('isReasoningModel', () => { + it('returns false for anthropic provider (out of scope)', () => { + assert.equal(isReasoningModel('anthropic', 'claude-opus-4-6'), false) + }) + + it('returns true for gemini 2.5+ reasoning models', () => { + assert.equal(isReasoningModel('gemini', 'gemini-2.5-pro'), true) + assert.equal(isReasoningModel('gemini', 'gemini-2.5-flash-preview-05-20'), true) + assert.equal(isReasoningModel('gemini', 'gemini-3-flash'), true) + }) + + it('returns false for older gemini models', () => { + assert.equal(isReasoningModel('gemini', 'gemini-2.0-flash'), false) + assert.equal(isReasoningModel('gemini', 'gemini-1.5-pro'), false) + }) + + it('returns true for qwen provider', () => { + assert.equal(isReasoningModel('qwen', 'qwen3-max'), true) + }) + + it('returns true for deepseek v4 model', () => { + assert.equal(isReasoningModel('deepseek', 'deepseek-v4-pro'), true) + }) + + it('returns true for deepseek hybrid (v3.x / chat)', () => { + assert.equal(isReasoningModel('deepseek', 'deepseek-chat'), true) + assert.equal(isReasoningModel('deepseek', 'deepseek-v3.1'), true) + }) + + it('returns true for openai o-series', () => { + assert.equal(isReasoningModel('openai', 'o3'), true) + }) + + it('returns true for openai gpt-5.x', () => { + assert.equal(isReasoningModel('openai', 'gpt-5.2'), true) + }) + + it('returns true for self-hosted qwen3 (heuristic)', () => { + assert.equal(isReasoningModel('openai-compatible', 'qwen3:8b'), true) + }) + + it('returns false for non-reasoning model (gpt-4o)', () => { + assert.equal(isReasoningModel('openai', 'gpt-4o'), false) + }) + + it('returns false for models with -non-reasoning suffix', () => { + assert.equal(isReasoningModel('xai', 'grok-4-fast-non-reasoning'), false) + }) + + it('returns true for hunyuan reasoning models', () => { + assert.equal(isReasoningModel('hunyuan', 'hunyuan-t1-latest'), true) + assert.equal(isReasoningModel('tencent', 'hunyuan-a13b'), true) + }) + + it('returns true for known reasoning model families', () => { + assert.equal(isReasoningModel('minimax', 'minimax-m2'), true) + assert.equal(isReasoningModel('baichuan', 'baichuan-m3'), true) + assert.equal(isReasoningModel('mistral', 'mistral-small-2603'), true) + assert.equal(isReasoningModel('step', 'step-3'), true) + assert.equal(isReasoningModel('google', 'gemma-4-27b'), true) + assert.equal(isReasoningModel('perplexity', 'sonar-deep-research'), true) + assert.equal(isReasoningModel('xiaomi', 'mimo-v2-flash'), true) + assert.equal(isReasoningModel('bytedance', 'seed-oss-36b'), true) + }) + + it('returns true for kimi k2.5+ models', () => { + assert.equal(isReasoningModel('kimi', 'kimi-k2.5'), true) + assert.equal(isReasoningModel('kimi', 'kimi-k3'), true) + }) + + it('returns true for grok reasoning variants', () => { + assert.equal(isReasoningModel('xai', 'grok-3-mini'), true) + assert.equal(isReasoningModel('xai', 'grok-4'), true) + assert.equal(isReasoningModel('xai', 'grok-4-fast'), true) + assert.equal(isReasoningModel('xai', 'grok-build'), true) + }) +}) + +describe('getSupportedThinkingLevels', () => { + it('returns [] for non-reasoning models', () => { + assert.deepEqual(getSupportedThinkingLevels('openai', 'gpt-4o'), []) + }) + + it('returns [] for anthropic (out of scope)', () => { + assert.deepEqual(getSupportedThinkingLevels('anthropic', 'claude-opus-4-6'), []) + }) + + it('always includes default as first option for reasoning models', () => { + const levels = getSupportedThinkingLevels('qwen', 'qwen3-max') + assert.equal(levels[0], 'default') + }) + + it('returns default+off+high for qwen (on/off only)', () => { + assert.deepEqual(getSupportedThinkingLevels('qwen', 'qwen3-max'), ['default', 'off', 'high']) + }) + + it('returns default+off+high for glm (same as qwen)', () => { + assert.deepEqual(getSupportedThinkingLevels('glm', 'glm-5'), ['default', 'off', 'high']) + }) + + it('returns default+off+high+xhigh for deepseek-v4', () => { + assert.deepEqual(getSupportedThinkingLevels('deepseek', 'deepseek-v4-pro'), ['default', 'off', 'high', 'xhigh']) + }) + + it('returns default+off+auto for deepseek hybrid', () => { + assert.deepEqual(getSupportedThinkingLevels('deepseek', 'deepseek-chat'), ['default', 'off', 'auto']) + }) + + it('returns default+low+medium+high for o-series (cannot disable)', () => { + assert.deepEqual(getSupportedThinkingLevels('openai', 'o3'), ['default', 'low', 'medium', 'high']) + }) + + it('returns full range with default for gpt-5.2+', () => { + assert.deepEqual(getSupportedThinkingLevels('openai', 'gpt-5.2'), [ + 'default', + 'off', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + ]) + }) + + it('returns default+minimal+low+medium+high for gpt-5 base (cannot disable)', () => { + assert.deepEqual(getSupportedThinkingLevels('openai', 'gpt-5'), ['default', 'minimal', 'low', 'medium', 'high']) + }) + + it('returns default+off+high for self-hosted qwen3 (qwen type)', () => { + assert.deepEqual(getSupportedThinkingLevels('openai-compatible', 'qwen3:8b'), ['default', 'off', 'high']) + }) + + it('returns default+off+auto+low+medium+high for kimi thinking models', () => { + assert.deepEqual(getSupportedThinkingLevels('kimi', 'kimi-k2-thinking'), [ + 'default', + 'off', + 'auto', + 'low', + 'medium', + 'high', + ]) + }) + + it('returns default+off+auto+high for doubao seed', () => { + assert.deepEqual(getSupportedThinkingLevels('doubao', 'doubao-seed-2-0-pro-260215'), [ + 'default', + 'off', + 'auto', + 'high', + ]) + }) + + it('returns default+low+high for grok (cannot disable)', () => { + assert.deepEqual(getSupportedThinkingLevels('xai', 'grok-4'), ['default', 'low', 'high']) + }) + + it('returns default+off+high for hunyuan', () => { + assert.deepEqual(getSupportedThinkingLevels('hunyuan', 'hunyuan-t1-latest'), ['default', 'off', 'high']) + }) + + it('returns default+off+low+medium+high for gemini 2.5+', () => { + assert.deepEqual(getSupportedThinkingLevels('gemini', 'gemini-2.5-flash-preview-05-20'), [ + 'default', + 'off', + 'low', + 'medium', + 'high', + ]) + assert.deepEqual(getSupportedThinkingLevels('gemini', 'gemini-3-pro'), ['default', 'off', 'low', 'medium', 'high']) + }) + + it('returns empty for older gemini models', () => { + assert.deepEqual(getSupportedThinkingLevels('gemini', 'gemini-2.0-flash'), []) + }) + + it('returns default+off+auto for mimo (deepseek_hybrid type)', () => { + assert.deepEqual(getSupportedThinkingLevels('openai-compatible', 'mimo-v2-flash'), ['default', 'off', 'auto']) + }) + + it('returns default+off+low+medium+high for generic reasoning (fallback)', () => { + assert.deepEqual(getSupportedThinkingLevels('openai-compatible', 'some-reasoning-model'), [ + 'default', + 'off', + 'low', + 'medium', + 'high', + ]) + }) +}) + +describe('getThinkingCompat', () => { + it('returns {} for non-reasoning model', () => { + assert.deepEqual(getThinkingCompat('openai', 'gpt-4o'), {}) + }) + + it('returns thinkingFormat:qwen for qwen provider', () => { + assert.deepEqual(getThinkingCompat('qwen', 'qwen3-max'), { thinkingFormat: 'qwen' }) + }) + + it('returns thinkingFormat:qwen for glm', () => { + assert.deepEqual(getThinkingCompat('glm', 'glm-5'), { thinkingFormat: 'qwen' }) + }) + + it('returns thinkingFormat:qwen for hunyuan', () => { + assert.deepEqual(getThinkingCompat('tencent', 'hunyuan-a13b'), { thinkingFormat: 'qwen' }) + }) + + it('returns thinkingFormat:qwen for self-hosted qwen3', () => { + assert.deepEqual(getThinkingCompat('openai-compatible', 'qwen3:8b'), { thinkingFormat: 'qwen' }) + }) + + it('returns thinkingFormat:deepseek + thinkingLevelMap for deepseek-v4', () => { + const compat = getThinkingCompat('deepseek', 'deepseek-v4-pro') + assert.equal(compat.thinkingFormat, 'deepseek') + assert.equal(compat.supportsReasoningEffort, undefined) + assert.equal(compat.thinkingLevelMap?.high, 'high') + assert.equal(compat.thinkingLevelMap?.xhigh, 'max') + assert.equal(compat.thinkingLevelMap?.minimal, null) + }) + + it('returns thinkingFormat:deepseek + auto mapping for deepseek hybrid', () => { + const compat = getThinkingCompat('deepseek', 'deepseek-chat') + assert.equal(compat.thinkingFormat, 'deepseek') + assert.equal(compat.thinkingLevelMap?.auto, 'auto') + assert.equal(compat.thinkingLevelMap?.high, 'high') + assert.equal(compat.thinkingLevelMap?.xhigh, null) + }) + + it('returns supportsReasoningEffort for o-series', () => { + const compat = getThinkingCompat('openai', 'o3') + assert.equal(compat.supportsReasoningEffort, true) + assert.equal(compat.thinkingLevelMap, undefined) + }) + + it('returns supportsReasoningEffort + off:none for gpt-5.1', () => { + const compat = getThinkingCompat('openai', 'gpt-5.1') + assert.equal(compat.supportsReasoningEffort, true) + assert.equal(compat.thinkingLevelMap?.off, 'none') + }) + + it('returns supportsReasoningEffort + off:none + xhigh for gpt-5.2+', () => { + const compat = getThinkingCompat('openai', 'gpt-5.2') + assert.equal(compat.supportsReasoningEffort, true) + assert.equal(compat.thinkingLevelMap?.off, 'none') + assert.equal(compat.thinkingLevelMap?.xhigh, 'xhigh') + }) + + it('returns supportsReasoningEffort + auto/off mappings for kimi', () => { + const compat = getThinkingCompat('kimi', 'kimi-k2-thinking') + assert.equal(compat.supportsReasoningEffort, true) + assert.equal(compat.thinkingLevelMap?.auto, 'auto') + assert.equal(compat.thinkingLevelMap?.off, 'none') + }) + + it('returns supportsReasoningEffort + auto mapping for doubao', () => { + const compat = getThinkingCompat('doubao', 'doubao-seed-2-0-pro-260215') + assert.equal(compat.supportsReasoningEffort, true) + assert.equal(compat.thinkingLevelMap?.auto, 'auto') + assert.equal(compat.thinkingLevelMap?.high, 'high') + }) + + it('returns thinkingFormat:deepseek for mimo/minimax/seed-oss (thinking:{type} format)', () => { + const compat = getThinkingCompat('openai-compatible', 'mimo-v2-flash') + assert.equal(compat.thinkingFormat, 'deepseek') + assert.equal(compat.thinkingLevelMap?.auto, 'auto') + assert.equal(compat.thinkingLevelMap?.high, 'high') + + const compat2 = getThinkingCompat('minimax', 'minimax-m2') + assert.equal(compat2.thinkingFormat, 'deepseek') + }) +}) diff --git a/packages/core/src/ai/__tests__/tool-result-text.test.ts b/packages/core/src/ai/__tests__/tool-result-text.test.ts new file mode 100644 index 000000000..6e8c57d1f --- /dev/null +++ b/packages/core/src/ai/__tests__/tool-result-text.test.ts @@ -0,0 +1,37 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { MAX_PERSISTED_TOOL_RESULT_CHARS, extractToolResultText, truncateToolResultText } from '../tool-result-text' + +describe('extractToolResultText', () => { + it('joins text parts from AgentToolResult content', () => { + const result = { + content: [ + { type: 'text', text: 'line 1' }, + { type: 'image', data: 'xxx', mimeType: 'image/png' }, + { type: 'text', text: 'line 2' }, + ], + details: { ignored: true }, + } + assert.equal(extractToolResultText(result), 'line 1\nline 2') + }) + + it('returns empty string for non-object or malformed results', () => { + assert.equal(extractToolResultText(null), '') + assert.equal(extractToolResultText('plain string'), '') + assert.equal(extractToolResultText({ content: 'not an array' }), '') + assert.equal(extractToolResultText({ content: [{ type: 'text' }] }), '') + }) +}) + +describe('truncateToolResultText', () => { + it('keeps short text unchanged', () => { + assert.equal(truncateToolResultText('short'), 'short') + }) + + it('truncates oversized text with a marker', () => { + const long = 'a'.repeat(MAX_PERSISTED_TOOL_RESULT_CHARS + 100) + const truncated = truncateToolResultText(long) + assert.ok(truncated.length <= MAX_PERSISTED_TOOL_RESULT_CHARS + 20) + assert.ok(truncated.endsWith('…[truncated]')) + }) +}) diff --git a/packages/core/src/ai/content-parser.ts b/packages/core/src/ai/content-parser.ts new file mode 100644 index 000000000..c266cc895 --- /dev/null +++ b/packages/core/src/ai/content-parser.ts @@ -0,0 +1,54 @@ +/** + * Agent content parsing utilities. + * Extracts thinking tags and strips tool-call markup from LLM output. + */ + +export const THINK_TAGS = ['think', 'analysis', 'reasoning', 'reflection', 'thought', 'thinking'] + +export function extractThinkingContent(content: string): { thinking: string; cleanContent: string } { + if (!content) { + return { thinking: '', cleanContent: '' } + } + + const tagPattern = THINK_TAGS.join('|') + const thinkRegex = new RegExp(`<(${tagPattern})>([\\s\\S]*?)<\\/\\1>`, 'gi') + const thinkingParts: string[] = [] + let cleanContent = content + + const matches = content.matchAll(thinkRegex) + for (const match of matches) { + const thinkText = match[2].trim() + if (thinkText) { + thinkingParts.push(thinkText) + } + cleanContent = cleanContent.replace(match[0], '') + } + + return { thinking: thinkingParts.join('\n').trim(), cleanContent: cleanContent.trim() } +} + +export function stripToolCallTags(content: string): string { + return content.replace(/[\s\S]*?<\/tool_call>/gi, '').trim() +} + +/** + * Recursively strip large avatar/senderAvatar base64 strings from objects. + * Used when serializing tool results to avoid transmitting large image data. + */ +export function stripAvatarFields(obj: unknown): void { + if (!obj || typeof obj !== 'object') return + if (Array.isArray(obj)) { + for (const item of obj) stripAvatarFields(item) + return + } + const record = obj as Record + for (const key of Object.keys(record)) { + if ((key === 'avatar' || key === 'senderAvatar') && typeof record[key] === 'string') { + if ((record[key] as string).length > 200) { + record[key] = '[stripped]' + } + } else if (typeof record[key] === 'object' && record[key] !== null) { + stripAvatarFields(record[key]) + } + } +} diff --git a/packages/core/src/ai/evidence-types.ts b/packages/core/src/ai/evidence-types.ts new file mode 100644 index 000000000..8b1699317 --- /dev/null +++ b/packages/core/src/ai/evidence-types.ts @@ -0,0 +1,87 @@ +/** + * 证据检索 payload 类型(平台无关,跨包共享) + * + * 由 retrieve_chat_evidence 工具产出,持久化到 AI 对话历史,并由前端证据块渲染。 + * 放在 core 以便 tools / node-runtime / 前端共享同一份定义,避免重复漂移。 + * + * 单位约定:聊天库 / RawMessage.timestamp 为秒;evidence payload 统一使用毫秒。 + */ + +/** 证据检索模式 */ +export type EvidenceRetrievalMode = 'auto' | 'hybrid' | 'semantic' | 'keyword' + +/** 单组证据状态:计入 / 不计入 / 不确定 */ +export type EvidenceStatus = 'included' | 'excluded' | 'uncertain' + +/** 整个证据 payload 状态 */ +export type EvidencePayloadStatus = 'complete' | 'partial' | 'empty' | 'unavailable' + +/** 证据检索告警 */ +export type EvidenceWarning = + | 'criteria_missing' + | 'keywords_missing_for_hybrid' + | 'keywords_required_for_keyword_mode' + | 'semantic_unavailable' + | 'keyword_unavailable' + | 'semantic_partial' + +/** + * 毫秒级、可单边的时间区间。 + * + * 用于 evidence payload 与语义路径;单边区间表达“今年以来 / 截至某天”等查询。 + */ +export interface EvidenceTimeRangeMs { + startTs?: number + endTs?: number +} + +/** 单条证据来源(已脱敏,timestamp 为毫秒) */ +export interface ChatEvidenceSource { + /** 点击追溯锚点;语义 range source 默认用 startMessageId,关键词 source 用命中消息 id */ + messageId: number + /** 范围来源起点;关键词 source 可与 messageId 相同 */ + startMessageId?: number + /** 范围来源终点;关键词 source 可与 messageId 相同 */ + endMessageId?: number + /** 毫秒时间戳 */ + timestamp: number + senderName?: string + snippet: string + role?: 'primary' | 'supporting' + sourceKind?: 'semantic' | 'keyword' +} + +/** 一组证据 */ +export interface ChatEvidenceGroup { + id: string + status: EvidenceStatus + title: string + reason: string + /** 毫秒时间范围 */ + timeRange?: { startTs: number; endTs: number } + sources: ChatEvidenceSource[] +} + +/** + * 证据 payload(持久化到 AI 对话历史,时间戳统一毫秒)。 + * + * 只保存脱敏后的 snippet / 时间戳 / messageId / 分组元数据 / 查询上下文, + * 不保存原始 rawMessages、整段上下文全文或 embedding 原文。 + */ +export interface ChatEvidencePayload { + version: 1 + query: string + criteria?: string + /** 已 resolve 的实际检索模式(不会是 auto) */ + mode: EvidenceRetrievalMode + status: EvidencePayloadStatus + summary?: string + /** 实际生效的时间过滤(毫秒,可单边) */ + appliedTimeFilter?: { + startTs?: number + endTs?: number + label?: string + } + warnings?: EvidenceWarning[] + groups: ChatEvidenceGroup[] +} diff --git a/packages/core/src/ai/index.ts b/packages/core/src/ai/index.ts new file mode 100644 index 000000000..3fbaf4256 --- /dev/null +++ b/packages/core/src/ai/index.ts @@ -0,0 +1,55 @@ +/** + * AI 模块(平台无关的静态数据和类型) + */ + +// 内置工具目录 +export type { ToolCategory, BuiltinToolCatalogEntry } from './tool-catalog' +export { + BUILTIN_TOOL_CATALOG, + normalizeBuiltinToolName, + normalizeBuiltinToolNames, + CHART_CAPABILITY_SKILL_ID, +} from './tool-catalog' + +// LLM 模型系统类型 +export type { + ProviderKind, + ProviderDefinition, + ModelCapability, + ModelStatus, + ModelRecommendedFor, + ModelDefinition, + ModelSlot, +} from './model-types' + +// Provider Registry(内置 provider 目录) +export { BUILTIN_PROVIDERS, getBuiltinProviderById } from './provider-registry' + +// Model Catalog(内置模型目录) +export { BUILTIN_MODELS, getBuiltinModelsByProvider, getBuiltinModelById } from './model-catalog' + +// Content parsing (thinking-tag extraction, tool-call stripping, avatar sanitization) +export { THINK_TAGS, extractThinkingContent, stripToolCallTags, stripAvatarFields } from './content-parser' + +// Streaming think-tag parser (for models that embed in content) +export { StreamingThinkTagParser, needsStreamingThinkParsing } from './streaming-think-parser' +export type { StreamParserEvent } from './streaming-think-parser' + +// Thinking / reasoning level configuration (per-model level tables + compat) +export type { ThinkingLevel, ThinkingCompat } from './thinking' +export { getSupportedThinkingLevels, isReasoningModel, getThinkingCompat } from './thinking' + +// Tool result text extraction/truncation (persisted into content blocks for history replay) +export { MAX_PERSISTED_TOOL_RESULT_CHARS, extractToolResultText, truncateToolResultText } from './tool-result-text' + +// Chat evidence payload types (shared by tools / node-runtime / frontend) +export type { + EvidenceRetrievalMode, + EvidenceStatus, + EvidencePayloadStatus, + EvidenceWarning, + EvidenceTimeRangeMs, + ChatEvidenceSource, + ChatEvidenceGroup, + ChatEvidencePayload, +} from './evidence-types' diff --git a/packages/core/src/ai/model-catalog.ts b/packages/core/src/ai/model-catalog.ts new file mode 100644 index 000000000..e1ede4098 --- /dev/null +++ b/packages/core/src/ai/model-catalog.ts @@ -0,0 +1,897 @@ +/** + * Model Catalog — 内置模型目录 + */ + +import type { ModelDefinition } from './model-types' +export type { ModelDefinition } + +// ==================== Helper ==================== + +type ModelInput = Omit + +function builtin(m: ModelInput): ModelDefinition { + return { ...m, builtin: true, editable: false } +} + +// ==================== OpenAI ==================== + +const OPENAI_MODELS: ModelDefinition[] = [ + builtin({ + id: 'gpt-5.4-pro', + providerId: 'openai', + name: 'GPT-5.4 Pro', + description: 'Latest flagship, best reasoning & coding', + contextWindow: 1000000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'gpt-5.4', + providerId: 'openai', + name: 'GPT-5.4', + description: 'GPT-5.4 standard', + contextWindow: 1000000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'gpt-5.2-pro', + providerId: 'openai', + name: 'GPT-5.2 Pro', + description: 'GPT-5.2 Pro', + contextWindow: 1000000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'gpt-5.2', + providerId: 'openai', + name: 'GPT-5.2', + description: 'GPT-5.2 standard', + contextWindow: 1000000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'gpt-5.1', + providerId: 'openai', + name: 'GPT-5.1', + description: 'GPT-5.1', + contextWindow: 1000000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'gpt-5-pro', + providerId: 'openai', + name: 'GPT-5 Pro', + description: 'GPT-5 Pro', + contextWindow: 1000000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'gpt-5', + providerId: 'openai', + name: 'GPT-5', + description: 'GPT-5 standard', + contextWindow: 1000000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'gpt-4.1', + providerId: 'openai', + name: 'GPT-4.1', + description: 'Best at coding & instruction following', + contextWindow: 1047576, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'gpt-4.1-mini', + providerId: 'openai', + name: 'GPT-4.1 Mini', + description: 'Cost-effective', + contextWindow: 1047576, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'gpt-4.1-nano', + providerId: 'openai', + name: 'GPT-4.1 Nano', + description: 'Fastest and most affordable', + contextWindow: 1047576, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'gpt-4o', + providerId: 'openai', + name: 'GPT-4o', + description: 'Multimodal flagship model', + contextWindow: 128000, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'gpt-4o-mini', + providerId: 'openai', + name: 'GPT-4o Mini', + description: 'Lightweight multimodal model', + contextWindow: 128000, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'o3', + providerId: 'openai', + name: 'o3', + description: 'Advanced reasoning model', + contextWindow: 200000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'o4-mini', + providerId: 'openai', + name: 'o4-mini', + description: 'Efficient reasoning model', + contextWindow: 200000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'text-embedding-3-small', + providerId: 'openai', + name: 'Text Embedding 3 Small', + description: 'Efficient embedding model', + contextWindow: 8191, + capabilities: ['embedding'], + recommendedFor: ['embedding'], + status: 'stable', + }), + builtin({ + id: 'text-embedding-3-large', + providerId: 'openai', + name: 'Text Embedding 3 Large', + description: 'High-precision embedding model', + contextWindow: 8191, + capabilities: ['embedding'], + recommendedFor: ['embedding'], + status: 'stable', + }), +] + +// ==================== Anthropic ==================== + +const ANTHROPIC_MODELS: ModelDefinition[] = [ + builtin({ + id: 'claude-opus-4-6', + providerId: 'anthropic', + name: 'Claude Opus 4.6', + description: 'Latest flagship, adaptive thinking', + contextWindow: 200000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'claude-sonnet-4-6', + providerId: 'anthropic', + name: 'Claude Sonnet 4.6', + description: 'Cost-effective, adaptive thinking', + contextWindow: 200000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'claude-opus-4-5', + providerId: 'anthropic', + name: 'Claude Opus 4.5', + description: 'Previous generation flagship', + contextWindow: 200000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'claude-sonnet-4-5', + providerId: 'anthropic', + name: 'Claude Sonnet 4.5', + description: 'Previous generation cost-effective', + contextWindow: 200000, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'claude-haiku-4-5', + providerId: 'anthropic', + name: 'Claude Haiku 4.5', + description: 'Fast and lightweight', + contextWindow: 200000, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'claude-sonnet-4-20250514', + providerId: 'anthropic', + name: 'Claude Sonnet 4', + description: 'Sonnet 4 snapshot', + contextWindow: 200000, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'claude-opus-4-20250514', + providerId: 'anthropic', + name: 'Claude Opus 4', + description: 'Opus 4 snapshot', + contextWindow: 200000, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'claude-haiku-3-5-20241022', + providerId: 'anthropic', + name: 'Claude 3.5 Haiku', + description: '3.5 Haiku snapshot', + contextWindow: 200000, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), +] + +// ==================== Gemini ==================== + +const GEMINI_MODELS: ModelDefinition[] = [ + builtin({ + id: 'gemini-3.1-pro-preview', + providerId: 'gemini', + name: 'Gemini 3.1 Pro', + description: 'Latest Gemini 3.1 Pro preview', + contextWindow: 1048576, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'preview', + }), + builtin({ + id: 'gemini-3-pro-preview', + providerId: 'gemini', + name: 'Gemini 3 Pro', + description: 'Gemini 3 Pro preview', + contextWindow: 1048576, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'preview', + }), + builtin({ + id: 'gemini-2.5-flash', + providerId: 'gemini', + name: 'Gemini 2.5 Flash', + description: 'Cost-effective, low latency, adaptive thinking', + contextWindow: 1048576, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'gemini-2.5-pro', + providerId: 'gemini', + name: 'Gemini 2.5 Pro', + description: 'Deep reasoning and complex tasks', + contextWindow: 1048576, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'gemini-2.0-flash', + providerId: 'gemini', + name: 'Gemini 2.0 Flash', + description: 'Previous generation fast model', + contextWindow: 1048576, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'text-embedding-004', + providerId: 'gemini', + name: 'Text Embedding 004', + description: 'Google embedding model', + contextWindow: 2048, + capabilities: ['embedding'], + recommendedFor: ['embedding'], + status: 'stable', + }), +] + +// ==================== DeepSeek ==================== + +const DEEPSEEK_MODELS: ModelDefinition[] = [ + builtin({ + id: 'deepseek-v4-pro', + providerId: 'deepseek', + name: 'DeepSeek V4 Pro', + description: 'Flagship model, 1M context, thinking & non-thinking', + contextWindow: 1000000, + capabilities: ['chat', 'reasoning', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'deepseek-v4-flash', + providerId: 'deepseek', + name: 'DeepSeek V4 Flash', + description: 'Fast model, 1M context, thinking & non-thinking', + contextWindow: 1000000, + capabilities: ['chat', 'reasoning', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), +] + +// ==================== 通义千问 ==================== + +const QWEN_MODELS: ModelDefinition[] = [ + builtin({ + id: 'qwen3.5-plus', + providerId: 'qwen', + name: 'Qwen3.5 Plus', + description: 'Latest Qwen3.5 flagship', + contextWindow: 262144, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'qwen3.5-flash', + providerId: 'qwen', + name: 'Qwen3.5 Flash', + description: 'Qwen3.5 fast version', + contextWindow: 262144, + capabilities: ['chat', 'reasoning', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'qwen3-max', + providerId: 'qwen', + name: 'Qwen3 Max', + description: 'Qwen3 massive-scale', + contextWindow: 262144, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'qwen-max', + providerId: 'qwen', + name: 'Qwen Max', + description: 'Massive-scale language model', + contextWindow: 262144, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'qwen-plus', + providerId: 'qwen', + name: 'Qwen Plus', + description: 'Good quality, suitable for most scenarios', + contextWindow: 262144, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'qwen-turbo', + providerId: 'qwen', + name: 'Qwen Turbo', + description: 'Fast and cost-effective', + contextWindow: 1000000, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'qwen-long', + providerId: 'qwen', + name: 'Qwen Long', + description: 'Ultra-long context model', + contextWindow: 262144, + capabilities: ['chat'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'text-embedding-v3', + providerId: 'qwen', + name: 'Text Embedding V3', + description: 'Qwen embedding model', + contextWindow: 8192, + capabilities: ['embedding'], + recommendedFor: ['embedding'], + status: 'stable', + }), +] + +// ==================== 智谱 GLM ==================== + +const GLM_MODELS: ModelDefinition[] = [ + builtin({ + id: 'glm-5', + providerId: 'glm', + name: 'GLM-5', + description: 'Latest flagship model', + contextWindow: 200000, + capabilities: ['chat', 'reasoning', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'glm-4.7', + providerId: 'glm', + name: 'GLM-4.7', + description: 'GLM-4.7 high performance', + contextWindow: 200000, + capabilities: ['chat', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'glm-4.6v', + providerId: 'glm', + name: 'GLM-4.6V', + description: '4.6 multimodal model', + contextWindow: 200000, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'glm-4.6v-flash', + providerId: 'glm', + name: 'GLM-4.6V Flash', + description: '4.6V free multimodal', + contextWindow: 200000, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'glm-4.5', + providerId: 'glm', + name: 'GLM-4.5', + description: 'GLM-4.5 standard', + contextWindow: 200000, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'glm-4.5-flash', + providerId: 'glm', + name: 'GLM-4.5 Flash', + description: '4.5 free model', + contextWindow: 200000, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'glm-4-plus', + providerId: 'glm', + name: 'GLM-4 Plus', + description: 'Previous generation flagship', + contextWindow: 128000, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'glm-4-flash', + providerId: 'glm', + name: 'GLM-4 Flash', + description: 'Fast model', + contextWindow: 128000, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'embedding-3', + providerId: 'glm', + name: 'Embedding-3', + description: 'Zhipu embedding model', + contextWindow: 8192, + capabilities: ['embedding'], + recommendedFor: ['embedding'], + status: 'stable', + }), +] + +// ==================== Kimi ==================== + +const KIMI_MODELS: ModelDefinition[] = [ + builtin({ + id: 'kimi-k2.5', + providerId: 'kimi', + name: 'Kimi K2.5', + description: 'Latest flagship with vision support', + contextWindow: 262144, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'kimi-k2-thinking', + providerId: 'kimi', + name: 'Kimi K2 Thinking', + description: 'K2 deep reasoning', + contextWindow: 131072, + capabilities: ['chat', 'reasoning', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'kimi-k2-thinking-turbo', + providerId: 'kimi', + name: 'Kimi K2 Thinking Turbo', + description: 'K2 fast reasoning', + contextWindow: 131072, + capabilities: ['chat', 'reasoning', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'kimi-k2-0905-Preview', + providerId: 'kimi', + name: 'Kimi K2 Preview', + description: 'K2 preview', + contextWindow: 131072, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'preview', + }), + builtin({ + id: 'moonshot-v1-auto', + providerId: 'kimi', + name: 'Moonshot V1 Auto', + description: 'Auto-select context length', + contextWindow: 131072, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), +] + +// ==================== 豆包 ==================== + +const DOUBAO_MODELS: ModelDefinition[] = [ + builtin({ + id: 'doubao-seed-2-0-pro-260215', + providerId: 'doubao', + name: 'Doubao Seed 2.0 Pro', + description: 'Latest flagship model', + contextWindow: 256000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'doubao-seed-2-0-lite-260215', + providerId: 'doubao', + name: 'Doubao Seed 2.0 Lite', + description: 'Seed 2.0 lightweight', + contextWindow: 256000, + capabilities: ['chat', 'reasoning', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'doubao-seed-2-0-mini-260215', + providerId: 'doubao', + name: 'Doubao Seed 2.0 Mini', + description: 'Seed 2.0 ultra-fast', + contextWindow: 256000, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'doubao-seed-1-8-251228', + providerId: 'doubao', + name: 'Doubao Seed 1.8', + description: 'Previous generation flagship', + contextWindow: 128000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'doubao-seed-1-6-251015', + providerId: 'doubao', + name: 'Doubao Seed 1.6', + description: 'High-performance general model', + contextWindow: 128000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'doubao-seed-1-6-lite-251015', + providerId: 'doubao', + name: 'Doubao Seed 1.6 Lite', + description: 'Lightweight and cost-effective', + contextWindow: 128000, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), +] + +// ==================== 硅基流动 ==================== + +const SILICONFLOW_MODELS: ModelDefinition[] = [ + builtin({ + id: 'deepseek-ai/DeepSeek-V3.2', + providerId: 'siliconflow', + name: 'DeepSeek V3.2', + description: 'DeepSeek V3.2 on SiliconFlow', + contextWindow: 128000, + capabilities: ['chat', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'deepseek-ai/DeepSeek-R1', + providerId: 'siliconflow', + name: 'DeepSeek R1', + description: 'DeepSeek R1 reasoning model', + contextWindow: 65536, + capabilities: ['chat', 'reasoning'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'Qwen/Qwen3-8B', + providerId: 'siliconflow', + name: 'Qwen3 8B', + description: 'Qwen3 8B lightweight model', + contextWindow: 32768, + capabilities: ['chat'], + recommendedFor: [], + status: 'stable', + }), +] + +// ==================== Groq ==================== + +const GROQ_MODELS: ModelDefinition[] = [ + builtin({ + id: 'llama3-70b-8192', + providerId: 'groq', + name: 'LLaMA3 70B', + description: 'Meta LLaMA3 70B ultra-fast inference', + contextWindow: 8192, + capabilities: ['chat', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'llama3-8b-8192', + providerId: 'groq', + name: 'LLaMA3 8B', + description: 'Meta LLaMA3 8B lightweight ultra-fast', + contextWindow: 8192, + capabilities: ['chat'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'mistral-saba-24b', + providerId: 'groq', + name: 'Mistral Saba 24B', + description: 'Mistral Saba 24B', + contextWindow: 32768, + capabilities: ['chat'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'gemma-9b-it', + providerId: 'groq', + name: 'Gemma 9B', + description: 'Google Gemma 9B', + contextWindow: 8192, + capabilities: ['chat'], + recommendedFor: [], + status: 'stable', + }), +] + +// ==================== OpenRouter ==================== + +const OPENROUTER_MODELS: ModelDefinition[] = [ + builtin({ + id: 'deepseek/deepseek-v4-pro', + providerId: 'openrouter', + name: 'DeepSeek V4 Pro', + description: 'DeepSeek V4 Pro via OpenRouter', + contextWindow: 1000000, + capabilities: ['chat', 'reasoning', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'google/gemini-2.5-flash-preview', + providerId: 'openrouter', + name: 'Gemini 2.5 Flash', + description: 'Google Gemini 2.5 Flash via OpenRouter', + contextWindow: 1048576, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'mistralai/mistral-7b-instruct:free', + providerId: 'openrouter', + name: 'Mistral 7B (Free)', + description: 'Mistral 7B free tier', + contextWindow: 32768, + capabilities: ['chat'], + recommendedFor: [], + status: 'stable', + }), +] + +// ==================== xAI ==================== + +const XAI_MODELS: ModelDefinition[] = [ + builtin({ + id: 'grok-4', + providerId: 'xai', + name: 'Grok 4', + description: 'Latest flagship model', + contextWindow: 256000, + capabilities: ['chat', 'reasoning', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'grok-3', + providerId: 'xai', + name: 'Grok 3', + description: 'Grok 3 standard', + contextWindow: 131072, + capabilities: ['chat', 'vision', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'grok-3-fast', + providerId: 'xai', + name: 'Grok 3 Fast', + description: 'Grok 3 fast version', + contextWindow: 131072, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'grok-3-mini', + providerId: 'xai', + name: 'Grok 3 Mini', + description: 'Grok 3 small reasoning model', + contextWindow: 131072, + capabilities: ['chat', 'reasoning', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'grok-3-mini-fast', + providerId: 'xai', + name: 'Grok 3 Mini Fast', + description: 'Grok 3 Mini fast version', + contextWindow: 131072, + capabilities: ['chat', 'reasoning', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), +] + +// ==================== MiniMax ==================== + +const MINIMAX_MODELS: ModelDefinition[] = [ + builtin({ + id: 'MiniMax-M2.7', + providerId: 'minimax', + name: 'MiniMax M2.7', + description: 'Latest flagship model', + contextWindow: 200000, + capabilities: ['chat', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'MiniMax-M2.7-highspeed', + providerId: 'minimax', + name: 'MiniMax M2.7 HS', + description: 'M2.7 high-speed', + contextWindow: 200000, + capabilities: ['chat', 'function_calling'], + recommendedFor: ['chat'], + status: 'stable', + }), + builtin({ + id: 'MiniMax-M2.5', + providerId: 'minimax', + name: 'MiniMax M2.5', + description: 'M2.5 standard', + contextWindow: 204800, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), + builtin({ + id: 'MiniMax-M2.5-highspeed', + providerId: 'minimax', + name: 'MiniMax M2.5 HS', + description: 'M2.5 high-speed', + contextWindow: 204800, + capabilities: ['chat', 'function_calling'], + recommendedFor: [], + status: 'stable', + }), +] + +// ==================== 汇总导出 ==================== + +export const BUILTIN_MODELS: ModelDefinition[] = [ + ...OPENAI_MODELS, + ...ANTHROPIC_MODELS, + ...GEMINI_MODELS, + ...DEEPSEEK_MODELS, + ...QWEN_MODELS, + ...GLM_MODELS, + ...KIMI_MODELS, + ...DOUBAO_MODELS, + ...SILICONFLOW_MODELS, + ...GROQ_MODELS, + ...OPENROUTER_MODELS, + ...XAI_MODELS, + ...MINIMAX_MODELS, +] + +/** 按 provider 筛选内置模型 */ +export function getBuiltinModelsByProvider(providerId: string): ModelDefinition[] { + return BUILTIN_MODELS.filter((m) => m.providerId === providerId) +} + +/** 按 id + providerId 查找内置模型 */ +export function getBuiltinModelById(providerId: string, modelId: string): ModelDefinition | null { + return BUILTIN_MODELS.find((m) => m.providerId === providerId && m.id === modelId) || null +} diff --git a/packages/core/src/ai/model-types.ts b/packages/core/src/ai/model-types.ts new file mode 100644 index 000000000..c97ea1ef6 --- /dev/null +++ b/packages/core/src/ai/model-types.ts @@ -0,0 +1,94 @@ +/** + * 模型系统核心类型定义 + * 基于 Provider Registry + Model Catalog 分层架构 + */ + +// ==================== Provider 定义 ==================== + +export type ProviderKind = 'official' | 'aggregator' | 'openai-compatible' + +export interface ProviderDefinition { + id: string + name: string + kind: ProviderKind + website?: string + consoleUrl?: string + defaultBaseUrl: string + authMode: 'api-key' + supportsCustomModels: boolean + builtin: boolean + enabledByDefault: boolean + modelIds: string[] +} + +// ==================== Model 定义 ==================== + +export type ModelCapability = 'chat' | 'reasoning' | 'vision' | 'function_calling' | 'embedding' | 'ranking' + +export type ModelStatus = 'stable' | 'preview' | 'deprecated' + +export type ModelRecommendedFor = 'chat' | 'embedding' | 'rerank' + +export interface ModelDefinition { + id: string + providerId: string + name: string + description?: string + contextWindow?: number + capabilities: ModelCapability[] + recommendedFor: ModelRecommendedFor[] + status: ModelStatus + builtin: boolean + editable: boolean +} + +// ==================== 连接配置 ==================== + +export interface LLMConnectionConfig { + id: string + name: string + providerId: string + modelId: string + apiKey: string + baseUrl?: string + maxTokens?: number + createdAt: number + updatedAt: number +} + +export interface LLMConnectionConfigCompat extends LLMConnectionConfig { + customModels?: Array<{ id: string; name: string }> +} + +// ==================== 用途选择(预留) ==================== + +export type ModelUsage = 'chat' | 'embedding' + +export interface ModelSelectionState { + usage: ModelUsage + configId: string + providerId: string + modelId: string +} + +// ==================== 存储结构 ==================== + +export interface ProviderRegistryStore { + providers: ProviderDefinition[] +} + +export interface ModelCatalogStore { + models: ModelDefinition[] +} + +export interface ModelSlot { + configId: string + modelId: string +} + +export interface LLMConnectionStore { + configs: LLMConnectionConfigCompat[] + defaultAssistant: ModelSlot | null + fastModel: ModelSlot | null + schemaVersion: number +} diff --git a/packages/core/src/ai/provider-registry.ts b/packages/core/src/ai/provider-registry.ts new file mode 100644 index 000000000..cba6c282c --- /dev/null +++ b/packages/core/src/ai/provider-registry.ts @@ -0,0 +1,285 @@ +/** + * Provider Registry — 内置 provider 目录 + */ + +import type { ProviderDefinition } from './model-types' +export type { ProviderDefinition } + +// ==================== 内置 Provider 目录 ==================== + +const OPENAI: ProviderDefinition = { + id: 'openai', + name: 'OpenAI', + kind: 'official', + website: 'https://openai.com', + consoleUrl: 'https://platform.openai.com/api-keys', + defaultBaseUrl: 'https://api.openai.com/v1', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: [ + 'gpt-5.4-pro', + 'gpt-5.4', + 'gpt-5.2-pro', + 'gpt-5.2', + 'gpt-5.1', + 'gpt-5-pro', + 'gpt-5', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4o', + 'gpt-4o-mini', + 'o3', + 'o4-mini', + 'text-embedding-3-small', + 'text-embedding-3-large', + ], +} + +const ANTHROPIC: ProviderDefinition = { + id: 'anthropic', + name: 'Anthropic', + kind: 'official', + website: 'https://www.anthropic.com', + consoleUrl: 'https://console.anthropic.com/settings/keys', + defaultBaseUrl: 'https://api.anthropic.com', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: [ + 'claude-opus-4-6', + 'claude-sonnet-4-6', + 'claude-opus-4-5', + 'claude-sonnet-4-5', + 'claude-haiku-4-5', + 'claude-sonnet-4-20250514', + 'claude-opus-4-20250514', + 'claude-haiku-3-5-20241022', + ], +} + +const GEMINI: ProviderDefinition = { + id: 'gemini', + name: 'Gemini', + kind: 'official', + website: 'https://ai.google.dev', + consoleUrl: 'https://aistudio.google.com/apikey', + defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1beta', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: [ + 'gemini-3.1-pro-preview', + 'gemini-3-pro-preview', + 'gemini-2.5-flash', + 'gemini-2.5-pro', + 'gemini-2.0-flash', + 'text-embedding-004', + ], +} + +const DEEPSEEK: ProviderDefinition = { + id: 'deepseek', + name: 'DeepSeek', + kind: 'official', + website: 'https://www.deepseek.com', + consoleUrl: 'https://platform.deepseek.com/api_keys', + defaultBaseUrl: 'https://api.deepseek.com/v1', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: ['deepseek-v4-pro', 'deepseek-v4-flash'], +} + +const QWEN: ProviderDefinition = { + id: 'qwen', + name: 'Qwen', + kind: 'official', + website: 'https://tongyi.aliyun.com', + consoleUrl: 'https://dashscope.console.aliyun.com/apiKey', + defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: [ + 'qwen3.5-plus', + 'qwen3.5-flash', + 'qwen3-max', + 'qwen-max', + 'qwen-plus', + 'qwen-turbo', + 'qwen-long', + 'text-embedding-v3', + ], +} + +const GLM: ProviderDefinition = { + id: 'glm', + name: 'GLM', + kind: 'official', + website: 'https://www.zhipuai.cn', + consoleUrl: 'https://open.bigmodel.cn/usercenter/apikeys', + defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: [ + 'glm-5', + 'glm-4.7', + 'glm-4.6v', + 'glm-4.6v-flash', + 'glm-4.5', + 'glm-4.5-flash', + 'glm-4-plus', + 'glm-4-flash', + 'embedding-3', + ], +} + +const KIMI: ProviderDefinition = { + id: 'kimi', + name: 'Kimi', + kind: 'official', + website: 'https://www.moonshot.cn', + consoleUrl: 'https://platform.moonshot.cn/console/api-keys', + defaultBaseUrl: 'https://api.moonshot.cn/v1', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: ['kimi-k2.5', 'kimi-k2-thinking', 'kimi-k2-thinking-turbo', 'kimi-k2-0905-Preview', 'moonshot-v1-auto'], +} + +const DOUBAO: ProviderDefinition = { + id: 'doubao', + name: 'Doubao', + kind: 'official', + website: 'https://www.volcengine.com/product/doubao', + consoleUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey', + defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: [ + 'doubao-seed-2-0-pro-260215', + 'doubao-seed-2-0-lite-260215', + 'doubao-seed-2-0-mini-260215', + 'doubao-seed-1-8-251228', + 'doubao-seed-1-6-251015', + 'doubao-seed-1-6-lite-251015', + ], +} + +const SILICONFLOW: ProviderDefinition = { + id: 'siliconflow', + name: 'SiliconFlow', + kind: 'official', + website: 'https://siliconflow.cn', + consoleUrl: 'https://cloud.siliconflow.cn/account/ak', + defaultBaseUrl: 'https://api.siliconflow.cn/v1', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: ['deepseek-ai/DeepSeek-V3.2', 'deepseek-ai/DeepSeek-R1', 'Qwen/Qwen3-8B'], +} + +const GROQ: ProviderDefinition = { + id: 'groq', + name: 'Groq', + kind: 'official', + website: 'https://groq.com', + consoleUrl: 'https://console.groq.com/keys', + defaultBaseUrl: 'https://api.groq.com/openai/v1', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: ['llama3-70b-8192', 'llama3-8b-8192', 'mistral-saba-24b', 'gemma-9b-it'], +} + +const OPENROUTER: ProviderDefinition = { + id: 'openrouter', + name: 'OpenRouter', + kind: 'aggregator', + website: 'https://openrouter.ai', + consoleUrl: 'https://openrouter.ai/keys', + defaultBaseUrl: 'https://openrouter.ai/api/v1', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: ['deepseek/deepseek-v4-pro', 'google/gemini-2.5-flash-preview', 'mistralai/mistral-7b-instruct:free'], +} + +const XAI: ProviderDefinition = { + id: 'xai', + name: 'xAI', + kind: 'official', + website: 'https://x.ai', + consoleUrl: 'https://console.x.ai', + defaultBaseUrl: 'https://api.x.ai/v1', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: ['grok-4', 'grok-3', 'grok-3-fast', 'grok-3-mini', 'grok-3-mini-fast'], +} + +const MINIMAX: ProviderDefinition = { + id: 'minimax', + name: 'MiniMax', + kind: 'official', + website: 'https://www.minimaxi.com', + consoleUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', + defaultBaseUrl: 'https://api.minimaxi.com/v1', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'], +} + +const OPENAI_COMPATIBLE: ProviderDefinition = { + id: 'openai-compatible', + name: 'OpenAI Compatible', + kind: 'openai-compatible', + defaultBaseUrl: 'http://localhost:11434/v1', + authMode: 'api-key', + supportsCustomModels: true, + builtin: true, + enabledByDefault: true, + modelIds: [], +} + +// ==================== 内置目录导出 ==================== + +export const BUILTIN_PROVIDERS: ProviderDefinition[] = [ + OPENAI, + ANTHROPIC, + GEMINI, + DEEPSEEK, + QWEN, + GLM, + KIMI, + DOUBAO, + SILICONFLOW, + GROQ, + OPENROUTER, + XAI, + MINIMAX, + OPENAI_COMPATIBLE, +] + +/** 按 id 查找内置 provider */ +export function getBuiltinProviderById(id: string): ProviderDefinition | null { + return BUILTIN_PROVIDERS.find((p) => p.id === id) || null +} diff --git a/packages/core/src/ai/streaming-think-parser.ts b/packages/core/src/ai/streaming-think-parser.ts new file mode 100644 index 000000000..6bf5890a1 --- /dev/null +++ b/packages/core/src/ai/streaming-think-parser.ts @@ -0,0 +1,142 @@ +/** + * Streaming think-tag parser. + * + * Detects , , etc. tags embedded in streaming text deltas + * and splits them into separate thinking/content events. Handles tags split + * across multiple chunks via internal buffering. + */ + +import { THINK_TAGS } from './content-parser' + +export type StreamParserEvent = + | { type: 'content'; content: string } + | { type: 'thinking_start'; tag: string } + | { type: 'thinking_delta'; content: string } + | { type: 'thinking_end' } + +type ParserState = 'content' | 'thinking' + +export class StreamingThinkTagParser { + private state: ParserState = 'content' + private buffer = '' + private activeTag = '' + private emit: (event: StreamParserEvent) => void + + constructor(emit: (event: StreamParserEvent) => void) { + this.emit = emit + } + + feed(text: string): void { + this.buffer += text + this.processBuffer() + } + + /** Flush any remaining buffer (call at stream end). */ + flush(): void { + if (!this.buffer) return + if (this.state === 'thinking') { + this.emit({ type: 'thinking_delta', content: this.buffer }) + } else { + this.emit({ type: 'content', content: this.buffer }) + } + this.buffer = '' + } + + private processBuffer(): void { + while (this.buffer.length > 0) { + const ltIdx = this.buffer.indexOf('<') + + if (ltIdx === -1) { + this.emitBufferContent(this.buffer) + this.buffer = '' + return + } + + if (ltIdx > 0) { + this.emitBufferContent(this.buffer.slice(0, ltIdx)) + this.buffer = this.buffer.slice(ltIdx) + } + + // Buffer starts with '<', try to match a tag + const tagResult = this.tryMatchTag() + if (tagResult === 'incomplete') { + // Need more data; keep buffer for next feed() + return + } + if (tagResult === 'matched') { + // Tag was consumed and events emitted, continue processing + continue + } + // 'no_match': the '<' is not a think tag, emit it and advance + this.emitBufferContent('<') + this.buffer = this.buffer.slice(1) + } + } + + /** + * Try to match a think tag at the start of buffer. + * Returns: 'matched' if a tag was consumed, 'incomplete' if more data + * needed, 'no_match' if it's not a valid think tag. + */ + private tryMatchTag(): 'matched' | 'incomplete' | 'no_match' { + if (this.state === 'content') { + return this.tryMatchOpenTag() + } + return this.tryMatchCloseTag() + } + + private tryMatchOpenTag(): 'matched' | 'incomplete' | 'no_match' { + // Look for pattern + for (const tag of THINK_TAGS) { + const openTag = `<${tag}>` + if (this.buffer.startsWith(openTag)) { + this.buffer = this.buffer.slice(openTag.length) + this.state = 'thinking' + this.activeTag = tag + this.emit({ type: 'thinking_start', tag }) + return 'matched' + } + // Could be a partial match — check if buffer is a prefix of the tag + if (openTag.startsWith(this.buffer) && this.buffer.length < openTag.length) { + return 'incomplete' + } + } + return 'no_match' + } + + private tryMatchCloseTag(): 'matched' | 'incomplete' | 'no_match' { + const closeTag = `` + if (this.buffer.startsWith(closeTag)) { + this.buffer = this.buffer.slice(closeTag.length) + this.state = 'content' + this.emit({ type: 'thinking_end' }) + this.activeTag = '' + return 'matched' + } + // Could be a partial match + if (closeTag.startsWith(this.buffer) && this.buffer.length < closeTag.length) { + return 'incomplete' + } + // Not a close tag — emit the '<' as thinking content and continue + this.emit({ type: 'thinking_delta', content: '<' }) + this.buffer = this.buffer.slice(1) + return 'matched' + } + + private emitBufferContent(text: string): void { + if (!text) return + if (this.state === 'thinking') { + this.emit({ type: 'thinking_delta', content: text }) + } else { + this.emit({ type: 'content', content: text }) + } + } +} + +/** + * Check if a model likely embeds think tags in content + * (i.e. uses native output rather than separate reasoning fields). + */ +export function needsStreamingThinkParsing(provider: string, _modelId: string): boolean { + return provider === 'minimax' +} diff --git a/packages/core/src/ai/thinking.ts b/packages/core/src/ai/thinking.ts new file mode 100644 index 000000000..d6057cac3 --- /dev/null +++ b/packages/core/src/ai/thinking.ts @@ -0,0 +1,288 @@ +/** + * Thinking / Reasoning level configuration for AI models. + * + * Inspired by cherry-studio's MODEL_SUPPORTED_REASONING_EFFORT table. + * This module is the single source of truth for: + * - which models support thinking and at what granularity + * - what `compat` fields pi-ai needs to actually inject the right request params + * + * Scope: openai-completions API path only. + * anthropic-messages / google-generative-ai are out of scope for this version. + */ + +// ── Types ───────────────────────────────────────────────────────────────────── + +/** + * ThinkingLevel values the UI selector can display. + * - 'default': no reasoning params sent — rely on model's native default behavior + * - 'off': explicitly disable reasoning + * - 'auto': let the model flexibly decide reasoning intensity + * - others: specific intensity levels + */ +export type ThinkingLevel = 'default' | 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'auto' + +/** Used internally to categorise a model into a thinking behaviour group. */ +type ThinkingType = + | 'none' // model is not a reasoning model + | 'qwen' // Qwen/GLM-family: boolean enable_thinking (off/on) + | 'deepseek_v4' // DeepSeek V4+: thinkingFormat:'deepseek', off/high/xhigh + | 'deepseek_hybrid' // DeepSeek V3.x hybrid inference: supports auto + | 'o_series' // OpenAI o1/o3: can't disable thinking; low/medium/high only + | 'gpt5' // OpenAI gpt-5 base: can't disable; minimal/low/medium/high + | 'gpt5_1' // OpenAI gpt-5.1: off→none supported; off/low/medium/high + | 'gpt5_2plus' // OpenAI gpt-5.2+: off→none + xhigh; full range + | 'grok' // xAI grok: can't disable; low/high only + | 'kimi' // Kimi thinking: off/auto/low/medium/high + | 'doubao' // Doubao seed reasoning: off/auto/high + | 'hunyuan' // Hunyuan: enable_thinking boolean, similar to qwen + | 'gemini' // Gemini 2.5+: thinking level via Google API (off/low/medium/high) + | 'default' // generic reasoning: off / low / medium / high + +// ── Internal: per-type level tables ────────────────────────────────────────── + +/** UI options for each type — what the selector will show. */ +const TYPE_LEVELS: Record = { + none: [], + qwen: ['default', 'off', 'high'], + deepseek_v4: ['default', 'off', 'high', 'xhigh'], + deepseek_hybrid: ['default', 'off', 'auto'], + o_series: ['default', 'low', 'medium', 'high'], + gpt5: ['default', 'minimal', 'low', 'medium', 'high'], + gpt5_1: ['default', 'off', 'low', 'medium', 'high'], + gpt5_2plus: ['default', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh'], + grok: ['default', 'low', 'high'], + kimi: ['default', 'off', 'auto', 'low', 'medium', 'high'], + doubao: ['default', 'off', 'auto', 'high'], + hunyuan: ['default', 'off', 'high'], + gemini: ['default', 'off', 'low', 'medium', 'high'], + default: ['default', 'off', 'low', 'medium', 'high'], +} + +/** + * thinkingLevelMap entries for models that use `reasoning_effort`. + * `null` means that level is explicitly not supported. + * `undefined` (key absent) means pass the level string verbatim. + * Matches pi-ai's convention in models.generated.js. + */ +const TYPE_LEVEL_MAP: Partial>>> = { + gpt5: { + minimal: 'minimal', + low: 'low', + medium: 'medium', + high: 'high', + }, + gpt5_1: { + off: 'none', + low: 'low', + medium: 'medium', + high: 'high', + xhigh: null, + }, + gpt5_2plus: { + off: 'none', + minimal: 'minimal', + low: 'low', + medium: 'medium', + high: 'high', + xhigh: 'xhigh', + }, + grok: { + minimal: null, + low: 'low', + medium: null, + high: 'high', + xhigh: null, + }, + kimi: { + off: 'none', + auto: 'auto', + minimal: null, + low: 'low', + medium: 'medium', + high: 'high', + xhigh: null, + }, + doubao: { + off: null, + auto: 'auto', + high: 'high', + }, + deepseek_hybrid: { + off: null, + auto: 'auto', + high: 'high', + }, +} + +// ── Internal: model → ThinkingType classification ───────────────────────────── + +/** + * Broad regex matching common reasoning model naming patterns. + * Used as a fallback when no specific provider/model rule matches. + * Excludes explicit '-non-reasoning' variants. + */ +const REASONING_FALLBACK_REGEX = + /^(?!.*-non-reasoning\b)(o\d+(?:-[\w-]+)?|.*\b(?:reasoning|reasoner|thinking|think)\b.*|.*-[rR]\d+.*|.*\bqwq(?:-[\w-]+)?\b.*|.*\bhunyuan-t1(?:-[\w-]+)?\b.*|.*\bgrok-(?:3-mini|4|4-fast|build)(?:-[\w-]+)?\b.*)$/i + +/** + * Classify a model into a ThinkingType based on provider + model id. + */ +function classifyThinkingType(provider: string, modelId: string): ThinkingType { + const id = modelId.toLowerCase() + const prov = provider.toLowerCase() + + // ── Explicit non-reasoning variants ── + if (id.includes('-non-reasoning')) return 'none' + + // ── Anthropic uses different API format (out of scope) ── + if (prov === 'anthropic') return 'none' + + // ── Gemini 2.5+ reasoning models (via Google Generative AI API) ── + if (prov === 'gemini') { + if (/gemini-(?:2\.5|3(?:\.\d+)?-(?:flash|pro)|flash-latest|pro-latest)/i.test(id)) return 'gemini' + return 'none' + } + + // ── Qwen (official DashScope + self-hosted models containing qwen/qwq) ── + if (prov === 'qwen' || /\bqwen|qwq/i.test(id)) return 'qwen' + + // ── DeepSeek V4+ ── + if (/deepseek[_-]v([4-9]|\d{2,})/i.test(id) || /deepseek-ai\/deepseek-r1/i.test(id)) return 'deepseek_v4' + + // ── DeepSeek V3.x hybrid inference (deepseek-chat, deepseek-v3.x) ── + if (/deepseek[_-]chat|deepseek[_-]v3/i.test(id)) return 'deepseek_hybrid' + + // ── OpenAI gpt-5.x family ── + if (prov === 'openai' || prov === 'openai-compatible') { + if (/gpt-5\.[2-9]|gpt-5\.[1-9]\d/.test(id)) return 'gpt5_2plus' + if (/gpt-5\.1/.test(id)) return 'gpt5_1' + if (/gpt-5/.test(id)) return 'gpt5' + if (/^o\d/.test(id)) return 'o_series' + } + // o-series on other providers (e.g., Azure, OpenRouter) + if (/^o\d/.test(id)) return 'o_series' + + // ── xAI Grok reasoning models (grok-3-mini, grok-4, grok-4-fast, grok-build) ── + if (/\bgrok-(?:3-mini|4|4-fast|build)\b/i.test(id)) return 'grok' + + // ── Kimi thinking models (k2-thinking, k2.5+, k3+) ── + if (/\bkimi-k(?:2-thinking|2\.[5-9]|[3-9])/i.test(id)) return 'kimi' + if (prov === 'kimi' && /thinking/i.test(id)) return 'kimi' + + // ── Doubao seed reasoning ── + if (/\bdoubao-.*seed/i.test(id) || (prov === 'doubao' && /seed/i.test(id))) return 'doubao' + + // ── Hunyuan reasoning models ── + if (/\bhunyuan-(?:t1|a13b)/i.test(id)) return 'hunyuan' + + // ── GLM (ZAI) — glm-5, glm-4.5/4.6/4.7, glm-z1, glm-zero-preview ── + if (prov === 'glm' || /\bglm-?(?:5|4\.[5-7]|z1|zero)/i.test(id)) return 'qwen' + + // ── SiliconFlow hosted reasoning models (DeepSeek R1 family) ── + if (prov === 'siliconflow' && /r[1-9]/i.test(id)) return 'deepseek_v4' + + // ── OpenRouter: classify by inner model id ── + if (prov === 'openrouter') { + if (/deepseek.*(v[4-9]|r\d)/i.test(id)) return 'deepseek_v4' + if (/deepseek[_-]?(chat|v3)/i.test(id)) return 'deepseek_hybrid' + if (/\bgrok-(?:3-mini|4|4-fast|build)\b/i.test(id)) return 'grok' + if (/\bkimi-k(?:2-thinking|2\.[5-9]|[3-9])/i.test(id)) return 'kimi' + if (REASONING_FALLBACK_REGEX.test(id)) return 'default' + return 'none' + } + + // ── Models using thinking:{type:'disabled'/'enabled'} format (same as DeepSeek) ── + if (/\bmimo-v2/i.test(id)) return 'deepseek_hybrid' + if (/\bminimax-m[123]/i.test(id)) return 'deepseek_hybrid' + if (/\bseed-oss/i.test(id)) return 'deepseek_hybrid' + + // ── Known reasoning model families → 'default' bucket (supportsReasoningEffort) ── + if (/\bbaichuan-m[23]/i.test(id)) return 'default' + if (/\bmagistral/i.test(id)) return 'default' + if (/\bmistral-small-2603/i.test(id)) return 'default' + if (/\bstep-(?:3|r1-v-mini)/i.test(id)) return 'default' + if (/\bgemma-?4/i.test(id)) return 'default' + if (/\bpangu-pro-moe/i.test(id)) return 'default' + if (/\bsonar-deep-research/i.test(id)) return 'default' + if (/\bring-(?:1t|mini|flash)/i.test(id)) return 'default' + + // ── Self-hosted / unknown — heuristic from model name ── + if (/\bqwen|qwq/i.test(id)) return 'qwen' + if (/deepseek[_-]?r\d|deepseek[_-]?v[4-9]/i.test(id)) return 'deepseek_v4' + if (/^o\d/i.test(id)) return 'o_series' + + // Broad fallback: match common reasoning naming patterns + if (REASONING_FALLBACK_REGEX.test(id)) return 'default' + + return 'none' +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Returns the list of ThinkingLevel values the selector should show for a model. + * An empty array means the model has no thinking support — hide the selector. + * + * Used by: UI (ChatStatusBar) to render the level picker. + */ +export function getSupportedThinkingLevels(provider: string, modelId: string): ThinkingLevel[] { + const type = classifyThinkingType(provider, modelId) + return TYPE_LEVELS[type] +} + +/** + * Returns whether the model/provider combo is a reasoning-capable model + * on the openai-completions path. (anthropic/gemini always return false here.) + * + * Used by: buildPiModel to set `PiModel.reasoning`. + */ +export function isReasoningModel(provider: string, modelId: string): boolean { + return classifyThinkingType(provider, modelId) !== 'none' +} + +/** + * PiModel compat fragment that makes the chosen thinkingLevel actually reach + * the request body. Must be spread into the PiModel returned by buildPiModel. + * + * Used by: buildPiModel (openai-completions branch). + */ +export interface ThinkingCompat { + thinkingFormat?: 'qwen' | 'deepseek' + supportsReasoningEffort?: true + thinkingLevelMap?: Partial> +} + +export function getThinkingCompat(provider: string, modelId: string): ThinkingCompat { + const type = classifyThinkingType(provider, modelId) + + if (type === 'none') return {} + + // Gemini uses pi-ai's native Google provider; no compat fields needed. + if (type === 'gemini') return {} + + if (type === 'qwen' || type === 'hunyuan') { + return { thinkingFormat: 'qwen' } + } + + if (type === 'deepseek_v4') { + return { + thinkingFormat: 'deepseek', + thinkingLevelMap: { high: 'high', xhigh: 'max', minimal: null, low: null, medium: null }, + } + } + + if (type === 'deepseek_hybrid') { + return { + thinkingFormat: 'deepseek', + thinkingLevelMap: { auto: 'auto', high: 'high', minimal: null, low: null, medium: null, xhigh: null }, + } + } + + // All remaining types use OpenAI-style reasoning_effort. + const compat: ThinkingCompat = { supportsReasoningEffort: true } + const levelMap = TYPE_LEVEL_MAP[type] + if (levelMap) { + compat.thinkingLevelMap = levelMap as Partial> + } + return compat +} diff --git a/packages/core/src/ai/tool-catalog.ts b/packages/core/src/ai/tool-catalog.ts new file mode 100644 index 000000000..ab3a7ad00 --- /dev/null +++ b/packages/core/src/ai/tool-catalog.ts @@ -0,0 +1,64 @@ +/** + * 内置工具目录(静态数据) + * + * 仅包含工具名称和分类,不含任何运行时依赖。 + * 用于前端展示工具列表和助手配置面板。 + */ + +export type ToolCategory = 'core' | 'analysis' + +export interface BuiltinToolCatalogEntry { + name: string + category: ToolCategory +} + +const LEGACY_BUILTIN_TOOL_NAME_ALIASES: Record = { + get_session_messages: 'get_segment_messages', + get_session_summaries: 'get_segment_summaries', +} + +export function normalizeBuiltinToolName(toolName: string): string { + return LEGACY_BUILTIN_TOOL_NAME_ALIASES[toolName] ?? toolName +} + +export function normalizeBuiltinToolNames(toolNames: readonly string[]): string[] { + return Array.from(new Set(toolNames.map(normalizeBuiltinToolName))) +} + +export const CHART_CAPABILITY_SKILL_ID = 'chart_runtime' + +export const BUILTIN_TOOL_CATALOG: BuiltinToolCatalogEntry[] = [ + // Core 工具 + { name: 'get_chat_overview', category: 'core' }, + { name: 'search_messages', category: 'core' }, + { name: 'deep_search_messages', category: 'core' }, + { name: 'get_recent_messages', category: 'core' }, + { name: 'get_message_context', category: 'core' }, + { name: 'get_segment_messages', category: 'core' }, + { name: 'get_members', category: 'core' }, + { name: 'get_schema', category: 'core' }, + + // Analysis 工具 + { name: 'get_member_stats', category: 'analysis' }, + { name: 'get_time_stats', category: 'analysis' }, + { name: 'get_member_name_history', category: 'analysis' }, + { name: 'get_conversation_between', category: 'analysis' }, + { name: 'get_segment_summaries', category: 'analysis' }, + { name: 'response_time_analysis', category: 'analysis' }, + { name: 'keyword_frequency', category: 'analysis' }, + { name: 'render_chart', category: 'analysis' }, + { name: 'execute_sql', category: 'analysis' }, + + // SQL Analysis 工具 + { name: 'message_type_breakdown', category: 'analysis' }, + { name: 'peak_chat_hours_by_member', category: 'analysis' }, + { name: 'member_activity_trend', category: 'analysis' }, + { name: 'silent_members', category: 'analysis' }, + { name: 'reply_interaction_ranking', category: 'analysis' }, + { name: 'mutual_interaction_pairs', category: 'analysis' }, + { name: 'member_message_length_stats', category: 'analysis' }, + { name: 'daily_active_members', category: 'analysis' }, + { name: 'conversation_initiator_stats', category: 'analysis' }, + { name: 'activity_heatmap', category: 'analysis' }, + { name: 'unanswered_messages', category: 'analysis' }, +] diff --git a/packages/core/src/ai/tool-result-text.ts b/packages/core/src/ai/tool-result-text.ts new file mode 100644 index 000000000..14ba19b96 --- /dev/null +++ b/packages/core/src/ai/tool-result-text.ts @@ -0,0 +1,37 @@ +/** + * Tool result text extraction and truncation. + * + * Tool results are persisted (truncated) into assistant message content blocks + * so later turns can replay them as real toolCall/toolResult message pairs, + * keeping the model grounded in what it actually retrieved. + */ + +/** + * Max characters of a single tool result persisted into a content block. + * Bounds both DB row size and the per-result token cost when history is replayed. + */ +export const MAX_PERSISTED_TOOL_RESULT_CHARS = 4000 + +const TRUNCATION_MARKER = '\n…[truncated]' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Extract the text the model saw from an AgentToolResult-shaped value + * (`{ content: [{ type: 'text', text }], details? }`). Non-text parts are skipped. + */ +export function extractToolResultText(toolResult: unknown): string { + if (!isRecord(toolResult)) return '' + if (!Array.isArray(toolResult.content)) return '' + return toolResult.content + .map((part) => (isRecord(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : '')) + .filter((text) => text.length > 0) + .join('\n') +} + +export function truncateToolResultText(text: string, maxChars: number = MAX_PERSISTED_TOOL_RESULT_CHARS): string { + if (text.length <= maxChars) return text + return text.slice(0, maxChars) + TRUNCATION_MARKER +} diff --git a/packages/core/src/chart/index.test.ts b/packages/core/src/chart/index.test.ts new file mode 100644 index 000000000..7af439548 --- /dev/null +++ b/packages/core/src/chart/index.test.ts @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { buildChartPayload, ChartValidationError } from './index' + +test('builds a pie chart from dynamic label/value fields', () => { + const chart = buildChartPayload( + [ + { member_name: 'Alice', msg_count: 3 }, + { member_name: 'Bob', msg_count: 5 }, + ], + { + version: 1, + type: 'pie', + title: 'Selected members', + encoding: { label: 'member_name', value: 'msg_count' }, + } + ) + + assert.equal(chart.spec.type, 'pie') + assert.deepEqual(chart.data, { labels: ['Alice', 'Bob'], values: [3, 5] }) +}) + +test('builds line series when encoding.series is provided', () => { + const chart = buildChartPayload( + [ + { day: '2026-06-01', member_name: 'Alice', msg_count: 2 }, + { day: '2026-06-01', member_name: 'Bob', msg_count: 4 }, + { day: '2026-06-02', member_name: 'Alice', msg_count: 1 }, + ], + { + version: 1, + type: 'line', + title: 'Member trend', + encoding: { x: 'day', y: 'msg_count', series: 'member_name' }, + } + ) + + assert.deepEqual(chart.data, { + labels: ['2026-06-01', '2026-06-02'], + values: [2, 1], + series: [ + { name: 'Alice', values: [2, 1] }, + { name: 'Bob', values: [4, 0] }, + ], + }) +}) + +test('builds heatmap indices from x/y/value fields', () => { + const chart = buildChartPayload( + [ + { hour: 9, weekday: 'Mon', msg_count: 2 }, + { hour: 10, weekday: 'Mon', msg_count: 3 }, + { hour: 9, weekday: 'Tue', msg_count: 4 }, + ], + { + version: 1, + type: 'heatmap', + title: 'Activity heatmap', + encoding: { x: 'hour', y: 'weekday', value: 'msg_count' }, + } + ) + + assert.deepEqual(chart.data, { + xLabels: ['9', '10'], + yLabels: ['Mon', 'Tue'], + data: [ + [0, 0, 2], + [1, 0, 3], + [0, 1, 4], + ], + }) +}) + +test('rejects missing encoding fields', () => { + assert.throws( + () => + buildChartPayload([{ label: 'Alice', count: 1 }], { + version: 1, + type: 'bar', + title: 'Broken chart', + encoding: { x: 'label', y: 'missing_count' }, + }), + ChartValidationError + ) +}) diff --git a/packages/core/src/chart/index.ts b/packages/core/src/chart/index.ts new file mode 100644 index 000000000..7268b2ec4 --- /dev/null +++ b/packages/core/src/chart/index.ts @@ -0,0 +1,336 @@ +export type ChartType = 'bar' | 'line' | 'pie' | 'heatmap' + +export type ChartFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'date' | 'datetime' | 'category' + +export interface ChartField { + name: string + type: ChartFieldType + label?: string + unit?: string +} + +export interface ChartEncoding { + x?: string + y?: string + value?: string + label?: string + series?: string + color?: string +} + +export interface ChartSpec { + version: 1 + type: ChartType + title: string + subtitle?: string + description?: string + encoding: ChartEncoding + fields?: ChartField[] + unit?: string + filters?: { + timeRange?: { startTs?: number; endTs?: number; label?: string } + members?: Array<{ id?: number; name: string }> + keywords?: string[] + messageTypes?: number[] + custom?: Record + } + display?: { + horizontal?: boolean + stacked?: boolean + showLegend?: boolean + showDataZoom?: boolean + height?: number + } +} + +export interface ChartDataset { + columns: ChartField[] + rows: Record[] +} + +export interface BarChartRenderData { + labels: string[] + values: number[] +} + +export interface LineChartRenderData { + labels: string[] + values: number[] + series?: Array<{ name: string; values: number[] }> +} + +export interface PieChartRenderData { + labels: string[] + values: number[] +} + +export interface HeatmapChartRenderData { + xLabels: string[] + yLabels: string[] + data: Array<[number, number, number]> +} + +export type ChartRenderData = BarChartRenderData | LineChartRenderData | PieChartRenderData | HeatmapChartRenderData + +export interface ChartPayload { + version: 1 + spec: ChartSpec + dataset: ChartDataset + data: ChartRenderData + rowCount: number + truncated?: boolean +} + +export class ChartValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'ChartValidationError' + } +} + +const CHART_TYPES = new Set(['bar', 'line', 'pie', 'heatmap']) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asFieldName(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ChartValidationError(`${label} must be a non-empty field name`) + } + return value +} + +function assertFieldExists(rows: Record[], field: string): void { + if (rows.length === 0) return + if (!Object.prototype.hasOwnProperty.call(rows[0], field)) { + throw new ChartValidationError(`Field "${field}" does not exist in SQL result`) + } +} + +function toLabel(value: unknown): string { + if (value === null || value === undefined || value === '') return '(empty)' + return String(value) +} + +function toNumber(value: unknown, field: string): number { + const num = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(num)) { + throw new ChartValidationError(`Field "${field}" contains non-numeric value "${String(value)}"`) + } + return num +} + +function inferFieldType(values: unknown[]): ChartFieldType { + const present = values.filter((v) => v !== null && v !== undefined) + if (present.length === 0) return 'string' + if (present.every((v) => typeof v === 'number' && Number.isInteger(v))) return 'integer' + if ( + present.every( + (v) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) + ) + ) { + return 'number' + } + if (present.every((v) => typeof v === 'boolean')) return 'boolean' + return 'category' +} + +export function normalizeChartSpec(raw: unknown): ChartSpec { + if (!isRecord(raw)) throw new ChartValidationError('chartSpec must be an object') + if (raw.version !== 1) throw new ChartValidationError('chartSpec.version must be 1') + if (typeof raw.type !== 'string' || !CHART_TYPES.has(raw.type as ChartType)) { + throw new ChartValidationError('chartSpec.type must be one of: bar, line, pie, heatmap') + } + if (typeof raw.title !== 'string' || raw.title.trim().length === 0) { + throw new ChartValidationError('chartSpec.title must be a non-empty string') + } + if (!isRecord(raw.encoding)) throw new ChartValidationError('chartSpec.encoding must be an object') + + const spec = raw as unknown as ChartSpec + switch (spec.type) { + case 'bar': + case 'line': + asFieldName(spec.encoding.x, 'encoding.x') + asFieldName(spec.encoding.y, 'encoding.y') + break + case 'pie': + asFieldName(spec.encoding.label, 'encoding.label') + asFieldName(spec.encoding.value, 'encoding.value') + break + case 'heatmap': + asFieldName(spec.encoding.x, 'encoding.x') + asFieldName(spec.encoding.y, 'encoding.y') + asFieldName(spec.encoding.value, 'encoding.value') + break + } + + return { + ...spec, + title: spec.title.trim(), + } +} + +export function inferChartDataset(rows: Record[], declaredFields?: ChartField[]): ChartDataset { + const declared = new Map((declaredFields ?? []).map((field) => [field.name, field])) + const columnNames = rows.length > 0 ? Object.keys(rows[0]) : [...declared.keys()] + const columns = columnNames.map((name) => { + const declaredField = declared.get(name) + if (declaredField) return declaredField + return { + name, + type: inferFieldType(rows.map((row) => row[name])), + } + }) + + return { columns, rows } +} + +function aggregateByLabel( + rows: Record[], + labelField: string, + valueField: string +): { + labels: string[] + values: number[] +} { + const labels: string[] = [] + const indexByLabel = new Map() + const values: number[] = [] + + for (const row of rows) { + const label = toLabel(row[labelField]) + let index = indexByLabel.get(label) + if (index === undefined) { + index = labels.length + indexByLabel.set(label, index) + labels.push(label) + values.push(0) + } + values[index] += toNumber(row[valueField], valueField) + } + + return { labels, values } +} + +function buildLineData(rows: Record[], spec: ChartSpec): LineChartRenderData { + const xField = spec.encoding.x! + const yField = spec.encoding.y! + const seriesField = spec.encoding.series + + if (!seriesField) return aggregateByLabel(rows, xField, yField) + + assertFieldExists(rows, seriesField) + const labels: string[] = [] + const labelIndex = new Map() + const seriesNames: string[] = [] + const seriesIndex = new Map() + const matrix: number[][] = [] + + for (const row of rows) { + const label = toLabel(row[xField]) + const seriesName = toLabel(row[seriesField]) + let xIndex = labelIndex.get(label) + if (xIndex === undefined) { + xIndex = labels.length + labelIndex.set(label, xIndex) + labels.push(label) + for (const values of matrix) values.push(0) + } + + let sIndex = seriesIndex.get(seriesName) + if (sIndex === undefined) { + sIndex = seriesNames.length + seriesIndex.set(seriesName, sIndex) + seriesNames.push(seriesName) + matrix.push(Array(labels.length).fill(0)) + } + + matrix[sIndex][xIndex] += toNumber(row[yField], yField) + } + + const series = seriesNames.map((name, index) => ({ name, values: matrix[index] })) + return { + labels, + values: series[0]?.values ?? [], + series, + } +} + +function buildHeatmapData(rows: Record[], spec: ChartSpec): HeatmapChartRenderData { + const xField = spec.encoding.x! + const yField = spec.encoding.y! + const valueField = spec.encoding.value! + const xLabels: string[] = [] + const yLabels: string[] = [] + const xIndex = new Map() + const yIndex = new Map() + const values = new Map() + + for (const row of rows) { + const xLabel = toLabel(row[xField]) + const yLabel = toLabel(row[yField]) + let xi = xIndex.get(xLabel) + if (xi === undefined) { + xi = xLabels.length + xIndex.set(xLabel, xi) + xLabels.push(xLabel) + } + let yi = yIndex.get(yLabel) + if (yi === undefined) { + yi = yLabels.length + yIndex.set(yLabel, yi) + yLabels.push(yLabel) + } + const key = `${xi}:${yi}` + values.set(key, (values.get(key) ?? 0) + toNumber(row[valueField], valueField)) + } + + const data: Array<[number, number, number]> = [] + for (const [key, value] of values.entries()) { + const [x, y] = key.split(':').map(Number) + data.push([x, y, value]) + } + + return { xLabels, yLabels, data } +} + +export function buildChartPayload( + rows: Record[], + rawSpec: unknown, + options?: { truncated?: boolean } +): ChartPayload { + const spec = normalizeChartSpec(rawSpec) + const dataset = inferChartDataset(rows, spec.fields) + + const requiredFields = new Set() + for (const field of Object.values(spec.encoding)) { + if (typeof field === 'string' && field) requiredFields.add(field) + } + for (const field of requiredFields) assertFieldExists(rows, field) + + let data: ChartRenderData + switch (spec.type) { + case 'bar': + data = aggregateByLabel(rows, spec.encoding.x!, spec.encoding.y!) + break + case 'line': + data = buildLineData(rows, spec) + break + case 'pie': + data = aggregateByLabel(rows, spec.encoding.label!, spec.encoding.value!) + break + case 'heatmap': + data = buildHeatmapData(rows, spec) + break + } + + return { + version: 1, + spec, + dataset, + data, + rowCount: rows.length, + truncated: options?.truncated, + } +} diff --git a/packages/core/src/import/__tests__/dedup.test.ts b/packages/core/src/import/__tests__/dedup.test.ts new file mode 100644 index 000000000..b7943241c --- /dev/null +++ b/packages/core/src/import/__tests__/dedup.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { generateMessageKey } from '../dedup' + +describe('generateMessageKey', () => { + const ts = 1710000000 + const sender = 'user-1' + + it('empty string and null produce the same key (storage normalization)', () => { + const keyEmpty = generateMessageKey(ts, sender, '') + const keyNull = generateMessageKey(ts, sender, null) + assert.equal(keyEmpty, keyNull) + }) + + it('different content produces different keys even with the same length', () => { + const a = generateMessageKey(ts, sender, '你好啊') + const b = generateMessageKey(ts, sender, '再见呀') + assert.notEqual(a, b) + }) + + it('different timestamps produce different keys', () => { + const a = generateMessageKey(ts, sender, 'hello') + const b = generateMessageKey(ts + 1, sender, 'hello') + assert.notEqual(a, b) + }) + + it('different senders produce different keys', () => { + const a = generateMessageKey(ts, 'alice', 'hello') + const b = generateMessageKey(ts, 'bob', 'hello') + assert.notEqual(a, b) + }) + + it('returns a non-empty hex string', () => { + const key = generateMessageKey(ts, sender, 'test content') + assert.ok(key.length > 0) + assert.ok(/^[0-9a-f]+$/.test(key), `key should be hex but got: ${key}`) + }) + + it('is deterministic for identical inputs', () => { + const a = generateMessageKey(ts, sender, 'same content') + const b = generateMessageKey(ts, sender, 'same content') + assert.equal(a, b) + }) + + it('null content and the literal string "null" produce different keys', () => { + const keyNull = generateMessageKey(ts, sender, null) + const keyLiteral = generateMessageKey(ts, sender, 'null') + assert.notEqual(keyNull, keyLiteral) + }) +}) diff --git a/packages/core/src/import/__tests__/writers.test.ts b/packages/core/src/import/__tests__/writers.test.ts new file mode 100644 index 000000000..f83b32f23 --- /dev/null +++ b/packages/core/src/import/__tests__/writers.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { buildMemberIdMap } from '../writers' +import type { DatabaseAdapter } from '../../interfaces' + +function createMockDb(members: Array<{ id: number; platform_id: string }>): DatabaseAdapter { + return { + prepare: (_sql: string) => ({ + all: () => [...members], + get: () => undefined, + run: () => ({ changes: 0 }), + }), + exec: () => {}, + transaction: (fn: () => T) => fn(), + pragma: () => undefined, + close: () => {}, + } +} + +describe('buildMemberIdMap', () => { + it('returns empty map for empty member table', () => { + const db = createMockDb([]) + const map = buildMemberIdMap(db) + assert.equal(map.size, 0) + }) + + it('maps platform_id to internal row id', () => { + const db = createMockDb([ + { id: 1, platform_id: 'alice' }, + { id: 2, platform_id: 'bob' }, + { id: 3, platform_id: 'charlie' }, + ]) + const map = buildMemberIdMap(db) + assert.equal(map.size, 3) + assert.equal(map.get('alice'), 1) + assert.equal(map.get('bob'), 2) + assert.equal(map.get('charlie'), 3) + }) + + it('returns undefined for unknown platform_id', () => { + const db = createMockDb([{ id: 1, platform_id: 'alice' }]) + const map = buildMemberIdMap(db) + assert.equal(map.get('unknown'), undefined) + }) +}) diff --git a/packages/core/src/import/dedup.ts b/packages/core/src/import/dedup.ts new file mode 100644 index 000000000..e23c91fa5 --- /dev/null +++ b/packages/core/src/import/dedup.ts @@ -0,0 +1,51 @@ +/** + * Canonical message deduplication key generator. + * + * Used by both Electron (worker import, merger) and Server (importer) + * to produce deterministic content-hash keys for messages that lack + * a platform_message_id. + * + * Uses a pure-JS FNV-1a 64-bit hash (browser-safe, no Node.js deps). + * Segments are NUL-separated; content is preceded by a type discriminator + * so that null content and the literal string "null" hash differently. + * + * Empty-string content is normalized to null before hashing to match the + * storage-layer behavior where '' is folded to NULL in SQLite. + */ + +/** + * FNV-1a 64-bit hash implemented with two 32-bit halves. + * Returns a 16-char hex string (~11 chars base36). + */ +function fnv1a64(input: string): string { + // FNV offset basis for 64-bit: 0xcbf29ce484222325 + let h0 = 0x811c9dc5 // low 32 + let h1 = 0xcbf29ce4 // high 32 + + for (let i = 0; i < input.length; i++) { + const c = input.charCodeAt(i) + h0 ^= c + // FNV prime for 64-bit: 0x00000100000001B3 + // Multiply (h1:h0) * 0x01000193 (32-bit FNV prime for low half mixing) + // and cross-mix to approximate 64-bit FNV + const t0 = Math.imul(h0, 0x01000193) + const t1 = Math.imul(h1, 0x01000193) + Math.imul(h0, 0x01) + h0 = t0 >>> 0 + h1 = t1 >>> 0 + } + + const hi = h1.toString(16).padStart(8, '0') + const lo = h0.toString(16).padStart(8, '0') + return hi + lo +} + +export function generateMessageKey(timestamp: number, senderPlatformId: string, content: string | null): string { + const normalizedContent = content || null + const parts = [ + String(timestamp), + senderPlatformId, + normalizedContent === null ? 'null' : 'text', + normalizedContent ?? '', + ] + return fnv1a64(parts.join('\0')) +} diff --git a/packages/core/src/import/index.ts b/packages/core/src/import/index.ts new file mode 100644 index 000000000..9a148bca7 --- /dev/null +++ b/packages/core/src/import/index.ts @@ -0,0 +1,2 @@ +export { generateMessageKey } from './dedup' +export { buildMemberIdMap } from './writers' diff --git a/packages/core/src/import/writers.ts b/packages/core/src/import/writers.ts new file mode 100644 index 000000000..8c876f269 --- /dev/null +++ b/packages/core/src/import/writers.ts @@ -0,0 +1,21 @@ +/** + * Shared import writer helpers. + * + * Functions here operate on DatabaseAdapter and can be used by any + * environment that wraps its DB connection accordingly. + */ + +import type { DatabaseAdapter } from '../interfaces' + +/** + * Build a Map from member platform_id → internal row id. + * Used after bulk member insert to resolve sender_id for messages. + */ +export function buildMemberIdMap(db: DatabaseAdapter): Map { + const rows = db.prepare('SELECT id, platform_id FROM member').all() as Array<{ id: number; platform_id: string }> + const map = new Map() + for (const row of rows) { + map.set(row.platform_id, row.id) + } + return map +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 000000000..c46901eca --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,363 @@ +/** + * @openchatlab/core + * + * 平台无关的 ChatLab 共享核心。 + * 提供抽象接口、查询工具、分析算法,不依赖任何特定运行时(Electron / Node / 浏览器)。 + */ + +// 抽象接口 +export type { DatabaseAdapter, PreparedStatement, RunResult, PathProvider } from './interfaces' + +// 查询工具 +export { + buildTimeFilter, + buildSystemMessageFilter, + hasTable, + hasColumn, + isChatSessionDb, + getSessionMeta, + getSessionOverview, + getDatabaseSchema, + getChatOverview, + getSegmentMessages, + getSegmentSummaries, + buildSessionInfo, + getSessionInfo, + getSummaryCount, + getLastPlatformMessageId, + DEFAULT_SESSION_GAP_THRESHOLD, + hasSessionIndex, + getSessionIndexStats, + getChatSessionList, + getSessionsByTimeRange, + getRecentChatSessions, + loadSegmentMessages, + getSegmentSummary, + saveSegmentSummary, + updateSessionGapThreshold, + clearSessionIndex, + generateSessionIndex, + generateIncrementalSessionIndex, + getPrivateChatMemberAvatar, + getExportSessionData, + getTimeRange, + getAvailableYears, + getMemberActivity, + getHourlyActivity, + getDailyActivity, + getWeekdayActivity, + getMessageTypeStats, + getMonthlyActivity, + getYearlyActivity, + getMessageLengthDistribution, + getTextStats, + getLongMessageCount, + getMemberMonthlyTrend, + getTextLengthPercentiles, + getGroupContactFacts, + getGroupRelationshipGraphFacts, + getLatestContactMessageTs, + getNonSystemMembersForContacts, + getPrivateContactFacts, + isValidContactPlatformId, + resolveOwnerMember, + MIN_PRIVATE_SESSIONS_FOR_CONTACTS, + computeFriendScore, + computeFriendScores, + computeNonFriendScore, + computeNonFriendScores, + computePrivateRegularity, + rankPercentiles, + queryMessages, + searchMessagesLike, + searchMessagesByKeywords, + getRecentMessages, + getMembers, + getMembersDetailed, + executeReadonlySql, + executeSql, + getSchemaDetailed, + getMessageContext, + getSearchMessageContext, + getConversationBetween, + getMemberNameHistory, + getMembersWithAliases, + getMembersPaginated, + executeParameterizedSql, + getCatchphraseAnalysis, + getMentionAnalysis, + getMentionGraph, + getLaughAnalysis, + getClusterGraph, + getRelationshipStats, + getLanguagePreferenceAnalysis, + getDragonKingAnalysis, + getDivingAnalysis, + getCheckInAnalysis, + getMemeBattleAnalysis, + getNightOwlAnalysis, + getRepeatAnalysis, + FULL_MSG_COLUMNS, + FULL_MSG_FROM, + FULL_MSG_SELECT, + MSG_COUNT_FROM, + SYSTEM_MSG_FILTER, + TEXT_ONLY_FILTER, + mapMessageRow, + buildMsgConditions, + fetchMessagesBefore, + fetchMessagesAfter, + searchMessagesLikeAsync, + searchMessagesWithFtsAsync, + fetchMessageContext, + fetchSearchMessageContext, + fetchAllRecentMessages, + fetchRecentTextMessages, + fetchConversationBetween, + updateMemberAliases, + mergeMembers, + deleteMember, + ensureAliasesColumn, + ensureAvatarColumn, + updateSessionOwnerId, + renameSession, +} from './query' + +// 查询类型 +export type { + SessionMeta, + SessionOverview, + SessionInfo, + CoreSessionInfo, + ChatOverviewData, + SegmentMessagesData, + SegmentSummaryData, + ChatSessionItem, + SessionIndexStats, + SessionPreviewMessage, + MemberActivity, + HourlyActivity, + DailyActivity, + WeekdayActivity, + MessageTypeStats, + MonthlyActivity, + YearlyActivity, + MessageLengthDistribution, + TextStats, + TextLengthPercentiles, + MemberMonthlyTrend, + ContactFactsOptions, + ContactMemberRef, + GroupContactFacts, + GroupRelationshipGraphFacts, + PrivateContactFacts, + RelationshipGraphEdgeFact, + RelationshipGraphMemberFact, + ContactScoringResult, + FriendScoreComponents, + FriendScoreInput, + NonFriendScoreComponents, + NonFriendScoreInput, + QueryMessagesOptions, + QueryMessagesResult, + MessageResult, + PaginatedMessages, + MemberDetailed, + ContextMessage, + ConversationData, + MemberNameHistoryEntry, + MemberWithAliases, + MembersPaginationParams, + MembersPaginatedResult, + CatchphraseAnalysis, + MemberCatchphrase, + CatchphraseItem, + MentionGraphData, + MentionGraphNode, + MentionGraphLink, + ClusterGraphData, + ClusterGraphNode, + ClusterGraphLink, + ClusterGraphOptions, + RelationshipStats, + RelationshipMonthStats, + IceBreakerItem, + ResponseLatencyMember, + PerseveranceMember, + MonthlyResponseLatency, + MonthlyPerseverance, + RelationshipOptions, + NlpProvider, + PosTagResult, + LanguagePreferenceParams, + NightOwlTitle, + NightOwlRankItem, + TimeRankItem, + ConsecutiveNightRecord, + NightOwlChampion, + NightOwlAnalysis, + DragonKingRankItem, + DragonKingAnalysis, + DivingRankItem, + DivingAnalysis, + RepeatStatItem, + RepeatRateItem, + ChainLengthDistribution, + HotRepeatContent, + FastestRepeaterItem, + RepeatAnalysis, + MemeBattleRankItem, + MemeBattleRecord, + MemeBattleAnalysis, + StreakRankItem, + LoyaltyRankItem, + CheckInAnalysis, + FullMessageRow, + MappedMessage, + MsgQueryConditions, + AsyncSqlExecutor, + AsyncPaginatedMessages, + AsyncMessagesWithTotal, + AsyncConversationData, + SqlExecutionOptions, + SqlExecutionResult, + TableSchema, +} from './query' + +// 版本比较 +export { isNewerStableVersion, isStableVersion } from './version' +export type { ParsedStableVersion } from './version' + +// Chart runtime +export { ChartValidationError, normalizeChartSpec, inferChartDataset, buildChartPayload } from './chart' +export type { + ChartType, + ChartFieldType, + ChartField, + ChartEncoding, + ChartSpec, + ChartDataset, + ChartPayload, + ChartRenderData, + BarChartRenderData, + LineChartRenderData, + PieChartRenderData, + HeatmapChartRenderData, +} from './chart' + +// NLP(平台无关的类型、数据和工具函数) +export { + POS_TAG_DEFINITIONS, + MEANINGFUL_POS_TAGS, + CHINESE_STOPWORDS, + ENGLISH_STOPWORDS, + JAPANESE_STOPWORDS, + getStopwords, + isStopword, + cleanText, + isValidWord, +} from './nlp' +export type { + SupportedLocale, + PosFilterMode, + DictType, + PosTagInfo, + WordFrequencyItem, + PosTagStat, + WordFrequencyResult, + WordFrequencyParams, + SegmentOptions, + BatchSegmentOptions, + BatchSegmentResult, + DictInfo, +} from './nlp' + +// AI(内置工具目录、LLM 模型系统等静态数据) +export type { ToolCategory, BuiltinToolCatalogEntry } from './ai' +export { BUILTIN_TOOL_CATALOG, normalizeBuiltinToolName, normalizeBuiltinToolNames } from './ai' +export type { + ProviderKind, + ProviderDefinition, + ModelCapability, + ModelStatus, + ModelRecommendedFor, + ModelDefinition, + ModelSlot, +} from './ai' +export { + BUILTIN_PROVIDERS, + getBuiltinProviderById, + BUILTIN_MODELS, + getBuiltinModelsByProvider, + getBuiltinModelById, + CHART_CAPABILITY_SKILL_ID, + THINK_TAGS, + extractThinkingContent, + stripToolCallTags, + stripAvatarFields, + StreamingThinkTagParser, + needsStreamingThinkParsing, + getSupportedThinkingLevels, + isReasoningModel, + getThinkingCompat, + MAX_PERSISTED_TOOL_RESULT_CHARS, + extractToolResultText, + truncateToolResultText, +} from './ai' +export type { StreamParserEvent, ThinkingLevel, ThinkingCompat } from './ai' +export type { + EvidenceRetrievalMode, + EvidenceStatus, + EvidencePayloadStatus, + EvidenceWarning, + EvidenceTimeRangeMs, + ChatEvidenceSource, + ChatEvidenceGroup, + ChatEvidencePayload, +} from './ai' + +// Owner profile matching(跨会话"我是谁"识别) +export { + NAME_MATCH_PLATFORMS, + isNameMatchPlatform, + normalizeOwnerName, + collectCandidateNames, + mergeConfirmedNames, + matchOwnerProfile, +} from './owner' +export type { OwnerMatchCandidate, OwnerMatchResult } from './owner' + +// Import utilities +export { generateMessageKey, buildMemberIdMap } from './import' + +// Merger algorithms +export { + getCollidingPlatformIds, + getCollidingPlatformIdsFromMessages, + normalizePlatformId, + detectConflictsInMessages, + mergeMembers as mergeMergerMembers, + deduplicateAndSortMessages, +} from './merger' +export type { + MergerMember, + MergerMessage, + MergeConflict, + ConflictCheckResult, + MergedMember, + MergedMessage, +} from './merger' + +// Schema 与迁移 +export { + CURRENT_SCHEMA_VERSION, + CHAT_DB_TABLES, + CHAT_DB_INDEXES, + CHAT_DB_SCHEMA, + FTS_TABLE_SCHEMA, + getSchemaVersion, + setSchemaVersion, + needsMigration, + runMigrations, +} from './schema' +export type { Migration } from './schema' diff --git a/packages/core/src/interfaces/database-adapter.ts b/packages/core/src/interfaces/database-adapter.ts new file mode 100644 index 000000000..da9475233 --- /dev/null +++ b/packages/core/src/interfaces/database-adapter.ts @@ -0,0 +1,79 @@ +/** + * 数据库适配器抽象接口 + * + * 定义平台无关的 SQLite 数据库访问契约。 + * 接口设计贴合 better-sqlite3 API(因为现有查询代码基于此编写), + * sql.js 等其他实现只需编写薄适配层。 + */ + +/** + * 预编译语句接口 + */ +export interface PreparedStatement { + /** + * 语句是否为只读(SELECT / WITH ... SELECT) + * better-sqlite3 原生支持此属性,用于安全检查。 + */ + readonly?: boolean + + /** + * 执行查询并返回第一行结果 + */ + get(...params: unknown[]): Record | undefined + + /** + * 执行查询并返回所有行 + */ + all(...params: unknown[]): Record[] + + /** + * 执行写操作(INSERT / UPDATE / DELETE) + */ + run(...params: unknown[]): RunResult +} + +/** + * 写操作的返回结果 + */ +export interface RunResult { + changes: number + lastInsertRowid?: number | bigint +} + +/** + * 数据库适配器接口 + * + * 调用方通过此接口操作 SQLite 数据库,不关心底层使用 + * better-sqlite3(Node.js)还是 sql.js(浏览器 WASM)。 + */ +export interface DatabaseAdapter { + /** + * 执行原始 SQL(不返回结果,用于 DDL 或批量语句) + */ + exec(sql: string): void + + /** + * 预编译 SQL 语句 + */ + prepare(sql: string): PreparedStatement + + /** + * 在事务中执行操作 + */ + transaction(fn: () => T): T + + /** + * 执行 PRAGMA 命令 + */ + pragma(pragma: string): unknown + + /** + * 关闭数据库连接 + */ + close(): void + + /** + * 数据库是否处于只读模式 + */ + readonly?: boolean +} diff --git a/packages/core/src/interfaces/index.ts b/packages/core/src/interfaces/index.ts new file mode 100644 index 000000000..6cae62002 --- /dev/null +++ b/packages/core/src/interfaces/index.ts @@ -0,0 +1,2 @@ +export type { DatabaseAdapter, PreparedStatement, RunResult } from './database-adapter' +export type { PathProvider } from './path-provider' diff --git a/packages/core/src/interfaces/path-provider.ts b/packages/core/src/interfaces/path-provider.ts new file mode 100644 index 000000000..e2aa5619a --- /dev/null +++ b/packages/core/src/interfaces/path-provider.ts @@ -0,0 +1,55 @@ +/** + * 路径提供器抽象接口 + * + * 统一不同运行环境下的目录路径获取方式: + * - Electron:主进程 paths.ts 实现 + * - Node 独立运行:NodePathProvider 实现 + * + * 目录分为两类: + * + * 1. 系统数据(固定在 ~/.chatlab/,不可更改): + * ~/.chatlab/ + * ├── config.toml + * ├── ai/ AI 对话历史、助手/技能/LLM 配置 + * ├── settings/ 用户界面偏好 + * ├── cache/ 派生数据缓存(可再生) + * ├── temp/ 临时文件 + * └── logs/ 日志 + * + * 2. 用户核心数据(可配置位置): + * {userDataDir}/ + * ├── databases/ 聊天记录 SQLite 文件({uuid}.db) + * ├── vector/ 向量数据库(预留) + * └── media/ 媒体文件(预留) + */ +export interface PathProvider { + /** 系统数据根目录(固定 ~/.chatlab/) */ + getSystemDir(): string + + /** 用户数据根目录(可配置) */ + getUserDataDir(): string + + /** 数据库文件目录(存放 {uuid}.db),基于 userDataDir */ + getDatabaseDir(): string + + /** 向量库目录(存放 embedding_index.db 等可再生派生索引),基于 userDataDir */ + getVectorDir(): string + + /** AI 数据目录(对话历史、LLM 配置),基于 systemDir */ + getAiDataDir(): string + + /** 设置目录,基于 systemDir */ + getSettingsDir(): string + + /** 缓存目录(存放可再生的派生数据),基于 systemDir */ + getCacheDir(): string + + /** 临时文件目录,基于 systemDir */ + getTempDir(): string + + /** 日志目录,基于 systemDir */ + getLogsDir(): string + + /** 下载目录(导出文件的默认保存位置) */ + getDownloadsDir(): string +} diff --git a/packages/core/src/merger/__tests__/algorithms.test.ts b/packages/core/src/merger/__tests__/algorithms.test.ts new file mode 100644 index 000000000..aa45f13c0 --- /dev/null +++ b/packages/core/src/merger/__tests__/algorithms.test.ts @@ -0,0 +1,145 @@ +/** + * Tests for merger pure algorithms. + * + * Run: npx tsx --test packages/core/src/merger/__tests__/algorithms.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + getCollidingPlatformIds, + normalizePlatformId, + detectConflictsInMessages, + mergeMembers, + deduplicateAndSortMessages, + type MergerMessage, +} from '../algorithms' + +describe('getCollidingPlatformIds', () => { + it('detects IDs that appear in multiple platforms', () => { + const result = getCollidingPlatformIds([ + { platform: 'qq', members: [{ platformId: 'user1' }, { platformId: 'user2' }] }, + { platform: 'wechat', members: [{ platformId: 'user1' }, { platformId: 'user3' }] }, + ]) + assert.ok(result.has('user1')) + assert.ok(!result.has('user2')) + assert.ok(!result.has('user3')) + }) + + it('returns empty set when no collisions', () => { + const result = getCollidingPlatformIds([ + { platform: 'qq', members: [{ platformId: 'a' }] }, + { platform: 'wechat', members: [{ platformId: 'b' }] }, + ]) + assert.equal(result.size, 0) + }) +}) + +describe('normalizePlatformId', () => { + it('returns original ID when not colliding', () => { + const collidingIds = new Set() + assert.equal(normalizePlatformId('user1', 'qq', collidingIds), 'user1') + }) + + it('returns namespaced ID when colliding', () => { + const collidingIds = new Set(['user1']) + const result = normalizePlatformId('user1', 'qq', collidingIds) + assert.ok(result.includes('__chatlab_platform__')) + assert.ok(result.includes('qq')) + assert.ok(result.includes('user1')) + }) + + it('produces different IDs for same platformId on different platforms', () => { + const collidingIds = new Set(['user1']) + const a = normalizePlatformId('user1', 'qq', collidingIds) + const b = normalizePlatformId('user1', 'wechat', collidingIds) + assert.notEqual(a, b) + }) +}) + +describe('detectConflictsInMessages', () => { + it('detects conflicts for same sender/timestamp but different content from different files', () => { + const msg = (content: string): MergerMessage => ({ + senderPlatformId: 'u1', + timestamp: 100, + type: 0, + content, + }) + const result = detectConflictsInMessages([ + { msg: msg('hello'), source: 'file1.txt', platform: 'qq' }, + { msg: msg('world'), source: 'file2.txt', platform: 'qq' }, + ]) + assert.equal(result.conflicts.length, 1) + assert.equal(result.conflicts[0].content1, 'hello') + assert.equal(result.conflicts[0].content2, 'world') + }) + + it('no conflicts when same content from different files', () => { + const msg = (content: string): MergerMessage => ({ + senderPlatformId: 'u1', + timestamp: 100, + type: 0, + content, + }) + const result = detectConflictsInMessages([ + { msg: msg('same'), source: 'file1.txt', platform: 'qq' }, + { msg: msg('same'), source: 'file2.txt', platform: 'qq' }, + ]) + assert.equal(result.conflicts.length, 0) + }) + + it('counts unique messages after dedup', () => { + const msg1: MergerMessage = { senderPlatformId: 'u1', timestamp: 100, type: 0, content: 'hello' } + const msg2: MergerMessage = { senderPlatformId: 'u2', timestamp: 200, type: 0, content: 'world' } + const result = detectConflictsInMessages([ + { msg: msg1, source: 'a', platform: 'qq' }, + { msg: msg1, source: 'b', platform: 'qq' }, + { msg: msg2, source: 'a', platform: 'qq' }, + ]) + assert.equal(result.totalMessages, 2) + }) +}) + +describe('mergeMembers', () => { + it('merges members from multiple sources', () => { + const result = mergeMembers( + [ + { platform: 'qq', members: [{ platformId: 'u1', accountName: 'Alice' }] }, + { + platform: 'qq', + members: [ + { platformId: 'u1', groupNickname: 'A' }, + { platformId: 'u2', accountName: 'Bob' }, + ], + }, + ], + new Set() + ) + assert.equal(result.size, 2) + const u1 = result.get('u1')! + assert.equal(u1.accountName, 'Alice') + assert.equal(u1.groupNickname, 'A') + }) +}) + +describe('deduplicateAndSortMessages', () => { + it('removes duplicates and sorts by timestamp', () => { + const msg = (ts: number, content: string): MergerMessage => ({ + senderPlatformId: 'u1', + timestamp: ts, + type: 0, + content, + }) + const result = deduplicateAndSortMessages( + [ + { platform: 'qq', messages: [msg(300, 'c'), msg(100, 'a')] }, + { platform: 'qq', messages: [msg(100, 'a'), msg(200, 'b')] }, + ], + new Set() + ) + assert.equal(result.length, 3) + assert.equal(result[0].timestamp, 100) + assert.equal(result[1].timestamp, 200) + assert.equal(result[2].timestamp, 300) + }) +}) diff --git a/packages/core/src/merger/algorithms.ts b/packages/core/src/merger/algorithms.ts new file mode 100644 index 000000000..578bcc93a --- /dev/null +++ b/packages/core/src/merger/algorithms.ts @@ -0,0 +1,246 @@ +/** + * Merger pure algorithms — platform ID collision detection, + * normalization, conflict detection, and dedup-sort merge. + * + * All functions are pure (no I/O, no Node.js deps). + */ + +import { generateMessageKey } from '../import/dedup' + +// ==================== Types ==================== + +export interface MergerMember { + platformId: string + accountName?: string + groupNickname?: string + avatar?: string +} + +export interface MergerMessage { + senderPlatformId: string + senderAccountName?: string + senderGroupNickname?: string + timestamp: number + type: number + content?: string | null +} + +export interface MergeConflict { + id: string + timestamp: number + sender: string + contentLength1: number + contentLength2: number + content1: string + content2: string +} + +export interface ConflictCheckResult { + conflicts: MergeConflict[] + totalMessages: number +} + +export interface MergedMember { + platformId: string + accountName?: string + groupNickname?: string + avatar?: string +} + +export interface MergedMessage { + sender: string + accountName?: string + groupNickname?: string + timestamp: number + type: number + content?: string | null +} + +// ==================== Platform ID collision ==================== + +export function getCollidingPlatformIds( + sources: Array<{ platform: string; members: Array<{ platformId: string }> }> +): Set { + const map = new Map>() + for (const source of sources) { + for (const member of source.members) { + if (!map.has(member.platformId)) map.set(member.platformId, new Set()) + map.get(member.platformId)!.add(source.platform || 'unknown') + } + } + const result = new Set() + for (const [id, platforms] of map) { + if (platforms.size > 1) result.add(id) + } + return result +} + +export function getCollidingPlatformIdsFromMessages( + allMessages: Array<{ msg: MergerMessage; platform: string }> +): Set { + const map = new Map>() + for (const item of allMessages) { + const pid = item.msg.senderPlatformId + if (!map.has(pid)) map.set(pid, new Set()) + map.get(pid)!.add(item.platform || 'unknown') + } + const result = new Set() + for (const [id, platforms] of map) { + if (platforms.size > 1) result.add(id) + } + return result +} + +export function normalizePlatformId(platformId: string, platform: string, collidingIds: Set): string { + if (!collidingIds.has(platformId)) return platformId + const np = encodeURIComponent(platform || 'unknown') + const ni = encodeURIComponent(platformId) + return `__chatlab_platform__${np}__${ni}` +} + +// ==================== Helpers ==================== + +function getMessageKey(msg: MergerMessage, senderOverride?: string): string { + return generateMessageKey(msg.timestamp, senderOverride || msg.senderPlatformId, msg.content ?? null) +} + +function getDisplayName(msg: MergerMessage): string { + return msg.senderGroupNickname || msg.senderAccountName || msg.senderPlatformId +} + +function isImageOnlyMessage(content: string | undefined | null): boolean { + if (!content) return false + return /^\[图片:\s*.+\]$/.test(content.trim()) +} + +// ==================== Conflict detection ==================== + +export function detectConflictsInMessages( + allMessages: Array<{ msg: MergerMessage; source: string; platform: string }> +): ConflictCheckResult { + const collidingIds = getCollidingPlatformIdsFromMessages(allMessages) + const conflicts: MergeConflict[] = [] + + const timeGroups = new Map>() + for (const item of allMessages) { + const ts = item.msg.timestamp + if (!timeGroups.has(ts)) timeGroups.set(ts, []) + timeGroups.get(ts)!.push(item) + } + + for (const [ts, items] of timeGroups) { + if (items.length < 2) continue + + const senderGroups = new Map() + for (const item of items) { + const sender = normalizePlatformId(item.msg.senderPlatformId, item.platform || 'unknown', collidingIds) + if (!senderGroups.has(sender)) senderGroups.set(sender, []) + senderGroups.get(sender)!.push(item) + } + + for (const [sender, senderItems] of senderGroups) { + if (senderItems.length < 2) continue + const sources = new Set(senderItems.map((it) => it.source)) + if (sources.size < 2) continue + + const contentGroups = new Map() + for (const item of senderItems) { + const c = item.msg.content || '' + if (!contentGroups.has(c)) contentGroups.set(c, []) + contentGroups.get(c)!.push(item) + } + + if (contentGroups.size > 1) { + const entries = Array.from(contentGroups.entries()) + for (let i = 0; i < entries.length - 1; i++) { + for (let j = i + 1; j < entries.length; j++) { + const [c1, items1] = entries[i] + const [c2, items2] = entries[j] + const item1 = items1[0] + const item2 = items2.find((it) => it.source !== item1.source) + if (!item2) continue + if (isImageOnlyMessage(c1) && isImageOnlyMessage(c2)) continue + + conflicts.push({ + id: `conflict_${ts}_${sender}_${conflicts.length}`, + timestamp: ts, + sender: getDisplayName(item1.msg) || sender, + contentLength1: c1.length, + contentLength2: c2.length, + content1: c1, + content2: c2, + }) + } + } + } + } + } + + const uniqueKeys = new Set() + for (const item of allMessages) { + const nid = normalizePlatformId(item.msg.senderPlatformId, item.platform || 'unknown', collidingIds) + uniqueKeys.add(getMessageKey(item.msg, nid)) + } + + return { conflicts, totalMessages: uniqueKeys.size } +} + +// ==================== Member merge ==================== + +export function mergeMembers( + sources: Array<{ platform: string; members: MergerMember[] }>, + collidingIds: Set +): Map { + const map = new Map() + for (const { platform, members } of sources) { + const p = platform || 'unknown' + for (const m of members) { + const nid = normalizePlatformId(m.platformId, p, collidingIds) + const existing = map.get(nid) + if (existing) { + if (m.accountName) existing.accountName = m.accountName + if (m.groupNickname) existing.groupNickname = m.groupNickname + if (m.avatar) existing.avatar = m.avatar + } else { + map.set(nid, { + platformId: nid, + accountName: m.accountName, + groupNickname: m.groupNickname, + avatar: m.avatar, + }) + } + } + } + return map +} + +// ==================== Message dedup + sort ==================== + +export function deduplicateAndSortMessages( + sources: Array<{ platform: string; messages: MergerMessage[] }>, + collidingIds: Set +): MergedMessage[] { + const seen = new Set() + const merged: MergedMessage[] = [] + + for (const { platform, messages } of sources) { + const p = platform || 'unknown' + for (const msg of messages) { + const nid = normalizePlatformId(msg.senderPlatformId, p, collidingIds) + const key = getMessageKey(msg, nid) + if (seen.has(key)) continue + seen.add(key) + merged.push({ + sender: nid, + accountName: msg.senderAccountName, + groupNickname: msg.senderGroupNickname, + timestamp: msg.timestamp, + type: msg.type, + content: msg.content, + }) + } + } + + merged.sort((a, b) => a.timestamp - b.timestamp) + return merged +} diff --git a/packages/core/src/merger/index.ts b/packages/core/src/merger/index.ts new file mode 100644 index 000000000..3ed0a80e2 --- /dev/null +++ b/packages/core/src/merger/index.ts @@ -0,0 +1,17 @@ +export { + getCollidingPlatformIds, + getCollidingPlatformIdsFromMessages, + normalizePlatformId, + detectConflictsInMessages, + mergeMembers, + deduplicateAndSortMessages, +} from './algorithms' + +export type { + MergerMember, + MergerMessage, + MergeConflict, + ConflictCheckResult, + MergedMember, + MergedMessage, +} from './algorithms' diff --git a/packages/core/src/nlp/index.ts b/packages/core/src/nlp/index.ts new file mode 100644 index 000000000..6fd1c64e3 --- /dev/null +++ b/packages/core/src/nlp/index.ts @@ -0,0 +1,27 @@ +/** + * NLP 模块(平台无关) + * + * 提供类型定义、停用词表、词性标签定义、文本处理工具。 + * 不包含依赖原生模块的分词引擎实现(在 @openchatlab/node-runtime 中)。 + */ + +export type { + SupportedLocale, + PosFilterMode, + DictType, + PosTagInfo, + WordFrequencyItem, + PosTagStat, + WordFrequencyResult, + WordFrequencyParams, + SegmentOptions, + BatchSegmentOptions, + BatchSegmentResult, + DictInfo, +} from './types' + +export { POS_TAG_DEFINITIONS, MEANINGFUL_POS_TAGS } from './pos-tags' + +export { CHINESE_STOPWORDS, ENGLISH_STOPWORDS, JAPANESE_STOPWORDS, getStopwords, isStopword } from './stopwords' + +export { cleanText, isValidWord } from './text-utils' diff --git a/packages/core/src/nlp/pos-tags.ts b/packages/core/src/nlp/pos-tags.ts new file mode 100644 index 000000000..93292fcab --- /dev/null +++ b/packages/core/src/nlp/pos-tags.ts @@ -0,0 +1,41 @@ +/** + * 词性标签定义(静态数据,平台无关) + */ + +import type { PosTagInfo } from './types' + +export const POS_TAG_DEFINITIONS: PosTagInfo[] = [ + { tag: 'n', name: '名词', description: '普通名词', meaningful: true }, + { tag: 'nr', name: '人名', description: '人名', meaningful: true }, + { tag: 'ns', name: '地名', description: '地名', meaningful: true }, + { tag: 'nt', name: '机构名', description: '机构团体名', meaningful: true }, + { tag: 'nz', name: '其他专名', description: '其他专有名词', meaningful: true }, + { tag: 'nw', name: '作品名', description: '作品名', meaningful: true }, + { tag: 'v', name: '动词', description: '普通动词', meaningful: false }, + { tag: 'vn', name: '动名词', description: '动名词', meaningful: true }, + { tag: 'vd', name: '副动词', description: '副动词', meaningful: false }, + { tag: 'vg', name: '动语素', description: '动词性语素', meaningful: false }, + { tag: 'a', name: '形容词', description: '普通形容词', meaningful: true }, + { tag: 'an', name: '名形词', description: '名形词', meaningful: true }, + { tag: 'ad', name: '副形词', description: '副形词', meaningful: true }, + { tag: 'ag', name: '形语素', description: '形容词性语素', meaningful: true }, + { tag: 'i', name: '成语', description: '成语', meaningful: true }, + { tag: 'l', name: '习用语', description: '习用语', meaningful: true }, + { tag: 'j', name: '简称', description: '简称略语', meaningful: true }, + { tag: 'd', name: '副词', description: '副词', meaningful: false }, + { tag: 'p', name: '介词', description: '介词', meaningful: false }, + { tag: 'c', name: '连词', description: '连词', meaningful: false }, + { tag: 'u', name: '助词', description: '助词', meaningful: false }, + { tag: 'r', name: '代词', description: '代词', meaningful: false }, + { tag: 'm', name: '数词', description: '数词', meaningful: false }, + { tag: 'q', name: '量词', description: '量词', meaningful: false }, + { tag: 'f', name: '方位词', description: '方位词', meaningful: false }, + { tag: 't', name: '时间词', description: '时间词', meaningful: false }, + { tag: 'e', name: '叹词', description: '叹词', meaningful: false }, + { tag: 'y', name: '语气词', description: '语气词', meaningful: false }, + { tag: 'o', name: '拟声词', description: '拟声词', meaningful: false }, + { tag: 'x', name: '非语素字', description: '非语素字', meaningful: false }, + { tag: 'w', name: '标点符号', description: '标点符号', meaningful: false }, +] + +export const MEANINGFUL_POS_TAGS = new Set(POS_TAG_DEFINITIONS.filter((t) => t.meaningful).map((t) => t.tag)) diff --git a/packages/core/src/nlp/stopwords.ts b/packages/core/src/nlp/stopwords.ts new file mode 100644 index 000000000..f194fe087 --- /dev/null +++ b/packages/core/src/nlp/stopwords.ts @@ -0,0 +1,810 @@ +import type { SupportedLocale } from './types' + +/** + * 停用词表(平台无关的静态数据) + */ + +/** + * 停用词表 + * 用于过滤无意义的高频词 + */ + +/** 中文停用词 */ +export const CHINESE_STOPWORDS = new Set([ + // 代词 + '我', + '你', + '他', + '她', + '它', + '我们', + '你们', + '他们', + '她们', + '它们', + '自己', + '别人', + '大家', + '谁', + '什么', + '哪', + '哪里', + '哪儿', + '这', + '那', + '这个', + '那个', + '这些', + '那些', + '这里', + '那里', + '这儿', + '那儿', + '这样', + '那样', + // 助词 + '的', + '地', + '得', + '了', + '着', + '过', + '吗', + '呢', + '吧', + '啊', + '呀', + '哇', + '哦', + '嗯', + '噢', + '喔', + '呃', + '唉', + '哎', + '嘛', + // 介词 + '在', + '从', + '到', + '向', + '往', + '把', + '被', + '给', + '跟', + '和', + '与', + '对', + '比', + '为', + '因', + '由', + '以', + '按', + '用', + '让', + // 连词 + '和', + '与', + '或', + '或者', + '而', + '并', + '并且', + '但', + '但是', + '可是', + '然而', + '不过', + '只是', + '如果', + '要是', + '假如', + '虽然', + '尽管', + '即使', + '所以', + '因此', + '于是', + '那么', + '因为', + '由于', + '既然', + '为了', + '以便', + // 副词 + '不', + '没', + '没有', + '很', + '太', + '最', + '更', + '也', + '都', + '就', + '才', + '又', + '再', + '还', + '却', + '只', + '只是', + '已', + '已经', + '曾', + '曾经', + '正', + '正在', + '将', + '将要', + '会', + '能', + '可以', + '可能', + '应该', + '必须', + '一定', + '大概', + '也许', + '或许', + '其实', + '确实', + '真的', + '当然', + '一直', + '总是', + '经常', + '常常', + '往往', + '偶尔', + '几乎', + '差不多', + '简直', + '反正', + '终于', + // 量词 + '个', + '只', + '条', + '件', + '位', + '种', + '些', + '点', + '下', + '次', + // 数词 + '一', + '二', + '三', + '四', + '五', + '六', + '七', + '八', + '九', + '十', + '百', + '千', + '万', + '亿', + '两', + '几', + '多', + '少', + '第', + '每', + // 动词(常见无实意动词) + '是', + '有', + '在', + '做', + '去', + '来', + '说', + '看', + '想', + '要', + '能', + '会', + '让', + '给', + '叫', + '用', + '打', + '把', + '被', + '到', + // 其他常见词 + '上', + '下', + '前', + '后', + '里', + '外', + '中', + '内', + '左', + '右', + '东', + '南', + '西', + '北', + '时', + '时候', + '现在', + '今天', + '明天', + '昨天', + '年', + '月', + '日', + '号', + '点', + '分', + '秒', + '周', + '星期', + // 网络聊天常见无意义词 + '好', + '好的', + '行', + '可以', + '嗯嗯', + '哈', + '呵', + '额', + '恩', + '昂', + 'ok', + 'OK', + '好吧', + '知道', + '知道了', + '谢谢', + '感谢', + '抱歉', + '不好意思', + // 语气词和程度词(虽然词性是名词/动词,但在聊天中无实际意义) + '感觉', + '有点', + '可能', + '应该', + '好像', + '觉得', + '认为', + '看看', + '看到', + '说', + '问', + '找', + '弄', + '搞', + '搞定', + '整', + '干', + '做', + '来', + '去', + '有', + '没有', + '没', + '是不是', + '有没有', + '能不能', + '会不会', + '要不要', + '怎样', + '如何', + '为何', + '为什么', + '怎么', + '怎么样', + '怎么办', + '东西', + '事情', + '事', + '问题', + '时候', + '地方', + '情况', + '样子', + '意思', + '一下', + '一点', + '一些', + '一样', + '一起', + '一直', + '一般', + '一定', + '差不多', +]) + +/** 英文停用词 */ +export const ENGLISH_STOPWORDS = new Set([ + // Articles + 'a', + 'an', + 'the', + // Pronouns + 'i', + 'me', + 'my', + 'myself', + 'we', + 'our', + 'ours', + 'ourselves', + 'you', + 'your', + 'yours', + 'yourself', + 'yourselves', + 'he', + 'him', + 'his', + 'himself', + 'she', + 'her', + 'hers', + 'herself', + 'it', + 'its', + 'itself', + 'they', + 'them', + 'their', + 'theirs', + 'themselves', + 'what', + 'which', + 'who', + 'whom', + 'this', + 'that', + 'these', + 'those', + // Prepositions + 'in', + 'on', + 'at', + 'by', + 'for', + 'with', + 'about', + 'against', + 'between', + 'into', + 'through', + 'during', + 'before', + 'after', + 'above', + 'below', + 'to', + 'from', + 'up', + 'down', + 'out', + 'off', + 'over', + 'under', + 'again', + 'further', + // Conjunctions + 'and', + 'but', + 'or', + 'nor', + 'so', + 'yet', + 'both', + 'either', + 'neither', + 'not', + 'only', + 'own', + 'same', + 'than', + 'too', + 'very', + 'just', + // Be verbs + 'am', + 'is', + 'are', + 'was', + 'were', + 'be', + 'been', + 'being', + // Have verbs + 'have', + 'has', + 'had', + 'having', + // Do verbs + 'do', + 'does', + 'did', + 'doing', + // Modal verbs + 'will', + 'would', + 'shall', + 'should', + 'can', + 'could', + 'may', + 'might', + 'must', + // Other common words + 'if', + 'then', + 'else', + 'when', + 'where', + 'why', + 'how', + 'all', + 'each', + 'every', + 'both', + 'few', + 'more', + 'most', + 'other', + 'some', + 'such', + 'no', + 'any', + 'now', + 'here', + 'there', + 'of', + 'as', + // Contractions (without apostrophe) + 'dont', + 'doesnt', + 'didnt', + 'wont', + 'wouldnt', + 'cant', + 'couldnt', + 'shouldnt', + 'isnt', + 'arent', + 'wasnt', + 'werent', + 'havent', + 'hasnt', + 'hadnt', + // Chat common words + 'ok', + 'okay', + 'yes', + 'no', + 'yeah', + 'yep', + 'nope', + 'sure', + 'thanks', + 'thank', + 'please', + 'sorry', + 'hi', + 'hello', + 'hey', + 'bye', + 'goodbye', + 'well', + 'like', + 'know', + 'think', + 'want', + 'need', + 'get', + 'got', + 'go', + 'going', + 'come', + 'coming', + 'make', + 'made', + 'take', + 'took', + 'see', + 'saw', + 'look', + 'looking', + 'say', + 'said', + 'tell', + 'told', + 'ask', + 'asked', + 'let', + 'put', + 'keep', + 'give', + 'gave', + 'find', + 'found', + 'try', + 'tried', + // Time words + 'today', + 'tomorrow', + 'yesterday', + 'now', + 'then', + 'always', + 'never', + 'sometimes', + 'often', + 'usually', + 'still', + 'already', + 'soon', + 'later', +]) + +/** 日语停用词 */ +export const JAPANESE_STOPWORDS = new Set([ + // 助詞 + 'の', + 'に', + 'は', + 'を', + 'た', + 'が', + 'で', + 'て', + 'と', + 'し', + 'れ', + 'さ', + 'ある', + 'いる', + 'も', + 'する', + 'から', + 'な', + 'こと', + 'として', + 'い', + 'や', + 'れる', + 'など', + 'なっ', + 'ない', + 'この', + 'ため', + 'その', + 'あっ', + 'よう', + 'また', + 'もの', + 'という', + 'あり', + 'まで', + 'られ', + 'なる', + 'へ', + 'か', + 'だ', + 'これ', + 'によって', + 'により', + 'おり', + 'より', + 'による', + 'ず', + 'なり', + 'られる', + 'において', + 'ば', + 'なかっ', + 'なく', + 'しかし', + 'について', + 'せ', + 'だっ', + 'その後', + 'できる', + 'それ', + 'う', + 'ので', + 'なお', + 'のみ', + 'でき', + 'き', + 'つ', + 'における', + 'および', + 'いう', + 'さらに', + 'でも', + 'ら', + 'たり', + 'その他', + 'に関する', + 'たち', + 'ます', + 'ん', + 'なら', + 'に対して', + // 代名詞 + '私', + '僕', + '俺', + '自分', + 'あなた', + '彼', + '彼女', + 'それ', + 'これ', + 'あれ', + 'ここ', + 'そこ', + 'あそこ', + 'どこ', + 'みんな', + '皆', + // 接続詞 + 'そして', + 'しかし', + 'でも', + 'だから', + 'それで', + 'だけど', + 'けど', + 'ところで', + 'さて', + 'つまり', + 'すなわち', + 'ただし', + 'もし', + 'また', + // 副詞 + 'とても', + 'すごく', + 'もう', + 'まだ', + 'よく', + 'ちょっと', + 'ちょと', + 'もっと', + 'やっぱり', + 'やはり', + 'たぶん', + 'きっと', + 'ぜんぜん', + 'ほんとに', + 'ほんと', + 'かなり', + 'だいたい', + 'ほとんど', + 'まあ', + 'なんか', + 'なんとなく', + // 感動詞・フィラー + 'あ', + 'ああ', + 'えー', + 'うん', + 'えっ', + 'おお', + 'へー', + 'ふーん', + 'はい', + 'いいえ', + 'うーん', + 'まあ', + 'ねえ', + 'ほら', + 'あのね', + 'えっと', + 'その', + // 動詞(高頻度) + 'いる', + 'ある', + 'する', + 'なる', + 'できる', + 'いく', + 'くる', + 'みる', + 'おもう', + 'いう', + 'やる', + 'くれる', + 'もらう', + 'あげる', + 'しまう', + 'おく', + // 形容詞(高頻度) + 'いい', + 'ない', + 'よい', + 'すごい', + 'おおきい', + 'ちいさい', + // 助動詞 + 'です', + 'ます', + 'でした', + 'ました', + 'ません', + 'だった', + 'でしょう', + // チャットでの高頻語 + 'www', + 'ww', + 'lol', + 'ok', + 'おけ', + 'りょ', + 'おつ', + 'わら', + '笑', + 'それな', + 'たしかに', + 'マジ', + 'まじ', + 'ガチ', + 'がち', +]) + +/** + * 获取停用词集合 + * @param locale 语言 + * @returns 停用词集合 + */ +export function getStopwords(locale: string): Set { + const normalizedLocale = normalizeStopwordLocale(locale) + + if (normalizedLocale.startsWith('zh')) { + return CHINESE_STOPWORDS + } + if (normalizedLocale === 'ja-JP') { + return JAPANESE_STOPWORDS + } + return ENGLISH_STOPWORDS +} + +/** + * 判断是否为停用词 + * @param word 词语 + * @param locale 语言 + * @returns 是否为停用词 + */ +export function isStopword(word: string, locale: string): boolean { + const normalizedLocale = normalizeStopwordLocale(locale) + const stopwords = getStopwords(normalizedLocale) + const normalizedWord = normalizedLocale === 'en-US' ? word.toLowerCase() : word + return stopwords.has(normalizedWord) +} + +/** + * 规范化停用词处理使用的 locale。 + * 这里额外兜底一次,避免上游误传数字或其他异常值时直接导致运行时崩溃。 + */ +function normalizeStopwordLocale(locale: string): SupportedLocale { + if (typeof locale !== 'string') { + return 'en-US' + } + + if (locale.startsWith('zh')) { + return 'zh-CN' + } + + if (locale === 'ja-JP') { + return 'ja-JP' + } + + return 'en-US' +} diff --git a/packages/core/src/nlp/text-utils.test.ts b/packages/core/src/nlp/text-utils.test.ts new file mode 100644 index 000000000..6a59f149b --- /dev/null +++ b/packages/core/src/nlp/text-utils.test.ts @@ -0,0 +1,30 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { cleanText } from './text-utils' + +describe('cleanText', () => { + it('removes chat media placeholders before punctuation cleanup', () => { + assert.equal(cleanText('今天发了[图片]和[视频],还有[文件]'), '今天发了 和 还有') + assert.equal(cleanText('[Image] [Video] [File] useful text'), 'useful text') + }) + + it('removes bracketed chat emoji placeholders before tokenization', () => { + assert.equal(cleanText('今天[破涕为笑][微笑][呲牙]很好'), '今天 很好') + }) + + it('removes unknown short bracketed emoji placeholders', () => { + assert.equal(cleanText('收到[旺柴]马上来'), '收到 马上来') + }) + + it('removes mapped emoji placeholders with variation selectors', () => { + assert.equal(cleanText('送你[爱心][太阳]'), '送你') + }) + + it('keeps ordinary non-bracketed words', () => { + assert.equal(cleanText('破涕为笑 微笑 呲牙'), '破涕为笑 微笑 呲牙') + }) + + it('keeps non-CJK bracketed words as regular text', () => { + assert.equal(cleanText('please check [report]'), 'please check report') + }) +}) diff --git a/packages/core/src/nlp/text-utils.ts b/packages/core/src/nlp/text-utils.ts new file mode 100644 index 000000000..c7d4ae1a6 --- /dev/null +++ b/packages/core/src/nlp/text-utils.ts @@ -0,0 +1,136 @@ +/** + * 文本处理工具(纯函数,平台无关) + */ + +const EMOJI_REGEX = /[\u{1F300}-\u{1FAFF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu +const EMOJI_VARIATION_SELECTOR_REGEX = /\u{FE0F}/gu +const PUNCTUATION_REGEX = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~,。!?、;:""''()【】《》…—~·\s]/g +const URL_REGEX = /https?:\/\/[^\s]+/g +const MENTION_REGEX = /@[^\s@]+/g +const PURE_NUMBER_REGEX = /^\d+$/ +const SYSTEM_PLACEHOLDER_REGEX = + /\[(?:图片|视频|语音|文件|动画表情|表情|链接|位置|名片|红包|转账|音乐|Image|Video|Voice|File|Sticker|Link)\]/gi +const BRACKET_EMOJI_PLACEHOLDER_REGEX = + /(?:\[|【)([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Letter}\p{Number}_-]{1,16})(?:\]|】)/gu +const CJK_TEXT_REGEX = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u + +const BRACKET_EMOJI_MAP: Record = { + 破涕为笑: '😂', + 微笑: '🙂', + 呲牙: '😁', + 大笑: '😄', + 笑哭: '😂', + 流泪: '😢', + 捂脸: '🤦', + 发呆: '😳', + 害羞: '😊', + 调皮: '😜', + 色: '😍', + 惊讶: '😮', + 撇嘴: '😒', + 难过: '😞', + 酷: '😎', + 抓狂: '😫', + 吐: '🤮', + 偷笑: '🤭', + 可爱: '😊', + 白眼: '🙄', + 傲慢: '😤', + 饥饿: '😋', + 困: '😴', + 惊恐: '😱', + 流汗: '😅', + 憨笑: '😄', + 悠闲: '😌', + 奋斗: '💪', + 咒骂: '😡', + 疑问: '❓', + 嘘: '🤫', + 晕: '😵', + 衰: '😞', + 敲打: '👊', + 再见: '👋', + 擦汗: '😅', + 抠鼻: '👃', + 鼓掌: '👏', + 坏笑: '😏', + 左哼哼: '😤', + 右哼哼: '😤', + 哈欠: '🥱', + 鄙视: '😒', + 委屈: '🥺', + 快哭了: '😢', + 阴险: '😏', + 亲亲: '😘', + 吓: '😱', + 可怜: '🥺', + 菜刀: '🔪', + 西瓜: '🍉', + 啤酒: '🍺', + 篮球: '🏀', + 乒乓: '🏓', + 咖啡: '☕', + 饭: '🍚', + 猪头: '🐷', + 玫瑰: '🌹', + 凋谢: '🥀', + 爱心: '❤️', + 心碎: '💔', + 蛋糕: '🎂', + 闪电: '⚡', + 炸弹: '💣', + 刀: '🔪', + 足球: '⚽', + 便便: '💩', + 月亮: '🌙', + 太阳: '☀️', + 礼物: '🎁', + 拥抱: '🤗', + 强: '👍', + 弱: '👎', + 握手: '🤝', + 胜利: '✌️', + 抱拳: '🙏', + 勾引: '☝️', + 拳头: '✊', + 差劲: '👎', + 爱你: '🤟', + NO: '🙅', + OK: '👌', +} + +/** + * 清理文本:移除 URL、@提及、表情、标点等 + */ +export function cleanText(text: string): string { + return text + .replace(URL_REGEX, ' ') + .replace(MENTION_REGEX, ' ') + .replace(SYSTEM_PLACEHOLDER_REGEX, ' ') + .replace(BRACKET_EMOJI_PLACEHOLDER_REGEX, (match, name: string) => { + if (BRACKET_EMOJI_MAP[name]) return BRACKET_EMOJI_MAP[name] + return CJK_TEXT_REGEX.test(name) ? ' ' : match + }) + .replace(EMOJI_REGEX, ' ') + .replace(EMOJI_VARIATION_SELECTOR_REGEX, ' ') + .replace(PUNCTUATION_REGEX, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +/** + * 判断是否为有效词语 + */ +export function isValidWord( + word: string, + locale: string, + minLength: number, + enableStopwords: boolean, + isStopwordFn: (word: string, locale: string) => boolean +): boolean { + if (!word || word.trim().length === 0) return false + if (PURE_NUMBER_REGEX.test(word)) return false + if (word.length < minLength) return false + if (enableStopwords && isStopwordFn(word, locale)) return false + return true +} diff --git a/packages/core/src/nlp/types.ts b/packages/core/src/nlp/types.ts new file mode 100644 index 000000000..15d4956da --- /dev/null +++ b/packages/core/src/nlp/types.ts @@ -0,0 +1,90 @@ +/** + * NLP 模块类型定义(平台无关) + */ + +/** 支持的语言 */ +export type SupportedLocale = 'zh-CN' | 'en-US' | 'zh-TW' | 'ja-JP' + +/** 词性过滤模式 */ +export type PosFilterMode = 'all' | 'meaningful' | 'custom' + +/** 词库类型 */ +export type DictType = 'default' | 'zh-CN' | 'zh-TW' + +/** 词性标签信息 */ +export interface PosTagInfo { + tag: string + name: string + description: string + meaningful: boolean +} + +/** 词频项 */ +export interface WordFrequencyItem { + word: string + count: number + percentage: number +} + +/** 词性统计项 */ +export interface PosTagStat { + tag: string + count: number +} + +/** 词频统计结果 */ +export interface WordFrequencyResult { + words: WordFrequencyItem[] + totalWords: number + totalMessages: number + uniqueWords: number + posTagStats?: PosTagStat[] +} + +/** 词频统计参数 */ +export interface WordFrequencyParams { + sessionId: string + locale: SupportedLocale + timeFilter?: { startTs?: number; endTs?: number } + memberId?: number + topN?: number + minWordLength?: number + minCount?: number + posFilterMode?: PosFilterMode + customPosTags?: string[] + enableStopwords?: boolean + dictType?: DictType + excludeWords?: string[] +} + +/** 分词选项 */ +export interface SegmentOptions { + minLength?: number + posFilterMode?: PosFilterMode + customPosTags?: string[] + enableStopwords?: boolean + dictType?: DictType +} + +/** 批量分词选项 */ +export interface BatchSegmentOptions extends SegmentOptions { + minCount?: number + topN?: number + excludeWords?: string[] +} + +/** 批量分词结果 */ +export interface BatchSegmentResult { + words: Map + uniqueWords: number + totalWords: number +} + +/** 词库信息 */ +export interface DictInfo { + id: string + label: string + locale: string + downloaded: boolean + fileSize?: number +} diff --git a/packages/core/src/owner/index.ts b/packages/core/src/owner/index.ts new file mode 100644 index 000000000..4138f1d87 --- /dev/null +++ b/packages/core/src/owner/index.ts @@ -0,0 +1,9 @@ +export { + NAME_MATCH_PLATFORMS, + isNameMatchPlatform, + normalizeOwnerName, + collectCandidateNames, + mergeConfirmedNames, + matchOwnerProfile, +} from './owner-matching' +export type { OwnerMatchCandidate, OwnerMatchResult } from './owner-matching' diff --git a/packages/core/src/owner/owner-matching.test.ts b/packages/core/src/owner/owner-matching.test.ts new file mode 100644 index 000000000..12be67034 --- /dev/null +++ b/packages/core/src/owner/owner-matching.test.ts @@ -0,0 +1,174 @@ +/** + * Unit tests for owner profile matching (deterministic "who am I" resolution). + * + * Run: npx tsx --test packages/core/src/owner/owner-matching.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { OwnerProfile } from '@openchatlab/shared-types' +import { + normalizeOwnerName, + collectCandidateNames, + mergeConfirmedNames, + matchOwnerProfile, + isNameMatchPlatform, +} from './owner-matching' + +function makeProfile(overrides?: Partial): OwnerProfile { + return { + platformId: 'Alice', + displayName: 'Alice', + confirmedNames: ['Alice'], + matchMode: 'name', + updatedAt: 1700000000, + ...overrides, + } +} + +describe('normalizeOwnerName', () => { + it('trims and collapses whitespace', () => { + assert.equal(normalizeOwnerName(' Alice Smith '), 'alice smith') + }) + + it('removes invisible direction/control characters (LRM/RLM/zero-width/BOM)', () => { + assert.equal(normalizeOwnerName('\u200EAlice\u200F'), 'alice') + assert.equal(normalizeOwnerName('\u202AAlice\u202C'), 'alice') + assert.equal(normalizeOwnerName('A\u200Blice'), 'alice') + assert.equal(normalizeOwnerName('\uFEFFAlice'), 'alice') + }) + + it('applies NFKC normalization (full-width to half-width)', () => { + assert.equal(normalizeOwnerName('Alice 123'), 'alice 123') + }) + + it('lowercases for case-insensitive comparison', () => { + assert.equal(normalizeOwnerName('ALICE'), 'alice') + }) + + it('keeps CJK names intact', () => { + assert.equal(normalizeOwnerName(' 王小明 '), '王小明') + }) +}) + +describe('collectCandidateNames', () => { + it('collects platformId, accountName, groupNickname, aliases and displayName', () => { + const names = collectCandidateNames({ + platformId: 'u001', + accountName: 'Alice', + groupNickname: 'Ali', + aliases: ['A.', 'Allie'], + displayName: 'Ali', + }) + assert.deepEqual(names, ['u001', 'Alice', 'Ali', 'A.', 'Allie']) + }) + + it('skips empty and whitespace-only values', () => { + const names = collectCandidateNames({ + platformId: 'u001', + accountName: '', + groupNickname: ' ', + aliases: [''], + displayName: null, + }) + assert.deepEqual(names, ['u001']) + }) +}) + +describe('mergeConfirmedNames', () => { + it('merges member names into existing list without duplicates, preserving order', () => { + const merged = mergeConfirmedNames(['Alice', 'Ali'], { + platformId: 'Alice', + accountName: 'Alice Smith', + groupNickname: 'Ali', + aliases: ['Allie'], + }) + assert.deepEqual(merged, ['Alice', 'Ali', 'Alice Smith', 'Allie']) + }) + + it('keeps original strings without normalization', () => { + const merged = mergeConfirmedNames([], { platformId: ' Alice ' }) + assert.deepEqual(merged, [' Alice ']) + }) +}) + +describe('matchOwnerProfile', () => { + it('matches exact platformId on any platform', () => { + const result = matchOwnerProfile('weixin', makeProfile({ platformId: 'wx_123', confirmedNames: [] }), [ + { platformId: 'wx_123', accountName: 'Me' }, + { platformId: 'wx_456', accountName: 'Other' }, + ]) + assert.deepEqual(result, { type: 'exact', platformId: 'wx_123' }) + }) + + it('falls back to name matching only on allowlisted platforms', () => { + const members = [ + { platformId: 'Alice Smith', accountName: 'Alice Smith' }, + { platformId: 'Bob', accountName: 'Bob' }, + ] + const profile = makeProfile({ platformId: 'Alice', confirmedNames: ['alice smith'] }) + + assert.deepEqual(matchOwnerProfile('whatsapp', profile, members), { + type: 'name', + platformId: 'Alice Smith', + }) + // weixin has stable IDs; no name fallback + assert.deepEqual(matchOwnerProfile('weixin', profile, members), { type: 'none' }) + // unknown platform never uses name fallback + assert.deepEqual(matchOwnerProfile('unknown', profile, members), { type: 'none' }) + }) + + it('matches names with invisible characters and width differences', () => { + const result = matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), [ + { platformId: '\u200EAlice\u200F' }, + { platformId: 'Bob' }, + ]) + assert.deepEqual(result, { type: 'name', platformId: '\u200EAlice\u200F' }) + }) + + it('matches via aliases and groupNickname', () => { + const result = matchOwnerProfile('line', makeProfile({ platformId: 'Allie W', confirmedNames: ['Allie'] }), [ + { platformId: 'Alice', accountName: 'Alice', aliases: ['Allie'] }, + { platformId: 'Bob' }, + ]) + assert.deepEqual(result, { type: 'name', platformId: 'Alice' }) + }) + + it('returns ambiguous when multiple members match', () => { + const result = matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), [ + { platformId: 'u1', accountName: 'Alice' }, + { platformId: 'u2', groupNickname: 'Alice' }, + ]) + assert.equal(result.type, 'ambiguous') + assert.deepEqual(result.type === 'ambiguous' ? result.platformIds : [], ['u1', 'u2']) + }) + + it('returns none when nothing matches or confirmed names are empty', () => { + const members = [{ platformId: 'Bob' }] + assert.deepEqual(matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), members), { + type: 'none', + }) + assert.deepEqual(matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: [' '] }), members), { + type: 'none', + }) + }) + + it('does not use substring containment', () => { + const result = matchOwnerProfile('whatsapp', makeProfile({ confirmedNames: ['Alice'] }), [ + { platformId: 'Alice Smith' }, + ]) + assert.deepEqual(result, { type: 'none' }) + }) +}) + +describe('isNameMatchPlatform', () => { + it('only allows whatsapp, line and instagram', () => { + assert.equal(isNameMatchPlatform('whatsapp'), true) + assert.equal(isNameMatchPlatform('line'), true) + assert.equal(isNameMatchPlatform('instagram'), true) + assert.equal(isNameMatchPlatform('weixin'), false) + assert.equal(isNameMatchPlatform('qq'), false) + assert.equal(isNameMatchPlatform('telegram'), false) + assert.equal(isNameMatchPlatform('unknown'), false) + }) +}) diff --git a/packages/core/src/owner/owner-matching.ts b/packages/core/src/owner/owner-matching.ts new file mode 100644 index 000000000..964b57ee1 --- /dev/null +++ b/packages/core/src/owner/owner-matching.ts @@ -0,0 +1,127 @@ +/** + * Owner profile matching — platform-independent "who am I" resolution. + * + * Given a platform-level owner profile (stored in preferences) and the member + * list of a session, decide which member is the owner. Matching is strictly + * deterministic: exact platformId first, then normalized-name equality for an + * allowlisted set of platforms whose exports lack stable native IDs. + * No fuzzy matching, no heuristics, no LLM inference. + */ + +import type { OwnerProfile } from '@openchatlab/shared-types' + +/** + * Platforms whose text exports use display names as platformId, so a + * normalized-name fallback is safe and necessary. All other platforms + * (including 'unknown') match by exact platformId only. + */ +export const NAME_MATCH_PLATFORMS: ReadonlySet = new Set(['whatsapp', 'line', 'instagram']) + +export function isNameMatchPlatform(platform: string): boolean { + return NAME_MATCH_PLATFORMS.has(platform) +} + +/** Minimal member shape needed for owner matching. */ +export interface OwnerMatchCandidate { + platformId: string + accountName?: string | null + groupNickname?: string | null + aliases?: string[] | null + /** Computed display name, if the caller already derived one. */ + displayName?: string | null +} + +export type OwnerMatchResult = + | { type: 'exact'; platformId: string } + | { type: 'name'; platformId: string } + | { type: 'none' } + | { type: 'ambiguous'; platformIds: string[] } + +// Invisible direction/control characters that chat exports commonly embed +// around names (LRM/RLM, directional embeddings/isolates, zero-width chars, BOM). +const INVISIBLE_CHARS_RE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g + +/** + * Deterministic name normalization for matching: + * strip invisible chars → NFKC → collapse whitespace → trim → lowercase. + * Stored names keep their original form; normalize only at compare time. + */ +export function normalizeOwnerName(name: string): string { + return name.replace(INVISIBLE_CHARS_RE, '').normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase() +} + +/** Collect all candidate name strings of a member (original, non-normalized, deduped, non-empty). */ +export function collectCandidateNames(member: OwnerMatchCandidate): string[] { + const names = [ + member.platformId, + member.accountName ?? '', + member.groupNickname ?? '', + ...(member.aliases ?? []), + member.displayName ?? '', + ] + const result: string[] = [] + const seen = new Set() + for (const name of names) { + if (!name || !name.trim()) continue + if (seen.has(name)) continue + seen.add(name) + result.push(name) + } + return result +} + +/** + * Merge a member's candidate names into an existing confirmedNames list. + * Keeps original strings; dedupes by exact string equality, preserving order. + */ +export function mergeConfirmedNames(existing: string[], member: OwnerMatchCandidate): string[] { + const result: string[] = [] + const seen = new Set() + for (const name of [...existing, ...collectCandidateNames(member)]) { + if (!name || !name.trim()) continue + if (seen.has(name)) continue + seen.add(name) + result.push(name) + } + return result +} + +/** + * Match a platform owner profile against a session's members. + * + * 1. Exact platformId match wins immediately. + * 2. Name fallback only for NAME_MATCH_PLATFORMS: a member matches when any + * of its normalized candidate names equals any normalized confirmed name. + * 3. Exactly one member must match; zero → none, multiple → ambiguous. + */ +export function matchOwnerProfile( + platform: string, + profile: OwnerProfile, + members: OwnerMatchCandidate[] +): OwnerMatchResult { + const exact = members.find((m) => m.platformId === profile.platformId) + if (exact) { + return { type: 'exact', platformId: exact.platformId } + } + + if (!isNameMatchPlatform(platform)) { + return { type: 'none' } + } + + const confirmed = new Set(profile.confirmedNames.map(normalizeOwnerName).filter((n) => n.length > 0)) + if (confirmed.size === 0) { + return { type: 'none' } + } + + const matched = members.filter((m) => + collectCandidateNames(m).some((name) => confirmed.has(normalizeOwnerName(name))) + ) + + if (matched.length === 1) { + return { type: 'name', platformId: matched[0].platformId } + } + if (matched.length > 1) { + return { type: 'ambiguous', platformIds: matched.map((m) => m.platformId) } + } + return { type: 'none' } +} diff --git a/packages/core/src/query/__tests__/basic-queries.test.ts b/packages/core/src/query/__tests__/basic-queries.test.ts new file mode 100644 index 000000000..6ef08ce32 --- /dev/null +++ b/packages/core/src/query/__tests__/basic-queries.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for the text/length statistics queries migrated from the plugin charts: + * getTextStats, getLongMessageCount, getMemberMonthlyTrend, getTextLengthPercentiles. + * + * Uses a real in-memory SQLite DB to lock the SQL + post-processing behavior, + * including system-message exclusion and member/time filtering. + * + * Run: npx tsx --test packages/core/src/query/__tests__/basic-queries.test.ts + */ + +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import Database from 'better-sqlite3' +import { getTextStats, getLongMessageCount, getMemberMonthlyTrend, getTextLengthPercentiles } from '../basic-queries' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces' + +class Stmt implements PreparedStatement { + readonly?: boolean + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } + get(...p: unknown[]) { + return this.stmt.get(...p) as Record | undefined + } + all(...p: unknown[]) { + return this.stmt.all(...p) as Record[] + } + run(...p: unknown[]): RunResult { + const r = this.stmt.run(...p) + return { changes: r.changes, lastInsertRowid: r.lastInsertRowid } + } +} + +class Adapter implements DatabaseAdapter { + constructor(private db: Database.Database) {} + exec(sql: string) { + this.db.exec(sql) + } + prepare(sql: string) { + return new Stmt(this.db.prepare(sql)) + } + transaction(fn: () => T): T { + return this.db.transaction(fn)() + } + pragma(p: string) { + return this.db.pragma(p) + } + close() { + this.db.close() + } +} + +// 2024-01-15 12:00:00 UTC, 时区偏移不会跨出 2024-01 +const TS = 1705320000 + +describe('basic-queries text/length stats', () => { + let raw: Database.Database + let db: Adapter + + beforeEach(() => { + raw = new Database(':memory:') + raw.exec(` + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT, account_name TEXT, group_nickname TEXT, avatar TEXT); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + INSERT INTO member (id, platform_id, account_name) VALUES (1, 'u1', 'Alice'), (2, 'u2', 'Bob'), (99, 'sys', '系统消息'); + `) + const insert = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)') + // Alice 文字消息 长度 3 / 6 / 40 + insert.run(1, 1, TS, 0, 'a'.repeat(3)) + insert.run(2, 1, TS, 0, 'a'.repeat(6)) + insert.run(3, 1, TS, 0, 'a'.repeat(40)) + // Bob 文字消息 长度 2 / 10 + insert.run(4, 2, TS, 0, 'b'.repeat(2)) + insert.run(5, 2, TS, 0, 'b'.repeat(10)) + // Alice 非文字消息(type!=0)应忽略 + insert.run(6, 1, TS, 1, 'image-url') + // 系统消息成员的长文字消息应被系统过滤排除 + insert.run(7, 99, TS, 0, 's'.repeat(50)) + db = new Adapter(raw) + }) + + afterEach(() => { + try { + raw.close() + } catch { + /* already closed */ + } + }) + + it('getTextStats aggregates text-only, excludes system messages', () => { + // 长度 [3,6,40,2,10] -> count5, avg=12.2, max40, short(<=5)=2 + assert.deepEqual(getTextStats(db), { textCount: 5, avgLength: 12.2, maxLength: 40, shortCount: 2 }) + }) + + it('getTextStats honors memberId filter', () => { + // Alice 长度 [3,6,40] -> count3, avg=16.3, max40, short=1 + assert.deepEqual(getTextStats(db, { memberId: 1 }), { + textCount: 3, + avgLength: 16.3, + maxLength: 40, + shortCount: 1, + }) + }) + + it('getTextStats returns zeros when no text messages match', () => { + // 系统成员被过滤后无文字消息 + assert.deepEqual(getTextStats(db, { memberId: 99 }), { + textCount: 0, + avgLength: 0, + maxLength: 0, + shortCount: 0, + }) + }) + + it('getTextLengthPercentiles computes percentiles over sorted lengths', () => { + // sorted [2,3,6,10,40] -> p25=3, p50=6, p75=10, p90=40 + assert.deepEqual(getTextLengthPercentiles(db), { p25: 3, p50: 6, p75: 10, p90: 40 }) + }) + + it('getTextLengthPercentiles returns zeros on empty set', () => { + assert.deepEqual(getTextLengthPercentiles(db, { memberId: 99 }), { p25: 0, p50: 0, p75: 0, p90: 0 }) + }) + + it('getLongMessageCount counts text >= minLength (default 30)', () => { + assert.equal(getLongMessageCount(db), 1) // 仅长度 40 + assert.equal(getLongMessageCount(db, undefined, 10), 2) // 长度 40 与 10 + assert.equal(getLongMessageCount(db, { memberId: 2 }, 10), 1) // Bob 仅长度 10 + }) + + it('getMemberMonthlyTrend groups by month and sender, excludes system', () => { + const rows = getMemberMonthlyTrend(db) + // Alice 3 文字+1 非文字=4 条;Bob 2 条;系统成员排除 + assert.equal(rows.length, 2) + const alice = rows.find((r) => r.memberId === 1) + const bob = rows.find((r) => r.memberId === 2) + assert.deepEqual(alice, { month: '2024-01', memberId: 1, memberName: 'Alice', count: 4 }) + assert.deepEqual(bob, { month: '2024-01', memberId: 2, memberName: 'Bob', count: 2 }) + assert.equal( + rows.find((r) => r.memberId === 99), + undefined + ) + }) +}) diff --git a/packages/core/src/query/__tests__/contact-queries-aliases.test.ts b/packages/core/src/query/__tests__/contact-queries-aliases.test.ts new file mode 100644 index 000000000..97f52d4de --- /dev/null +++ b/packages/core/src/query/__tests__/contact-queries-aliases.test.ts @@ -0,0 +1,72 @@ +/** + * Run: pnpm test -- packages/core/src/query/__tests__/contact-queries-aliases.test.ts + */ + +import assert from 'node:assert/strict' +import test from 'node:test' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces' +import { getNonSystemMembersForContacts } from '../contact-queries' + +class StaticStatement implements PreparedStatement { + readonly = true + + constructor(private readonly rows: Array>) {} + + get(): Record | undefined { + return this.rows[0] + } + + all(): Array> { + return this.rows + } + + run(): RunResult { + return { changes: 0, lastInsertRowid: 0 } + } +} + +class StaticDb implements DatabaseAdapter { + closed = false + + prepare(sql: string): PreparedStatement { + assert.match(sql, /SELECT/) + return new StaticStatement([ + { + id: 1, + platformId: 'alice-pid', + name: 'Alice', + aliases: '["Ally","小爱"]', + avatar: null, + }, + ]) + } + + exec(): void { + throw new Error('exec is not used in this test') + } + + transaction(fn: () => T): T { + return fn() + } + + pragma(): unknown { + return [ + { name: 'id' }, + { name: 'platform_id' }, + { name: 'account_name' }, + { name: 'group_nickname' }, + { name: 'aliases' }, + { name: 'avatar' }, + ] + } + + close(): void { + this.closed = true + } +} + +test('contact member refs include parsed saved aliases', () => { + const members = getNonSystemMembersForContacts(new StaticDb()) + + assert.deepEqual(members[0]?.aliases, ['Ally', '小爱']) +}) diff --git a/packages/core/src/query/__tests__/contact-queries.test.ts b/packages/core/src/query/__tests__/contact-queries.test.ts new file mode 100644 index 000000000..634b4eeea --- /dev/null +++ b/packages/core/src/query/__tests__/contact-queries.test.ts @@ -0,0 +1,444 @@ +/** + * Tests for single-session contact query helpers. + * + * Run: pnpm test -- packages/core/src/query/__tests__/contact-queries.test.ts + */ + +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import Database from 'better-sqlite3' +import { + getGroupContactFacts, + getGroupRelationshipGraphFacts, + getNonSystemMembersForContacts, + getPrivateContactFacts, + resolveOwnerMember, +} from '../contact-queries' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') +const SYSTEM_MESSAGE_TYPE = 80 + +class Stmt implements PreparedStatement { + readonly?: boolean + + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } + + get(...p: unknown[]) { + return this.stmt.get(...p) as Record | undefined + } + + all(...p: unknown[]) { + return this.stmt.all(...p) as Record[] + } + + run(...p: unknown[]): RunResult { + const r = this.stmt.run(...p) + return { changes: r.changes, lastInsertRowid: r.lastInsertRowid } + } +} + +class Adapter implements DatabaseAdapter { + constructor(private db: Database.Database) {} + + exec(sql: string) { + this.db.exec(sql) + } + + prepare(sql: string) { + return new Stmt(this.db.prepare(sql)) + } + + transaction(fn: () => T): T { + return this.db.transaction(fn)() + } + + pragma(p: string) { + return this.db.pragma(p) + } + + close() { + this.db.close() + } +} + +describe('contact query helpers', () => { + let raw: Database.Database + let db: Adapter + + beforeEach(() => { + raw = new Database(':memory:', { nativeBinding }) + raw.exec(` + CREATE TABLE meta ( + name TEXT, + platform TEXT, + type TEXT, + imported_at INTEGER, + owner_id TEXT + ); + CREATE TABLE member ( + id INTEGER PRIMARY KEY, + platform_id TEXT, + account_name TEXT, + group_nickname TEXT, + aliases TEXT DEFAULT '[]', + avatar TEXT + ); + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + sender_id INTEGER, + ts INTEGER, + type INTEGER, + content TEXT, + platform_message_id TEXT, + reply_to_message_id TEXT + ); + INSERT INTO meta (name, platform, type, imported_at, owner_id) + VALUES ('Group', 'wechat', 'group', 1700000000, 'owner-pid'); + INSERT INTO member (id, platform_id, account_name, group_nickname, aliases, avatar) VALUES + (1, 'owner-pid', 'Owner', NULL, '[]', NULL), + (2, 'alice-pid', 'Alice', 'Alice G', '["Ally","小爱"]', 'alice.png'), + (3, 'bob-pid', 'Bob', NULL, '[]', NULL), + (99, 'sys-pid', '系统消息', NULL, '[]', NULL); + `) + db = new Adapter(raw) + }) + + afterEach(() => { + raw.close() + }) + + it('resolves meta.owner_id to the matching member id', () => { + assert.deepEqual(resolveOwnerMember(db), { + id: 1, + platformId: 'owner-pid', + name: 'Owner', + aliases: [], + avatar: null, + }) + }) + + it('returns saved member aliases for contact candidates', () => { + const alice = getNonSystemMembersForContacts(db).find((member) => member.platformId === 'alice-pid') + + assert.ok(alice) + assert.deepEqual(alice.aliases, ['Ally', '小爱']) + }) + + it('returns null when owner_id is missing or cannot be matched', () => { + raw.exec('UPDATE meta SET owner_id = NULL') + assert.equal(resolveOwnerMember(db), null) + + raw.exec("UPDATE meta SET owner_id = 'missing-pid'") + assert.equal(resolveOwnerMember(db), null) + }) + + it('filters out system-message members from contact candidates', () => { + const members = getNonSystemMembersForContacts(db) + + assert.deepEqual( + members.map((m) => m.platformId), + ['owner-pid', 'alice-pid', 'bob-pid'] + ) + assert.equal( + members.find((m) => m.platformId === 'sys-pid'), + undefined + ) + }) + + it('filters localized parser system members by stable sender identity', () => { + raw.exec(` + INSERT INTO member (id, platform_id, account_name, group_nickname, aliases, avatar) + VALUES (100, 'system', '系統', NULL, '[]', NULL); + `) + + const members = getNonSystemMembersForContacts(db) + + assert.equal( + members.find((m) => m.platformId === 'system'), + undefined + ) + }) + + it('filters the group conversation itself from contact candidates and relationship facts', () => { + raw.exec(` + ALTER TABLE meta ADD COLUMN group_id TEXT; + UPDATE meta SET name = 'Project Group', group_id = 'group-pid'; + INSERT INTO member (id, platform_id, account_name, group_nickname, aliases, avatar) + VALUES + (100, 'group-pid', 'Project Group', NULL, '[]', NULL), + (101, 'Project Group', 'Project Group', NULL, '[]', NULL); + `) + const insert = raw.prepare( + `INSERT INTO message + (id, sender_id, ts, type, content, platform_message_id, reply_to_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + insert.run(1, 1, 1704103200, 0, 'owner', 'owner-1', null) + insert.run(2, 2, 1704103260, 0, 'alice', 'alice-1', null) + insert.run(3, 100, 1704103320, 0, 'group pseudo sender', 'group-1', null) + insert.run(4, 101, 1704103380, 0, 'legacy group pseudo sender', 'group-legacy-1', null) + + const owner = resolveOwnerMember(db) + assert.ok(owner) + + assert.equal( + getNonSystemMembersForContacts(db).some( + (member) => member.platformId === 'group-pid' || member.platformId === 'Project Group' + ), + false + ) + assert.equal( + getGroupContactFacts(db, owner.id).some( + (fact) => fact.contact.platformId === 'group-pid' || fact.contact.platformId === 'Project Group' + ), + false + ) + assert.equal( + getGroupRelationshipGraphFacts(db, owner.id).members.some( + (member) => member.contact.platformId === 'group-pid' || member.contact.platformId === 'Project Group' + ), + false + ) + }) + + it('returns private contact facts when the counterpart is unique', () => { + raw.exec("UPDATE meta SET type = 'private'") + raw.exec('DELETE FROM member WHERE id = 3') + const insert = raw.prepare( + 'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)' + ) + insert.run(1, 1, 1704103200, 0, 'from owner', 'm1') + insert.run(2, 2, 1704103260, 0, 'from alice', 'm2') + insert.run(3, 99, 1704103320, 0, 'system', 'm3') + insert.run(4, 2, 1706781600, 0, 'next month', 'm4') + + const owner = resolveOwnerMember(db) + assert.ok(owner) + + assert.deepEqual(getPrivateContactFacts(db, owner.id), { + type: 'ok', + contact: { + id: 2, + platformId: 'alice-pid', + name: 'Alice G', + aliases: ['Ally', '小爱'], + avatar: 'alice.png', + }, + privateMessageCount: 3, + activeMonths: ['2024-01', '2024-02'], + lastMessageTs: 1706781600, + }) + }) + + it('does not mark private LINE sessions ambiguous when they include localized system events', () => { + raw.exec("UPDATE meta SET type = 'private'") + raw.exec(` + DELETE FROM member WHERE id = 3; + INSERT INTO member (id, platform_id, account_name, group_nickname, aliases, avatar) + VALUES (100, 'system', '系統', NULL, '[]', NULL); + `) + const insert = raw.prepare( + 'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)' + ) + insert.run(1, 1, 1704103200, 0, 'from owner', 'm1') + insert.run(2, 2, 1704103260, 0, 'from alice', 'm2') + insert.run(3, 100, 1704103320, SYSTEM_MESSAGE_TYPE, 'localized system event', 'system-1') + + const owner = resolveOwnerMember(db) + assert.ok(owner) + + const facts = getPrivateContactFacts(db, owner.id) + + assert.equal(facts.type, 'ok') + assert.equal(facts.type === 'ok' ? facts.contact.platformId : null, 'alice-pid') + assert.equal(facts.type === 'ok' ? facts.privateMessageCount : null, 2) + }) + + it('filters private contact facts by message start timestamp', () => { + raw.exec("UPDATE meta SET type = 'private'") + raw.exec('DELETE FROM member WHERE id = 3') + const insert = raw.prepare( + 'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)' + ) + insert.run(1, 1, 1600000000, 0, 'old owner', 'old-1') + insert.run(2, 2, 1600000100, 0, 'old alice', 'old-2') + insert.run(3, 2, 1704103200, 0, 'new alice', 'new-1') + + const owner = resolveOwnerMember(db) + assert.ok(owner) + + const result = getPrivateContactFacts(db, owner.id, { startTs: 1700000000 }) + + assert.equal(result.type, 'ok') + assert.equal(result.type === 'ok' ? result.privateMessageCount : 0, 1) + assert.deepEqual(result.type === 'ok' ? result.activeMonths : [], ['2024-01']) + assert.equal(result.type === 'ok' ? result.lastMessageTs : null, 1704103200) + }) + + it('does not mark private sessions ambiguous when extra members are inactive in the selected range', () => { + raw.exec("UPDATE meta SET type = 'private'") + const insert = raw.prepare( + 'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)' + ) + insert.run(1, 3, 1600000000, 0, 'old bob residue', 'old-bob') + insert.run(2, 1, 1704103200, 0, 'from owner', 'owner-1') + insert.run(3, 2, 1704103260, 0, 'from alice', 'alice-1') + + const owner = resolveOwnerMember(db) + assert.ok(owner) + + const result = getPrivateContactFacts(db, owner.id, { startTs: 1700000000 }) + + assert.equal(result.type, 'ok') + assert.equal(result.type === 'ok' ? result.contact.platformId : null, 'alice-pid') + assert.equal(result.type === 'ok' ? result.privateMessageCount : null, 2) + }) + + it('marks private sessions with multiple non-owner members as ambiguous', () => { + raw.exec("UPDATE meta SET type = 'private'") + const owner = resolveOwnerMember(db) + assert.ok(owner) + + const result = getPrivateContactFacts(db, owner.id) + + assert.equal(result.type, 'ambiguous') + assert.deepEqual(result.type === 'ambiguous' ? result.candidates.map((m) => m.platformId) : [], [ + 'alice-pid', + 'bob-pid', + ]) + }) + + it('marks private sessions with no valid counterpart as missing', () => { + raw.exec("UPDATE meta SET type = 'private'; DELETE FROM member WHERE id IN (2, 3)") + const owner = resolveOwnerMember(db) + assert.ok(owner) + + assert.deepEqual(getPrivateContactFacts(db, owner.id), { type: 'missing' }) + }) + + it('returns one group contact fact per non-owner non-system member', () => { + const owner = resolveOwnerMember(db) + assert.ok(owner) + const insert = raw.prepare( + 'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)' + ) + insert.run(1, 1, 1704103200, 0, 'owner', 'owner-1') + insert.run(2, 2, 1704103260, 0, 'alice', 'alice-1') + insert.run(3, 2, 1704103320, 0, 'alice again', 'alice-2') + insert.run(4, 99, 1704103380, 0, 'system', 'sys-1') + + const facts = getGroupContactFacts(db, owner.id) + + assert.deepEqual( + facts.map((fact) => [fact.contact.platformId, fact.messageCount]), + [ + ['alice-pid', 2], + ['bob-pid', 0], + ] + ) + }) + + it('filters members that only emit system message types even when their localized name is unknown', () => { + raw.exec(` + INSERT INTO member (id, platform_id, account_name, group_nickname, aliases, avatar) + VALUES (100, 'line-event', 'LINE event', NULL, '[]', NULL); + `) + const owner = resolveOwnerMember(db) + assert.ok(owner) + const insert = raw.prepare( + 'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)' + ) + insert.run(1, 1, 1704103200, 0, 'owner', 'owner-1') + insert.run(2, 2, 1704103260, 0, 'alice', 'alice-1') + insert.run(3, 100, 1704103320, SYSTEM_MESSAGE_TYPE, 'unknown localized system event', 'sys-1') + + const facts = getGroupContactFacts(db, owner.id) + + assert.equal( + facts.find((fact) => fact.contact.platformId === 'line-event'), + undefined + ) + }) + + it('counts structured reply interactions in both directions', () => { + const owner = resolveOwnerMember(db) + assert.ok(owner) + const insert = raw.prepare( + `INSERT INTO message + (id, sender_id, ts, type, content, platform_message_id, reply_to_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + insert.run(1, 1, 1704103200, 0, 'owner to group', 'owner-1', null) + insert.run(2, 2, 1704103260, 0, 'alice replies owner', 'alice-1', 'owner-1') + insert.run(3, 3, 1704103320, 0, 'bob to group', 'bob-1', null) + insert.run(4, 1, 1704103380, 0, 'owner replies bob', 'owner-2', 'bob-1') + + const facts = getGroupContactFacts(db, owner.id) + const alice = facts.find((fact) => fact.contact.platformId === 'alice-pid') + const bob = facts.find((fact) => fact.contact.platformId === 'bob-pid') + + assert.ok(alice) + assert.equal(alice.repliesFromContactToOwner, 1) + assert.equal(alice.repliesFromOwnerToContact, 0) + assert.equal(alice.replyInteractionCount, 1) + assert.equal(alice.lastInteractionTs, 1704103260) + + assert.ok(bob) + assert.equal(bob.repliesFromContactToOwner, 0) + assert.equal(bob.repliesFromOwnerToContact, 1) + assert.equal(bob.replyInteractionCount, 1) + assert.equal(bob.lastInteractionTs, 1704103380) + }) + + it('filters group contact facts and reply edges by message start timestamp', () => { + const owner = resolveOwnerMember(db) + assert.ok(owner) + const insert = raw.prepare( + `INSERT INTO message + (id, sender_id, ts, type, content, platform_message_id, reply_to_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + insert.run(1, 1, 1600000000, 0, 'old owner', 'old-owner', null) + insert.run(2, 2, 1600000001, 0, 'old alice', 'old-alice', 'old-owner') + insert.run(3, 1, 1704103200, 0, 'new owner', 'new-owner', null) + insert.run(4, 2, 1704103201, 0, 'new alice', 'new-alice', 'new-owner') + insert.run(5, 1, 1704103202, 0, 'owner replies old alice', 'new-owner-reply-old', 'old-alice') + + const facts = getGroupContactFacts(db, owner.id, { startTs: 1700000000 }) + const alice = facts.find((fact) => fact.contact.platformId === 'alice-pid') + + assert.ok(alice) + assert.equal(alice.messageCount, 1) + assert.equal(alice.replyInteractionCount, 1) + assert.equal(alice.repliesFromContactToOwner, 1) + assert.equal(alice.repliesFromOwnerToContact, 0) + assert.equal(alice.lastInteractionTs, 1704103201) + }) + + it('computes owner-contact co-occurrence from nearby group messages', () => { + const owner = resolveOwnerMember(db) + assert.ok(owner) + const insert = raw.prepare( + 'INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES (?, ?, ?, ?, ?, ?)' + ) + insert.run(1, 1, 1704103200, 0, 'owner starts', 'owner-1') + insert.run(2, 2, 1704103201, 0, 'alice follows', 'alice-1') + insert.run(3, 1, 1704103202, 0, 'owner again', 'owner-2') + insert.run(4, 2, 1704103203, 0, 'alice follows again', 'alice-2') + insert.run(5, 3, 1704103800, 0, 'bob much later', 'bob-1') + + const facts = getGroupContactFacts(db, owner.id) + const alice = facts.find((fact) => fact.contact.platformId === 'alice-pid') + const bob = facts.find((fact) => fact.contact.platformId === 'bob-pid') + + assert.ok(alice) + assert.ok(bob) + assert.ok(alice.coOccurrenceCount > bob.coOccurrenceCount) + assert.ok(alice.coOccurrenceRawScore > bob.coOccurrenceRawScore) + assert.equal(alice.lastInteractionTs, 1704103203) + }) +}) diff --git a/packages/core/src/query/__tests__/contact-scoring.test.ts b/packages/core/src/query/__tests__/contact-scoring.test.ts new file mode 100644 index 000000000..8558cec2d --- /dev/null +++ b/packages/core/src/query/__tests__/contact-scoring.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for pure contact scoring helpers. + * + * Run: pnpm test -- packages/core/src/query/__tests__/contact-scoring.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + computeFriendScores, + computeNonFriendScores, + computePrivateRegularity, + rankPercentiles, +} from '../contact-scoring' + +describe('contact scoring helpers', () => { + it('scores private message volume with log1p percentile and bounded components', () => { + const contacts = [ + { key: 'quiet', privateMessageCount: 0, activeMonths: [], commonGroupCount: 0 }, + { key: 'medium', privateMessageCount: 9, activeMonths: ['2024-01'], commonGroupCount: 1 }, + { key: 'active', privateMessageCount: 99, activeMonths: ['2024-01', '2024-02'], commonGroupCount: 2 }, + ] + + const expectedMessageScores = rankPercentiles(contacts, (contact) => Math.log1p(contact.privateMessageCount)) + const scores = computeFriendScores(contacts) + + for (const contact of contacts) { + const result = scores.get(contact) + assert.ok(result) + assert.equal(result.scoreBreakdown.privateMessageScore, expectedMessageScores.get(contact)) + assert.ok(result.score >= 0 && result.score <= 1) + assert.ok((result.scoreBreakdown.privateMessageScore ?? -1) >= 0) + assert.ok((result.scoreBreakdown.privateMessageScore ?? 2) <= 1) + assert.ok((result.scoreBreakdown.privateRegularityScore ?? -1) >= 0) + assert.ok((result.scoreBreakdown.privateRegularityScore ?? 2) <= 1) + assert.ok((result.scoreBreakdown.commonGroupScore ?? -1) >= 0) + assert.ok((result.scoreBreakdown.commonGroupScore ?? 2) <= 1) + } + }) + + it('computes active-month regularity from active count and span ratio', () => { + assert.equal(computePrivateRegularity(['2024-01', '2024-02', '2024-03']), 3) + assert.equal(computePrivateRegularity(['2024-01', '2024-12']), 1 / 3) + assert.equal(computePrivateRegularity(['2024-05']), 1) + assert.ok(computePrivateRegularity(['2024-01', '2024-12']) < computePrivateRegularity(['2024-01', '2024-02'])) + }) + + it('scores non-friends with co-occurrence, common groups, and reply interactions', () => { + const contacts = [ + { key: 'low', coOccurrenceRawScore: 0, commonGroupCount: 1, replyInteractionCount: 0 }, + { key: 'group-overlap', coOccurrenceRawScore: 1, commonGroupCount: 5, replyInteractionCount: 1 }, + { key: 'interactive', coOccurrenceRawScore: 5, commonGroupCount: 2, replyInteractionCount: 8 }, + ] + + const scores = computeNonFriendScores(contacts) + + assert.ok(scores.get(contacts[2])!.score > scores.get(contacts[0])!.score) + for (const contact of contacts) { + const result = scores.get(contact) + assert.ok(result) + assert.ok(result.score >= 0 && result.score <= 1) + assert.ok((result.scoreBreakdown.coOccurrenceScore ?? -1) >= 0) + assert.ok((result.scoreBreakdown.coOccurrenceScore ?? 2) <= 1) + assert.ok((result.scoreBreakdown.commonGroupScore ?? -1) >= 0) + assert.ok((result.scoreBreakdown.commonGroupScore ?? 2) <= 1) + assert.ok((result.scoreBreakdown.replyInteractionScore ?? -1) >= 0) + assert.ok((result.scoreBreakdown.replyInteractionScore ?? 2) <= 1) + } + }) +}) diff --git a/packages/core/src/query/__tests__/member-ops.test.ts b/packages/core/src/query/__tests__/member-ops.test.ts new file mode 100644 index 000000000..e5f2ea73e --- /dev/null +++ b/packages/core/src/query/__tests__/member-ops.test.ts @@ -0,0 +1,110 @@ +/** + * Tests for shared member write operations. + * + * Run: npx tsx --test packages/core/src/query/__tests__/member-ops.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces/database-adapter' +import { updateMemberAliases, mergeMembers, deleteMember, ensureAliasesColumn, ensureAvatarColumn } from '../member-ops' + +function createInMemoryDb(): DatabaseAdapter & { rows: Map[]>; execLog: string[] } { + const rows = new Map[]>() + const execLog: string[] = [] + + return { + rows, + execLog, + exec(sql: string) { + execLog.push(sql) + }, + prepare(sql: string): PreparedStatement { + return { + get(..._params: unknown[]): Record | undefined { + return rows.get(sql)?.[0] + }, + all(..._params: unknown[]): Record[] { + return rows.get(sql) ?? [] + }, + run(..._params: unknown[]): RunResult { + execLog.push(`run:${sql.trim().substring(0, 50)}`) + return { changes: 1 } + }, + } + }, + transaction(fn: () => T): T { + return fn() + }, + pragma() { + return undefined + }, + close() { + /* no-op */ + }, + } +} + +describe('updateMemberAliases', () => { + it('returns true on success', () => { + const db = createInMemoryDb() + const result = updateMemberAliases(db, 1, ['nickname1', 'nickname2']) + assert.equal(result, true) + }) +}) + +describe('mergeMembers', () => { + it('returns false for same id', () => { + const db = createInMemoryDb() + assert.equal(mergeMembers(db, 1, 1), false) + }) + + it('returns false when members not found', () => { + const db = createInMemoryDb() + assert.equal(mergeMembers(db, 1, 2), false) + }) +}) + +describe('deleteMember', () => { + it('executes delete transaction', () => { + const db = createInMemoryDb() + const result = deleteMember(db, 42) + assert.equal(result, true) + assert.ok(db.execLog.some((s) => s.includes('DELETE FROM message'))) + assert.ok(db.execLog.some((s) => s.includes('DELETE FROM member'))) + }) +}) + +describe('ensureAliasesColumn', () => { + it('adds column when missing', () => { + const db = createInMemoryDb() + db.rows.set('PRAGMA table_info(member)', [{ name: 'id' }, { name: 'platform_id' }]) + const added = ensureAliasesColumn(db) + assert.equal(added, true) + assert.ok(db.execLog.some((s) => s.includes('ALTER TABLE member ADD COLUMN aliases'))) + }) + + it('skips when column exists', () => { + const db = createInMemoryDb() + db.rows.set('PRAGMA table_info(member)', [{ name: 'id' }, { name: 'aliases' }]) + const added = ensureAliasesColumn(db) + assert.equal(added, false) + }) +}) + +describe('ensureAvatarColumn', () => { + it('adds column when missing', () => { + const db = createInMemoryDb() + db.rows.set('PRAGMA table_info(member)', [{ name: 'id' }]) + const added = ensureAvatarColumn(db) + assert.equal(added, true) + assert.ok(db.execLog.some((s) => s.includes('ALTER TABLE member ADD COLUMN avatar'))) + }) + + it('skips when column exists', () => { + const db = createInMemoryDb() + db.rows.set('PRAGMA table_info(member)', [{ name: 'id' }, { name: 'avatar' }]) + const added = ensureAvatarColumn(db) + assert.equal(added, false) + }) +}) diff --git a/packages/core/src/query/__tests__/member-queries.test.ts b/packages/core/src/query/__tests__/member-queries.test.ts new file mode 100644 index 000000000..68d45ab7f --- /dev/null +++ b/packages/core/src/query/__tests__/member-queries.test.ts @@ -0,0 +1,347 @@ +/** + * Tests for member query functions: getMembersWithAliases, getMembersPaginated. + * + * Covers: + * - aliases/avatar column existence vs. absence + * - pagination, search, asc/desc sort + * - system message member filtering + * + * Run: npx tsx --test packages/core/src/query/__tests__/member-queries.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { getMemberNameHistory, getMembersWithAliases, getMembersPaginated } from '../message-queries' +import type { DatabaseAdapter, PreparedStatement } from '../../interfaces' + +// ==================== Mock helpers ==================== + +interface MockMember { + id: number + platformId: string + accountName: string | null + groupNickname: string | null + aliases: string | null + avatar: string | null + messageCount: number +} + +const SAMPLE_MEMBERS: MockMember[] = [ + { + id: 1, + platformId: 'u1', + accountName: 'Alice', + groupNickname: 'A', + aliases: '["小A"]', + avatar: 'data:img1', + messageCount: 100, + }, + { id: 2, platformId: 'u2', accountName: 'Bob', groupNickname: null, aliases: '[]', avatar: null, messageCount: 50 }, + { + id: 3, + platformId: 'u3', + accountName: 'Carol', + groupNickname: 'C', + aliases: null, + avatar: 'data:img3', + messageCount: 30, + }, + { + id: 4, + platformId: 'sys', + accountName: '系统消息', + groupNickname: '系统消息', + aliases: null, + avatar: null, + messageCount: 999, + }, +] + +function createMockDb(opts: { hasAliases?: boolean; hasAvatar?: boolean } = {}): DatabaseAdapter { + const { hasAliases = true, hasAvatar = true } = opts + + const nonSystemMembers = SAMPLE_MEMBERS.filter((m) => { + const displayName = m.groupNickname || m.accountName || m.platformId + return displayName !== '系统消息' + }) + + return { + prepare(sql: string): PreparedStatement { + return { + get(...params: unknown[]) { + if (sql.includes('PRAGMA table_info')) { + return undefined + } + if (sql.includes('COUNT(*)')) { + const searchParam = params.find((p) => typeof p === 'string' && (p as string).includes('%')) + let filtered = nonSystemMembers + if (searchParam) { + const term = (searchParam as string).replace(/%/g, '').toLowerCase() + filtered = filtered.filter( + (m) => + m.accountName?.toLowerCase().includes(term) || + m.groupNickname?.toLowerCase().includes(term) || + m.platformId.toLowerCase().includes(term) || + (hasAliases && m.aliases?.toLowerCase().includes(term)) + ) + } + return { total: filtered.length } + } + return undefined + }, + all(...params: unknown[]) { + if (sql.includes('PRAGMA table_info')) { + const cols = [{ name: 'id' }, { name: 'platform_id' }, { name: 'account_name' }, { name: 'group_nickname' }] + if (sql.includes('member')) { + if (hasAliases) cols.push({ name: 'aliases' }) + if (hasAvatar) cols.push({ name: 'avatar' }) + } + return cols + } + + let filtered = nonSystemMembers + + const searchParam = params.find((p) => typeof p === 'string' && (p as string).includes('%')) + if (searchParam) { + const term = (searchParam as string).replace(/%/g, '').toLowerCase() + filtered = filtered.filter( + (m) => + m.accountName?.toLowerCase().includes(term) || + m.groupNickname?.toLowerCase().includes(term) || + m.platformId.toLowerCase().includes(term) || + (hasAliases && m.aliases?.toLowerCase().includes(term)) + ) + } + + if (sql.includes('ORDER BY messageCount ASC')) { + filtered = [...filtered].sort((a, b) => a.messageCount - b.messageCount) + } else { + filtered = [...filtered].sort((a, b) => b.messageCount - a.messageCount) + } + + const limitIdx = params.findIndex((_, i) => { + const remaining = params.slice(i) + return remaining.length >= 2 && typeof remaining[0] === 'number' && typeof remaining[1] === 'number' + }) + if (limitIdx >= 0) { + const limit = params[limitIdx] as number + const offset = params[limitIdx + 1] as number + filtered = filtered.slice(offset, offset + limit) + } + + return filtered.map((m) => ({ + id: m.id, + platformId: m.platformId, + accountName: m.accountName, + groupNickname: m.groupNickname, + aliases: hasAliases ? m.aliases : null, + avatar: hasAvatar ? m.avatar : null, + messageCount: m.messageCount, + })) + }, + run() { + return { changes: 0, lastInsertRowid: 0 } + }, + } + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + exec() {}, + transaction(fn: () => T) { + return fn() + }, + pragma() { + return undefined + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + close() {}, + } +} + +function createNameHistoryDb(opts: { + hasHistoryTable: boolean + historyRows?: Record[] +}): DatabaseAdapter { + return { + prepare(sql: string): PreparedStatement { + return { + get(...params: unknown[]) { + if (sql.includes('sqlite_master') && params.includes('member_name_history')) { + return { cnt: opts.hasHistoryTable ? 1 : 0 } + } + return undefined + }, + all() { + if (sql.includes('FROM member_name_history')) { + return opts.historyRows ?? [] + } + if (sql.includes('FROM message')) { + return [ + { accountName: 'Alice', groupNickname: 'A', startTs: 1000, endTs: 1500 }, + { accountName: 'Alice Chen', groupNickname: null, startTs: 2000, endTs: 2500 }, + ] + } + return [] + }, + run() { + return { changes: 0, lastInsertRowid: 0 } + }, + } + }, + } as unknown as DatabaseAdapter +} + +// ==================== getMembersWithAliases ==================== + +describe('getMembersWithAliases', () => { + it('returns members with parsed aliases and avatar', () => { + const db = createMockDb({ hasAliases: true, hasAvatar: true }) + const result = getMembersWithAliases(db) + + assert.equal(result.length, 3, 'should exclude system message member') + assert.deepEqual(result[0].aliases, ['小A']) + assert.equal(result[0].avatar, 'data:img1') + assert.equal(result[0].accountName, 'Alice') + assert.equal(result[0].messageCount, 100) + }) + + it('returns empty aliases and null avatar when columns do not exist', () => { + const db = createMockDb({ hasAliases: false, hasAvatar: false }) + const result = getMembersWithAliases(db) + + assert.equal(result.length, 3) + for (const m of result) { + assert.deepEqual(m.aliases, [], 'aliases should default to empty array') + assert.equal(m.avatar, null, 'avatar should default to null') + } + }) + + it('excludes system message members', () => { + const db = createMockDb() + const result = getMembersWithAliases(db) + const hasSys = result.some((m) => m.accountName === '系统消息') + assert.equal(hasSys, false) + }) + + it('sorts by messageCount descending', () => { + const db = createMockDb() + const result = getMembersWithAliases(db) + for (let i = 1; i < result.length; i++) { + assert.ok(result[i - 1].messageCount >= result[i].messageCount) + } + }) +}) + +// ==================== getMemberNameHistory ==================== + +describe('getMemberNameHistory', () => { + it('derives history from message rows when member_name_history table is absent', () => { + const result = getMemberNameHistory(createNameHistoryDb({ hasHistoryTable: false }), 1) + + assert.deepEqual(result, [ + { nameType: 'account_name', name: 'Alice', startTs: 1000, endTs: 1500 }, + { nameType: 'group_nickname', name: 'A', startTs: 1000, endTs: 1500 }, + { nameType: 'account_name', name: 'Alice Chen', startTs: 2000, endTs: 2500 }, + ]) + }) + + it('derives history from message rows when member_name_history table is empty', () => { + const result = getMemberNameHistory(createNameHistoryDb({ hasHistoryTable: true, historyRows: [] }), 1) + + assert.deepEqual(result, [ + { nameType: 'account_name', name: 'Alice', startTs: 1000, endTs: 1500 }, + { nameType: 'group_nickname', name: 'A', startTs: 1000, endTs: 1500 }, + { nameType: 'account_name', name: 'Alice Chen', startTs: 2000, endTs: 2500 }, + ]) + }) +}) + +// ==================== getMembersPaginated ==================== + +describe('getMembersPaginated', () => { + it('returns first page with correct pagination meta', () => { + const db = createMockDb() + const result = getMembersPaginated(db, { page: 1, pageSize: 2 }) + + assert.equal(result.page, 1) + assert.equal(result.pageSize, 2) + assert.equal(result.total, 3) + assert.equal(result.totalPages, 2) + assert.equal(result.members.length, 2) + }) + + it('returns second page', () => { + const db = createMockDb() + const result = getMembersPaginated(db, { page: 2, pageSize: 2 }) + + assert.equal(result.page, 2) + assert.equal(result.members.length, 1) + }) + + it('filters by search term', () => { + const db = createMockDb() + const result = getMembersPaginated(db, { search: 'alice' }) + + assert.equal(result.total, 1) + assert.equal(result.members[0].accountName, 'Alice') + }) + + it('sorts ascending', () => { + const db = createMockDb() + const result = getMembersPaginated(db, { sortOrder: 'asc' }) + + for (let i = 1; i < result.members.length; i++) { + assert.ok(result.members[i - 1].messageCount <= result.members[i].messageCount) + } + }) + + it('sorts descending by default', () => { + const db = createMockDb() + const result = getMembersPaginated(db, {}) + + for (let i = 1; i < result.members.length; i++) { + assert.ok(result.members[i - 1].messageCount >= result.members[i].messageCount) + } + }) + + it('works when aliases/avatar columns are missing', () => { + const db = createMockDb({ hasAliases: false, hasAvatar: false }) + const result = getMembersPaginated(db, {}) + + assert.equal(result.total, 3) + for (const m of result.members) { + assert.deepEqual(m.aliases, []) + assert.equal(m.avatar, null) + } + }) + + it('uses default page and pageSize when omitted', () => { + const db = createMockDb() + const result = getMembersPaginated(db, {}) + + assert.equal(result.page, 1) + assert.equal(result.pageSize, 20) + }) + + it('clamps page to minimum 1', () => { + const db = createMockDb() + const result = getMembersPaginated(db, { page: -5 }) + + assert.equal(result.page, 1) + }) + + it('clamps pageSize to range [1, 100]', () => { + const db = createMockDb() + const r1 = getMembersPaginated(db, { pageSize: 0 }) + assert.equal(r1.pageSize, 1) + + const r2 = getMembersPaginated(db, { pageSize: 999 }) + assert.equal(r2.pageSize, 100) + }) + + it('excludes system message members', () => { + const db = createMockDb() + const result = getMembersPaginated(db, { pageSize: 100 }) + const hasSys = result.members.some((m) => m.accountName === '系统消息') + assert.equal(hasSys, false) + }) +}) diff --git a/packages/core/src/query/__tests__/message-query-functions.test.ts b/packages/core/src/query/__tests__/message-query-functions.test.ts new file mode 100644 index 000000000..f1dafcbaa --- /dev/null +++ b/packages/core/src/query/__tests__/message-query-functions.test.ts @@ -0,0 +1,252 @@ +/** + * Minimal tests for shared async message query functions. + * + * Verifies that both "Electron-style" (sync-backed) and "Web-style" (async-backed) + * executors call the same core functions and produce consistent results. + * + * Run: npx tsx --test packages/core/src/query/__tests__/message-query-functions.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { AsyncSqlExecutor } from '../message-query-functions' +import type { FullMessageRow } from '../message-sql' +import { + fetchMessagesBefore, + fetchMessagesAfter, + searchMessagesLikeAsync, + fetchMessageContext, + fetchSearchMessageContext, + fetchAllRecentMessages, + fetchRecentTextMessages, + fetchConversationBetween, +} from '../message-query-functions' + +// ==================== Test fixtures ==================== + +function makeRow(id: number, content: string = `msg-${id}`, ts?: number): FullMessageRow { + return { + id, + senderId: 1, + senderName: 'Alice', + senderPlatformId: 'alice_001', + aliasesJson: '[]', + senderAvatar: null, + content, + timestamp: ts ?? 1700000000 + id * 60, + type: 0, + replyToMessageId: null, + replyToContent: null, + replyToSenderName: null, + } +} + +const SAMPLE_ROWS: FullMessageRow[] = Array.from({ length: 10 }, (_, i) => makeRow(i + 1)) + +// ==================== Mock executors ==================== + +function extractQueryKey(sql: string): string { + const t = sql.trim().toLowerCase() + if (t.includes('count(*)')) return 'count' + if (t.includes('sqlite_master')) return 'sqlite_master' + if (t.includes('from message_context mc')) return 'session_context' + if (t.includes('from message_context')) return 'message_context' + if (t.includes('from member')) return 'member' + if (t.includes('msg.id in')) return 'by_ids' + if (t.includes('msg.id <') || (t.includes('id <') && t.includes('order by') && t.includes('desc'))) return 'before' + if (t.includes('msg.id >') || (t.includes('id >') && t.includes('order by') && t.includes('asc'))) return 'after' + if (t.includes('order by msg.ts desc')) return 'desc' + return 'default' +} + +/** Simulates Electron-style executor (sync-backed, wraps result in Promise.resolve). */ +function createSyncBackedExecutor(store: Map): AsyncSqlExecutor { + return { + all(sql: string, _params: unknown[] = []): Promise { + return Promise.resolve((store.get(extractQueryKey(sql)) ?? []) as T[]) + }, + get(sql: string, _params: unknown[] = []): Promise { + const rows = (store.get(extractQueryKey(sql)) ?? []) as T[] + return Promise.resolve(rows[0]) + }, + } +} + +/** Simulates Web-style executor (truly async, like pluginQuery over HTTP). */ +function createAsyncExecutor(store: Map): AsyncSqlExecutor { + return { + async all(sql: string, _params: unknown[] = []): Promise { + await new Promise((r) => setTimeout(r, 1)) + return (store.get(extractQueryKey(sql)) ?? []) as T[] + }, + async get(sql: string, _params: unknown[] = []): Promise { + await new Promise((r) => setTimeout(r, 1)) + const rows = (store.get(extractQueryKey(sql)) ?? []) as T[] + return rows[0] + }, + } +} + +// ==================== Tests ==================== + +describe('fetchMessagesBefore', () => { + it('returns messages in ascending order with hasMore flag', async () => { + const rows = SAMPLE_ROWS.slice(0, 4) + const store = new Map([['before', rows]]) + + const syncResult = await fetchMessagesBefore(createSyncBackedExecutor(store), 10, 3) + const asyncResult = await fetchMessagesBefore(createAsyncExecutor(store), 10, 3) + + assert.equal(syncResult.hasMore, true) + assert.equal(asyncResult.hasMore, true) + assert.equal(syncResult.messages.length, 3) + assert.equal(asyncResult.messages.length, 3) + assert.deepEqual( + syncResult.messages.map((m) => m.id), + asyncResult.messages.map((m) => m.id) + ) + }) + + it('returns hasMore=false when fewer results than limit+1', async () => { + const rows = SAMPLE_ROWS.slice(0, 2) + const store = new Map([['before', rows]]) + + const result = await fetchMessagesBefore(createSyncBackedExecutor(store), 10, 5) + assert.equal(result.hasMore, false) + assert.equal(result.messages.length, 2) + }) +}) + +describe('fetchMessagesAfter', () => { + it('returns messages with hasMore flag, consistent across executors', async () => { + const rows = SAMPLE_ROWS.slice(5, 10) + const store = new Map([['after', rows]]) + + const syncResult = await fetchMessagesAfter(createSyncBackedExecutor(store), 5, 4) + const asyncResult = await fetchMessagesAfter(createAsyncExecutor(store), 5, 4) + + assert.equal(syncResult.hasMore, true) + assert.equal(asyncResult.hasMore, true) + assert.equal(syncResult.messages.length, 4) + assert.deepEqual( + syncResult.messages.map((m) => m.id), + asyncResult.messages.map((m) => m.id) + ) + }) +}) + +describe('searchMessagesLikeAsync', () => { + it('returns total and messages consistently across executors', async () => { + const store = new Map([ + ['count', [{ total: 42 }]], + ['desc', SAMPLE_ROWS.slice(0, 5)], + ]) + + const syncResult = await searchMessagesLikeAsync(createSyncBackedExecutor(store), ['hello'], undefined, 20, 0) + const asyncResult = await searchMessagesLikeAsync(createAsyncExecutor(store), ['hello'], undefined, 20, 0) + + assert.equal(syncResult.total, 42) + assert.equal(asyncResult.total, 42) + assert.equal(syncResult.messages.length, asyncResult.messages.length) + }) +}) + +describe('fetchMessageContext', () => { + it('collects context ids around target messages', async () => { + const store = new Map([ + ['before', [{ id: 4 }, { id: 3 }]], + ['after', [{ id: 6 }, { id: 7 }]], + ['by_ids', [makeRow(3), makeRow(4), makeRow(5), makeRow(6), makeRow(7)]], + ]) + + const syncResult = await fetchMessageContext(createSyncBackedExecutor(store), 5, 2) + const asyncResult = await fetchMessageContext(createAsyncExecutor(store), 5, 2) + + assert.equal(syncResult.length, asyncResult.length) + assert.deepEqual( + syncResult.map((m) => m.id), + asyncResult.map((m) => m.id) + ) + }) +}) + +describe('fetchAllRecentMessages', () => { + it('returns total and messages in ascending order', async () => { + const rows = SAMPLE_ROWS.slice(0, 3) + const store = new Map([ + ['count', [{ total: 100 }]], + ['desc', rows], + ]) + + const syncResult = await fetchAllRecentMessages(createSyncBackedExecutor(store), undefined, 3) + const asyncResult = await fetchAllRecentMessages(createAsyncExecutor(store), undefined, 3) + + assert.equal(syncResult.total, 100) + assert.equal(asyncResult.total, 100) + assert.equal(syncResult.messages.length, 3) + assert.deepEqual( + syncResult.messages.map((m) => m.id), + asyncResult.messages.map((m) => m.id) + ) + }) +}) + +describe('fetchRecentTextMessages', () => { + it('returns total and messages from sync and async executors', async () => { + const rows = SAMPLE_ROWS.slice(0, 2) + const store = new Map([ + ['count', [{ total: 50 }]], + ['desc', rows], + ]) + + const syncResult = await fetchRecentTextMessages(createSyncBackedExecutor(store), undefined, 10) + const asyncResult = await fetchRecentTextMessages(createAsyncExecutor(store), undefined, 10) + + assert.equal(syncResult.total, 50) + assert.equal(asyncResult.total, 50) + assert.deepEqual( + syncResult.messages.map((m) => m.id), + asyncResult.messages.map((m) => m.id) + ) + }) +}) + +describe('fetchConversationBetween', () => { + it('returns empty when member not found', async () => { + const store = new Map() + const result = await fetchConversationBetween(createSyncBackedExecutor(store), 1, 2) + assert.equal(result.messages.length, 0) + assert.equal(result.member1Name, '') + assert.equal(result.member2Name, '') + }) + + it('returns conversation data when both members exist', async () => { + const rows = [makeRow(10, 'hi', 1700000000), makeRow(11, 'hello', 1700000060)] + const store = new Map([ + ['member', [{ name: 'Alice' }]], + ['count', [{ total: 2 }]], + ['desc', rows], + ]) + + const syncResult = await fetchConversationBetween(createSyncBackedExecutor(store), 1, 2) + const asyncResult = await fetchConversationBetween(createAsyncExecutor(store), 1, 2) + + assert.equal(syncResult.total, 2) + assert.equal(asyncResult.total, 2) + assert.equal(syncResult.member1Name, 'Alice') + assert.equal(asyncResult.member1Name, 'Alice') + }) +}) + +describe('fetchSearchMessageContext', () => { + it('falls back to id-based context when no message_context table', async () => { + const store = new Map([ + ['before', [{ id: 4 }]], + ['after', [{ id: 6 }]], + ['by_ids', [makeRow(4), makeRow(5), makeRow(6)]], + ]) + + const result = await fetchSearchMessageContext(createSyncBackedExecutor(store), [5], 1, 1) + assert.ok(result.length > 0) + }) +}) diff --git a/packages/core/src/query/__tests__/relationship-graph-queries.test.ts b/packages/core/src/query/__tests__/relationship-graph-queries.test.ts new file mode 100644 index 000000000..398212207 --- /dev/null +++ b/packages/core/src/query/__tests__/relationship-graph-queries.test.ts @@ -0,0 +1,168 @@ +/** + * Tests for single-session people relationship graph query helpers. + * + * Run: pnpm test -- packages/core/src/query/__tests__/relationship-graph-queries.test.ts + */ + +import { afterEach, beforeEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import path from 'node:path' +import Database from 'better-sqlite3' +import { getGroupRelationshipGraphFacts, resolveOwnerMember } from '../contact-queries' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +class Stmt implements PreparedStatement { + readonly?: boolean + + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } + + get(...p: unknown[]) { + return this.stmt.get(...p) as Record | undefined + } + + all(...p: unknown[]) { + return this.stmt.all(...p) as Record[] + } + + run(...p: unknown[]): RunResult { + const r = this.stmt.run(...p) + return { changes: r.changes, lastInsertRowid: r.lastInsertRowid } + } +} + +class Adapter implements DatabaseAdapter { + constructor(private db: Database.Database) {} + + exec(sql: string) { + this.db.exec(sql) + } + + prepare(sql: string) { + return new Stmt(this.db.prepare(sql)) + } + + transaction(fn: () => T): T { + return this.db.transaction(fn)() + } + + pragma(p: string) { + return this.db.pragma(p) + } + + close() { + this.db.close() + } +} + +describe('relationship graph query helpers', () => { + let raw: Database.Database + let db: Adapter + + beforeEach(() => { + raw = new Database(':memory:', { nativeBinding }) + raw.exec(` + CREATE TABLE meta ( + name TEXT, + platform TEXT, + type TEXT, + imported_at INTEGER, + owner_id TEXT + ); + CREATE TABLE member ( + id INTEGER PRIMARY KEY, + platform_id TEXT, + account_name TEXT, + group_nickname TEXT, + aliases TEXT DEFAULT '[]', + avatar TEXT + ); + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + sender_id INTEGER, + ts INTEGER, + type INTEGER, + content TEXT, + platform_message_id TEXT, + reply_to_message_id TEXT + ); + INSERT INTO meta (name, platform, type, imported_at, owner_id) + VALUES ('Group', 'wechat', 'group', 1700000000, 'owner-pid'); + INSERT INTO member (id, platform_id, account_name, group_nickname, aliases, avatar) VALUES + (1, 'owner-pid', 'Owner', NULL, '[]', NULL), + (2, 'alice-pid', 'Alice', 'Alice G', '["Ally"]', 'alice.png'), + (3, 'bob-pid', 'Bob', NULL, '[]', NULL), + (4, 'carol-pid', 'Carol', NULL, '[]', NULL), + (99, 'system', 'System', NULL, '[]', NULL); + `) + db = new Adapter(raw) + }) + + afterEach(() => { + raw.close() + }) + + it('returns non-owner member nodes and real interaction edges from co-occurrence and replies', () => { + const owner = resolveOwnerMember(db) + assert.ok(owner) + const insert = raw.prepare( + `INSERT INTO message + (id, sender_id, ts, type, content, platform_message_id, reply_to_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + insert.run(1, 1, 1704103200, 0, 'owner starts', 'owner-1', null) + insert.run(2, 2, 1704103201, 0, 'alice near bob', 'alice-1', null) + insert.run(3, 3, 1704103202, 0, 'bob replies alice', 'bob-1', 'alice-1') + insert.run(4, 4, 1704103900, 0, 'carol far away', 'carol-1', null) + insert.run(5, 99, 1704103901, 80, 'system event', 'system-1', null) + + const facts = getGroupRelationshipGraphFacts(db, owner.id) + + assert.equal(facts.ownerMessageCount, 1) + assert.deepEqual( + facts.members.map((member) => member.contact.platformId), + ['alice-pid', 'bob-pid', 'carol-pid'] + ) + assert.equal( + facts.members.find((member) => member.contact.platformId === 'owner-pid'), + undefined + ) + const edge = facts.edges.find( + (item) => item.source.platformId === 'alice-pid' && item.target.platformId === 'bob-pid' + ) + assert.ok(edge) + assert.ok(edge.coOccurrenceCount > 0) + assert.equal(edge.replyInteractionCount, 1) + assert.equal(edge.repliesFromTargetToSource, 1) + assert.equal(edge.lastInteractionTs, 1704103202) + }) + + it('filters relationship graph facts by message start timestamp', () => { + const owner = resolveOwnerMember(db) + assert.ok(owner) + const insert = raw.prepare( + `INSERT INTO message + (id, sender_id, ts, type, content, platform_message_id, reply_to_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + insert.run(1, 2, 1600000000, 0, 'old alice', 'old-alice', null) + insert.run(2, 3, 1600000001, 0, 'old bob', 'old-bob', 'old-alice') + insert.run(3, 2, 1704103200, 0, 'new alice', 'new-alice', null) + + const facts = getGroupRelationshipGraphFacts(db, owner.id, { startTs: 1700000000 }) + + assert.equal(facts.ownerMessageCount, 0) + assert.deepEqual( + facts.members.map((member) => [member.contact.platformId, member.messageCount]), + [ + ['alice-pid', 1], + ['bob-pid', 0], + ['carol-pid', 0], + ] + ) + assert.equal(facts.edges.length, 0) + }) +}) diff --git a/packages/core/src/query/__tests__/session-index.test.ts b/packages/core/src/query/__tests__/session-index.test.ts new file mode 100644 index 000000000..3de213469 --- /dev/null +++ b/packages/core/src/query/__tests__/session-index.test.ts @@ -0,0 +1,380 @@ +/** + * Tests for session index functions extracted to core: + * hasSessionIndex, getSessionIndexStats, getChatSessionList, + * getSegmentSummary, saveSegmentSummary, updateSessionGapThreshold, + * clearSessionIndex, generateSessionIndex, generateIncrementalSessionIndex. + * + * Run: npx tsx --test packages/core/src/query/__tests__/session-index.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import Database from 'better-sqlite3' +import { + DEFAULT_SESSION_GAP_THRESHOLD, + hasSessionIndex, + getSessionIndexStats, + getChatSessionList, + getSegmentSummary, + saveSegmentSummary, + updateSessionGapThreshold, + clearSessionIndex, + generateSessionIndex, + generateIncrementalSessionIndex, +} from '../session-queries' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../interfaces' + +// ==================== SQLite test DB ==================== + +class SqlitePreparedStatement implements PreparedStatement { + readonly?: boolean + + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } + + get(...params: unknown[]): Record | undefined { + return this.stmt.get(...params) as Record | undefined + } + + all(...params: unknown[]): Record[] { + return this.stmt.all(...params) as Record[] + } + + run(...params: unknown[]): RunResult { + const result = this.stmt.run(...params) + return { changes: result.changes, lastInsertRowid: result.lastInsertRowid } + } +} + +class TestSqliteDb implements DatabaseAdapter { + constructor(private db: Database.Database) {} + + exec(sql: string): void { + this.db.exec(sql) + } + + prepare(sql: string): PreparedStatement { + return new SqlitePreparedStatement(this.db.prepare(sql)) + } + + transaction(fn: () => T): T { + return this.db.transaction(fn)() + } + + pragma(pragma: string): unknown { + return this.db.pragma(pragma) + } + + close(): void { + this.db.close() + } +} + +function createSqliteDb(): TestSqliteDb { + const db = new TestSqliteDb(new Database(':memory:')) + db.exec(` + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL + ); + CREATE TABLE segment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER NOT NULL, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + CREATE TABLE message_context ( + message_id INTEGER NOT NULL, + segment_id INTEGER NOT NULL, + topic_id INTEGER + ); + CREATE TABLE meta ( + session_gap_threshold INTEGER + ); + INSERT INTO meta (session_gap_threshold) VALUES (NULL); + `) + return db +} + +function seedMessages(db: DatabaseAdapter, msgs: Array<{ id: number; ts: number }>) { + const insert = db.prepare('INSERT INTO message (id, ts) VALUES (?, ?)') + for (const msg of msgs) { + insert.run(msg.id, msg.ts) + } +} + +function countRows(db: DatabaseAdapter, table: 'segment' | 'message_context'): number { + const row = db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get() as { count: number } + return row.count +} + +function getMetaGapThreshold(db: DatabaseAdapter): number | null { + const row = db.prepare('SELECT session_gap_threshold FROM meta LIMIT 1').get() as { + session_gap_threshold: number | null + } + return row.session_gap_threshold +} + +// ==================== Tests ==================== + +describe('DEFAULT_SESSION_GAP_THRESHOLD', () => { + it('equals 1800 (30 minutes)', () => { + assert.equal(DEFAULT_SESSION_GAP_THRESHOLD, 1800) + }) +}) + +describe('hasSessionIndex', () => { + it('returns false when no sessions exist', () => { + const db = createSqliteDb() + assert.equal(hasSessionIndex(db), false) + }) + + it('returns true after generating sessions', () => { + const db = createSqliteDb() + seedMessages(db, [ + { id: 1, ts: 1000 }, + { id: 2, ts: 1100 }, + ]) + generateSessionIndex(db) + assert.equal(hasSessionIndex(db), true) + }) +}) + +describe('getSessionIndexStats', () => { + it('returns defaults when no index exists', () => { + const db = createSqliteDb() + const stats = getSessionIndexStats(db) + assert.equal(stats.sessionCount, 0) + assert.equal(stats.hasIndex, false) + assert.equal(stats.gapThreshold, DEFAULT_SESSION_GAP_THRESHOLD) + }) + + it('returns custom gap threshold from meta', () => { + const db = createSqliteDb() + updateSessionGapThreshold(db, 900) + const stats = getSessionIndexStats(db) + assert.equal(stats.gapThreshold, 900) + }) +}) + +describe('generateSessionIndex', () => { + it('returns 0 when no messages exist', () => { + const db = createSqliteDb() + assert.equal(generateSessionIndex(db), 0) + }) + + it('creates sessions based on gap threshold', () => { + const db = createSqliteDb() + seedMessages(db, [ + { id: 1, ts: 1000 }, + { id: 2, ts: 1100 }, + { id: 3, ts: 5000 }, + { id: 4, ts: 5100 }, + ]) + + const count = generateSessionIndex(db, 2000) + assert.equal(count, 2) + assert.equal(countRows(db, 'segment'), 2) + assert.equal(countRows(db, 'message_context'), 4) + }) + + it('puts all messages in one session when gap is large enough', () => { + const db = createSqliteDb() + seedMessages(db, [ + { id: 1, ts: 1000 }, + { id: 2, ts: 1100 }, + { id: 3, ts: 5000 }, + ]) + + const count = generateSessionIndex(db, 99999) + assert.equal(count, 1) + }) + + it('clears previous sessions before regenerating', () => { + const db = createSqliteDb() + seedMessages(db, [ + { id: 1, ts: 1000 }, + { id: 2, ts: 5000 }, + ]) + + generateSessionIndex(db, 2000) + assert.equal(countRows(db, 'segment'), 2) + + generateSessionIndex(db, 99999) + assert.equal(countRows(db, 'segment'), 1) + }) + + it('calls onProgress callback', () => { + const db = createSqliteDb() + seedMessages(db, [ + { id: 1, ts: 1000 }, + { id: 2, ts: 5000 }, + ]) + + let finalCurrent = 0 + let finalTotal = 0 + generateSessionIndex(db, 2000, (c, t) => { + finalCurrent = c + finalTotal = t + }) + assert.equal(finalCurrent, 2) + assert.equal(finalTotal, 2) + }) +}) + +describe('getChatSessionList', () => { + it('returns empty array when no sessions', () => { + const db = createSqliteDb() + assert.deepEqual(getChatSessionList(db), []) + }) + + it('returns sessions with firstMessageId', () => { + const db = createSqliteDb() + seedMessages(db, [ + { id: 10, ts: 1000 }, + { id: 20, ts: 1100 }, + { id: 30, ts: 5000 }, + ]) + generateSessionIndex(db, 2000) + + const list = getChatSessionList(db) + assert.equal(list.length, 2) + assert.equal(list[0].firstMessageId, 10) + assert.equal(list[0].messageCount, 2) + assert.equal(list[1].firstMessageId, 30) + }) +}) + +describe('getSegmentSummary / saveSegmentSummary', () => { + it('returns null when no summary set', () => { + const db = createSqliteDb() + seedMessages(db, [{ id: 1, ts: 1000 }]) + generateSessionIndex(db) + + assert.equal(getSegmentSummary(db, 1), null) + }) + + it('saves and retrieves summary', () => { + const db = createSqliteDb() + seedMessages(db, [{ id: 1, ts: 1000 }]) + generateSessionIndex(db) + + saveSegmentSummary(db, 1, 'Test summary') + assert.equal(getSegmentSummary(db, 1), 'Test summary') + }) +}) + +describe('updateSessionGapThreshold', () => { + it('updates gap threshold in meta', () => { + const db = createSqliteDb() + updateSessionGapThreshold(db, 900) + assert.equal(getMetaGapThreshold(db), 900) + }) + + it('accepts null to reset', () => { + const db = createSqliteDb() + updateSessionGapThreshold(db, 900) + updateSessionGapThreshold(db, null) + assert.equal(getMetaGapThreshold(db), null) + }) +}) + +describe('clearSessionIndex', () => { + it('removes all sessions and contexts', () => { + const db = createSqliteDb() + seedMessages(db, [ + { id: 1, ts: 1000 }, + { id: 2, ts: 5000 }, + ]) + generateSessionIndex(db, 2000) + assert.ok(countRows(db, 'segment') > 0) + + clearSessionIndex(db) + assert.equal(countRows(db, 'segment'), 0) + assert.equal(countRows(db, 'message_context'), 0) + }) + + it('rolls back context deletion when segment deletion fails', () => { + const db = createSqliteDb() + seedMessages(db, [ + { id: 1, ts: 1000 }, + { id: 2, ts: 5000 }, + ]) + generateSessionIndex(db, 2000) + db.exec(` + CREATE TRIGGER prevent_segment_delete + BEFORE DELETE ON segment + BEGIN + SELECT RAISE(ABORT, 'blocked'); + END; + `) + + assert.throws(() => clearSessionIndex(db), /blocked/) + assert.equal(countRows(db, 'segment'), 2) + assert.equal(countRows(db, 'message_context'), 2) + }) +}) + +describe('generateIncrementalSessionIndex', () => { + it('returns 0 when no new messages', () => { + const db = createSqliteDb() + seedMessages(db, [{ id: 1, ts: 1000 }]) + generateSessionIndex(db) + + const newCount = generateIncrementalSessionIndex(db) + assert.equal(newCount, 0) + }) + + it('creates new sessions for unindexed messages', () => { + const db = createSqliteDb() + seedMessages(db, [ + { id: 1, ts: 1000 }, + { id: 2, ts: 1100 }, + ]) + generateSessionIndex(db, 2000) + assert.equal(countRows(db, 'segment'), 1) + + seedMessages(db, [{ id: 3, ts: 50000 }]) + + const newCount = generateIncrementalSessionIndex(db, 2000) + assert.equal(newCount, 1) + assert.equal(countRows(db, 'segment'), 2) + }) + + it('appends to existing session when within threshold', () => { + const db = createSqliteDb() + seedMessages(db, [{ id: 1, ts: 1000 }]) + generateSessionIndex(db, 2000) + + seedMessages(db, [{ id: 2, ts: 1500 }]) + + const newCount = generateIncrementalSessionIndex(db, 2000) + assert.equal(newCount, 0, 'should not create new session') + assert.equal(countRows(db, 'message_context'), 2) + }) +}) + +describe('generateSessionIndex atomicity', () => { + it('preserves the existing index when rebuilding fails', () => { + const db = createSqliteDb() + seedMessages(db, [ + { id: 1, ts: 1000 }, + { id: 2, ts: 5000 }, + ]) + generateSessionIndex(db, 2000) + db.exec(` + CREATE TRIGGER prevent_segment_insert + BEFORE INSERT ON segment + BEGIN + SELECT RAISE(ABORT, 'blocked'); + END; + `) + + assert.throws(() => generateSessionIndex(db, 2000), /blocked/) + assert.equal(countRows(db, 'segment'), 2) + assert.equal(countRows(db, 'message_context'), 2) + }) +}) diff --git a/packages/core/src/query/__tests__/session-queries.test.ts b/packages/core/src/query/__tests__/session-queries.test.ts new file mode 100644 index 000000000..669324026 --- /dev/null +++ b/packages/core/src/query/__tests__/session-queries.test.ts @@ -0,0 +1,213 @@ +/** + * Minimal tests for session query functions. + * + * Verifies buildSessionInfo pure mapper and getSessionInfo / getSummaryCount / getLastPlatformMessageId + * against a mock DatabaseAdapter. + * + * Run: npx tsx --test packages/core/src/query/__tests__/session-queries.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { SessionMeta, SessionOverview } from '../session-queries' +import { + buildSessionInfo, + getChatOverview, + getSessionInfo, + getSummaryCount, + getLastPlatformMessageId, +} from '../session-queries' +import type { DatabaseAdapter } from '../../interfaces' + +// ==================== Mock helpers ==================== + +function makeMeta(overrides?: Partial): SessionMeta { + return { + name: 'Test Group', + platform: 'wechat', + type: 'group', + importedAt: 1700000000, + groupId: 'g001', + groupAvatar: null, + ownerId: 'u001', + ...overrides, + } +} + +function makeOverview(overrides?: Partial): SessionOverview { + return { + totalMessages: 500, + totalMembers: 10, + firstMessageTs: 1600000000, + lastMessageTs: 1700000000, + ...overrides, + } +} + +type QueryResult = Record + +/** + * Create a mock DatabaseAdapter whose prepare().get()/all() respond based on SQL patterns. + */ +function createMockDb(handlers: Record QueryResult | QueryResult[]>): DatabaseAdapter { + return { + prepare(sql: string) { + return { + get(...args: unknown[]) { + for (const [pattern, handler] of Object.entries(handlers)) { + if (sql.includes(pattern)) { + return handler(args) + } + } + return undefined + }, + all(...args: unknown[]) { + for (const [pattern, handler] of Object.entries(handlers)) { + if (sql.includes(pattern)) { + const result = handler(args) + return Array.isArray(result) ? result : [result] + } + } + return [] + }, + run() { + return { changes: 0, lastInsertRowid: 0 } + }, + } + }, + } as unknown as DatabaseAdapter +} + +// ==================== Tests ==================== + +describe('buildSessionInfo', () => { + it('composes meta + overview into flat CoreSessionInfo', () => { + const meta = makeMeta() + const overview = makeOverview() + const info = buildSessionInfo(meta, overview, 3) + + assert.equal(info.name, 'Test Group') + assert.equal(info.platform, 'wechat') + assert.equal(info.type, 'group') + assert.equal(info.importedAt, 1700000000) + assert.equal(info.messageCount, 500) + assert.equal(info.memberCount, 10) + assert.equal(info.firstMessageTs, 1600000000) + assert.equal(info.lastMessageTs, 1700000000) + assert.equal(info.groupId, 'g001') + assert.equal(info.groupAvatar, null) + assert.equal(info.ownerId, 'u001') + assert.equal(info.summaryCount, 3) + }) + + it('defaults summaryCount to 0 when omitted', () => { + const info = buildSessionInfo(makeMeta(), makeOverview()) + assert.equal(info.summaryCount, 0) + }) + + it('preserves null timestamps when overview has no messages', () => { + const overview = makeOverview({ firstMessageTs: null, lastMessageTs: null }) + const info = buildSessionInfo(makeMeta(), overview) + assert.equal(info.firstMessageTs, null) + assert.equal(info.lastMessageTs, null) + }) +}) + +describe('getSessionInfo', () => { + it('returns null when meta table is empty', () => { + const db = createMockDb({ + 'FROM meta': () => undefined as unknown as QueryResult, + }) + assert.equal(getSessionInfo(db), null) + }) + + it('returns combined CoreSessionInfo from DB', () => { + const db = createMockDb({ + 'FROM meta': () => ({ + name: 'Chat', + platform: 'telegram', + type: 'private', + imported_at: 1700000000, + group_id: null, + group_avatar: null, + owner_id: 'me', + }), + 'COUNT(*)': () => ({ count: 42 }), + 'MIN(ts)': () => ({ v: 1600000000 }), + 'MAX(ts)': () => ({ v: 1700000000 }), + sqlite_master: () => ({ cnt: 0 }), + }) + + const info = getSessionInfo(db) + assert.ok(info) + assert.equal(info.name, 'Chat') + assert.equal(info.platform, 'telegram') + assert.equal(info.type, 'private') + assert.equal(info.ownerId, 'me') + }) +}) + +describe('getChatOverview', () => { + it('returns summaryCount together with overview and top members', () => { + const db = createMockDb({ + 'FROM meta': () => ({ + name: 'Chat', + platform: 'wechat', + type: 'group', + imported_at: 1700000000, + group_id: null, + group_avatar: null, + owner_id: null, + }), + 'FROM message msg': () => ({ count: 42 }), + 'FROM member\n WHERE': () => ({ count: 2 }), + 'MIN(ts)': () => ({ v: 1600000000 }), + 'MAX(ts)': () => ({ v: 1700000000 }), + sqlite_master: () => ({ cnt: 1 }), + 'FROM segment': () => ({ count: 5 }), + 'FROM member m': () => [ + { memberId: 1, platformId: 'u1', name: 'Alice', avatar: null, messageCount: 30 }, + { memberId: 2, platformId: 'u2', name: 'Bob', avatar: null, messageCount: 12 }, + ], + }) + + const overview = getChatOverview(db, 1) + + assert.ok(overview) + assert.equal(overview.summaryCount, 5) + assert.deepEqual(overview.topMembers, [{ id: 1, name: 'Alice', count: 30 }]) + }) +}) + +describe('getSummaryCount', () => { + it('returns 0 when segment table does not exist', () => { + const db = createMockDb({ + sqlite_master: () => ({ cnt: 0 }), + }) + assert.equal(getSummaryCount(db), 0) + }) + + it('returns count when segment table exists', () => { + const db = createMockDb({ + sqlite_master: () => ({ cnt: 1 }), + 'FROM segment': () => ({ count: 7 }), + }) + assert.equal(getSummaryCount(db), 7) + }) +}) + +describe('getLastPlatformMessageId', () => { + it('returns null when no platform_message_id exists', () => { + const db = createMockDb({ + platform_message_id: () => undefined as unknown as QueryResult, + }) + assert.equal(getLastPlatformMessageId(db), null) + }) + + it('returns the latest platform_message_id', () => { + const db = createMockDb({ + platform_message_id: () => ({ platform_message_id: 'pmid-999' }), + }) + assert.equal(getLastPlatformMessageId(db), 'pmid-999') + }) +}) diff --git a/packages/core/src/query/advanced/__tests__/ranking.test.ts b/packages/core/src/query/advanced/__tests__/ranking.test.ts new file mode 100644 index 000000000..b2b019e03 --- /dev/null +++ b/packages/core/src/query/advanced/__tests__/ranking.test.ts @@ -0,0 +1,262 @@ +/** + * Tests for ranking analytics migrated from frontend pluginCompute: + * getDragonKingAnalysis, getDivingAnalysis, getCheckInAnalysis, + * getMemeBattleAnalysis, getNightOwlAnalysis, getRepeatAnalysis. + * + * Locks the ported algorithms against the previous behavior using a real + * in-memory SQLite DB. TZ is pinned to UTC so strftime('localtime') and JS + * Date hour/date math are deterministic across machines. + * + * Run: npx tsx --test packages/core/src/query/advanced/__tests__/ranking.test.ts + */ + +process.env.TZ = 'UTC' + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import Database from 'better-sqlite3' +import { + getDragonKingAnalysis, + getDivingAnalysis, + getCheckInAnalysis, + getMemeBattleAnalysis, + getNightOwlAnalysis, + getRepeatAnalysis, +} from '../ranking' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '../../../interfaces' + +class Stmt implements PreparedStatement { + readonly?: boolean + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } + get(...p: unknown[]) { + return this.stmt.get(...p) as Record | undefined + } + all(...p: unknown[]) { + return this.stmt.all(...p) as Record[] + } + run(...p: unknown[]): RunResult { + const r = this.stmt.run(...p) + return { changes: r.changes, lastInsertRowid: r.lastInsertRowid } + } +} + +class Adapter implements DatabaseAdapter { + constructor(private db: Database.Database) {} + exec(sql: string) { + this.db.exec(sql) + } + prepare(sql: string) { + return new Stmt(this.db.prepare(sql)) + } + transaction(fn: () => T): T { + return this.db.transaction(fn)() + } + pragma(p: string) { + return this.db.pragma(p) + } + close() { + this.db.close() + } +} + +interface Member { + id: number + name: string + system?: boolean +} +interface Msg { + id: number + senderId: number + ts: number + type?: number + content?: string | null +} + +function makeDb(members: Member[], messages: Msg[]): Adapter { + const raw = new Database(':memory:') + raw.exec(` + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT, account_name TEXT, group_nickname TEXT, avatar TEXT); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + `) + const mIns = raw.prepare('INSERT INTO member (id, platform_id, account_name, group_nickname) VALUES (?, ?, ?, ?)') + for (const m of members) { + mIns.run(m.id, `u${m.id}`, m.system ? '系统消息' : m.name, null) + } + const ins = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)') + for (const msg of messages) { + ins.run(msg.id, msg.senderId, msg.ts, msg.type ?? 0, msg.content ?? 'x') + } + return new Adapter(raw) +} + +const DAY1 = 1704067200 // 2024-01-01 00:00:00 UTC +const DAY2 = DAY1 + 86400 // 2024-01-02 +const DAY3 = DAY2 + 86400 // 2024-01-03 + +describe('ranking analytics (ported algorithms)', () => { + it('getDragonKingAnalysis ranks daily top sender', () => { + // A 两天都是发言最多 -> dragon_days=2;B 从未夺冠 -> 不进榜 + const db = makeDb( + [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ], + [ + { id: 1, senderId: 1, ts: DAY1 + 10 }, + { id: 2, senderId: 1, ts: DAY1 + 20 }, + { id: 3, senderId: 1, ts: DAY1 + 30 }, + { id: 4, senderId: 2, ts: DAY1 + 40 }, + { id: 5, senderId: 1, ts: DAY2 + 10 }, + { id: 6, senderId: 1, ts: DAY2 + 20 }, + { id: 7, senderId: 2, ts: DAY2 + 30 }, + ] + ) + const res = getDragonKingAnalysis(db) + assert.equal(res.totalDays, 2) + assert.equal(res.rank.length, 1) + assert.equal(res.rank[0].memberId, 1) + assert.equal(res.rank[0].count, 2) + assert.equal(res.rank[0].percentage, 100) + }) + + it('getDivingAnalysis orders by last message ascending', () => { + const db = makeDb( + [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ], + [ + { id: 1, senderId: 1, ts: DAY3 + 5 }, + { id: 2, senderId: 2, ts: DAY1 + 5 }, + ] + ) + const res = getDivingAnalysis(db) + assert.equal(res.rank.length, 2) + assert.equal(res.rank[0].memberId, 2) // B 最久没发言,排最前 + assert.equal(res.rank[0].lastMessageTs, DAY1 + 5) + assert.equal(res.rank[1].memberId, 1) + }) + + it('getCheckInAnalysis computes max/current streak and loyalty', () => { + // A: 01-01,02,03 连续 streak3;B: 01-01,03 有断层 streak1 + const db = makeDb( + [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ], + [ + { id: 1, senderId: 1, ts: DAY1 + 5 }, + { id: 2, senderId: 1, ts: DAY2 + 5 }, + { id: 3, senderId: 1, ts: DAY3 + 5 }, + { id: 4, senderId: 2, ts: DAY1 + 6 }, + { id: 5, senderId: 2, ts: DAY3 + 6 }, + ] + ) + const res = getCheckInAnalysis(db) + assert.equal(res.totalDays, 3) + const a = res.streakRank.find((r) => r.memberId === 1)! + const b = res.streakRank.find((r) => r.memberId === 2)! + assert.equal(a.maxStreak, 3) + assert.equal(a.currentStreak, 3) + assert.equal(b.maxStreak, 1) + assert.equal(b.currentStreak, 1) + const la = res.loyaltyRank.find((r) => r.memberId === 1)! + const lb = res.loyaltyRank.find((r) => r.memberId === 2)! + assert.equal(la.totalDays, 3) + assert.equal(la.percentage, 100) + assert.equal(lb.totalDays, 2) + assert.equal(lb.percentage, 67) + }) + + it('getMemeBattleAnalysis detects image chains with >=3 msgs and >=2 senders', () => { + // A,B,A 连续图片 (type 1) -> 一场斗图;C 文字打断 + const db = makeDb( + [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + { id: 3, name: 'C' }, + ], + [ + { id: 1, senderId: 1, ts: DAY1 + 1, type: 1 }, + { id: 2, senderId: 2, ts: DAY1 + 2, type: 1 }, + { id: 3, senderId: 1, ts: DAY1 + 3, type: 5 }, + { id: 4, senderId: 3, ts: DAY1 + 4, type: 0 }, + ] + ) + const res = getMemeBattleAnalysis(db) + assert.equal(res.totalBattles, 1) + assert.equal(res.topBattles[0].totalImages, 3) + assert.equal(res.topBattles[0].participantCount, 2) + // A 发 2 张图,B 发 1 张 + assert.equal(res.rankByImageCount[0].memberId, 1) + assert.equal(res.rankByImageCount[0].count, 2) + const aCount = res.rankByCount.find((r) => r.memberId === 1)! + assert.equal(aCount.count, 1) // 参与 1 场 + }) + + it('getNightOwlAnalysis counts late-night messages per member', () => { + // 同一成员 23 点 / 次日 0 点 / 次日 1 点 各一条,调整后同属一天 + const db = makeDb( + [{ id: 1, name: 'A' }], + [ + { id: 1, senderId: 1, ts: DAY1 + 23 * 3600 }, // 23:00 + { id: 2, senderId: 1, ts: DAY2 + 0 * 3600 + 60 }, // 次日 00:01 + { id: 3, senderId: 1, ts: DAY2 + 1 * 3600 }, // 次日 01:00 + ] + ) + const res = getNightOwlAnalysis(db) + assert.equal(res.totalDays, 1) + assert.equal(res.nightOwlRank.length, 1) + const owl = res.nightOwlRank[0] + assert.equal(owl.totalNightMessages, 3) + assert.equal(owl.title, '偶尔失眠') + assert.deepEqual(owl.hourlyBreakdown, { h23: 1, h0: 1, h1: 1, h2: 0, h3to4: 0 }) + }) + + it('getRepeatAnalysis detects repeat chains with originator/initiator/breaker', () => { + // A,B,C 复读 "haha" 形成长度 3 的链,D 用 "bye" 打断 + const db = makeDb( + [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + { id: 3, name: 'C' }, + { id: 4, name: 'D' }, + ], + [ + { id: 1, senderId: 1, ts: DAY1 + 1, content: 'haha' }, + { id: 2, senderId: 2, ts: DAY1 + 2, content: 'haha' }, + { id: 3, senderId: 3, ts: DAY1 + 3, content: 'haha' }, + { id: 4, senderId: 4, ts: DAY1 + 4, content: 'bye' }, + ] + ) + const res = getRepeatAnalysis(db) + assert.equal(res.totalRepeatChains, 1) + assert.equal(res.avgChainLength, 3) + assert.deepEqual(res.chainLengthDistribution, [{ length: 3, count: 1 }]) + assert.equal(res.originators[0].memberId, 1) + assert.equal(res.initiators[0].memberId, 2) + assert.equal(res.breakers[0].memberId, 4) + assert.equal(res.hotContents[0].content, 'haha') + assert.equal(res.hotContents[0].maxChainLength, 3) + }) + + it('excludes system messages for system-filtered analyses', () => { + // 系统消息成员不应进入龙王榜 + const db = makeDb( + [ + { id: 1, name: 'A' }, + { id: 99, name: 'sys', system: true }, + ], + [ + { id: 1, senderId: 1, ts: DAY1 + 1 }, + { id: 2, senderId: 99, ts: DAY1 + 2 }, + { id: 3, senderId: 99, ts: DAY1 + 3 }, + ] + ) + const res = getDragonKingAnalysis(db) + assert.equal(res.rank.length, 1) + assert.equal(res.rank[0].memberId, 1) + }) +}) diff --git a/packages/core/src/query/advanced/__tests__/social.test.ts b/packages/core/src/query/advanced/__tests__/social.test.ts new file mode 100644 index 000000000..bca307cc5 --- /dev/null +++ b/packages/core/src/query/advanced/__tests__/social.test.ts @@ -0,0 +1,48 @@ +/** + * Tests for shared social graph helpers. + * + * Run: pnpm test -- packages/core/src/query/advanced/__tests__/social.test.ts + */ + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { accumulateCoOccurrencePairs } from '../social' + +describe('social graph helpers', () => { + it('tracks the latest timestamp for each co-occurrence pair', () => { + const pairs = accumulateCoOccurrencePairs([ + { senderId: 1, ts: 1704103200 }, + { senderId: 2, ts: 1704103260 }, + { senderId: 1, ts: 1704103320 }, + { senderId: 2, ts: 1704103380 }, + { senderId: 3, ts: 1704107000 }, + ]) + + const ownerAlice = pairs.find((pair) => pair.sourceId === 1 && pair.targetId === 2) + + assert.ok(ownerAlice) + assert.equal(ownerAlice.lastOccurrenceTs, 1704103380) + }) + + it('uses unix seconds directly for co-occurrence decay', () => { + const closePair = accumulateCoOccurrencePairs( + [ + { senderId: 1, ts: 1704103200 }, + { senderId: 2, ts: 1704103260 }, + ], + { decaySeconds: 120 } + )[0] + const distantPair = accumulateCoOccurrencePairs( + [ + { senderId: 1, ts: 1704103200 }, + { senderId: 2, ts: 1704106800 }, + ], + { decaySeconds: 120 } + )[0] + + assert.ok(closePair) + assert.ok(distantPair) + assert.ok(closePair.rawScore > 0.6) + assert.ok(distantPair.rawScore < 0.001) + }) +}) diff --git a/packages/core/src/query/advanced/index.ts b/packages/core/src/query/advanced/index.ts new file mode 100644 index 000000000..c678e0a4a --- /dev/null +++ b/packages/core/src/query/advanced/index.ts @@ -0,0 +1,67 @@ +/** + * 高级分析模块入口(平台无关) + * + * 所有函数接收 DatabaseAdapter 参数,不依赖全局状态或特定运行时。 + */ + +export { getCatchphraseAnalysis } from './repeat' +export type { CatchphraseAnalysis, MemberCatchphrase, CatchphraseItem } from './repeat' + +export { getMentionAnalysis, getMentionGraph, getLaughAnalysis, getClusterGraph } from './social' +export type { + MentionGraphData, + MentionGraphNode, + MentionGraphLink, + ClusterGraphData, + ClusterGraphNode, + ClusterGraphLink, + ClusterGraphOptions, +} from './social' + +export { getRelationshipStats } from './relationship' +export type { + RelationshipStats, + RelationshipMonthStats, + IceBreakerItem, + ResponseLatencyMember, + PerseveranceMember, + MonthlyResponseLatency, + MonthlyPerseverance, + RelationshipOptions, +} from './relationship' + +export { getLanguagePreferenceAnalysis } from './languagePreference' +export type { NlpProvider, PosTagResult, LanguagePreferenceParams } from './languagePreference' + +export { + getDragonKingAnalysis, + getDivingAnalysis, + getCheckInAnalysis, + getMemeBattleAnalysis, + getNightOwlAnalysis, + getRepeatAnalysis, +} from './ranking' +export type { + NightOwlTitle, + NightOwlRankItem, + TimeRankItem, + ConsecutiveNightRecord, + NightOwlChampion, + NightOwlAnalysis, + DragonKingRankItem, + DragonKingAnalysis, + DivingRankItem, + DivingAnalysis, + RepeatStatItem, + RepeatRateItem, + ChainLengthDistribution, + HotRepeatContent, + FastestRepeaterItem, + RepeatAnalysis, + MemeBattleRankItem, + MemeBattleRecord, + MemeBattleAnalysis, + StreakRankItem, + LoyaltyRankItem, + CheckInAnalysis, +} from './ranking' diff --git a/packages/core/src/query/advanced/languagePreference.ts b/packages/core/src/query/advanced/languagePreference.ts new file mode 100644 index 000000000..735a49a76 --- /dev/null +++ b/packages/core/src/query/advanced/languagePreference.ts @@ -0,0 +1,248 @@ +/** + * 语言偏好分析模块(私聊专用,平台无关) + * + * NLP 能力通过 NlpProvider 接口注入,不直接依赖 @node-rs/jieba。 + * 调用方负责提供对应平台的实现(Electron / Server 用 jieba,浏览器可降级)。 + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '../../interfaces' +import { buildTimeFilter } from '../filters' +import { isSystemPlaceholderContent } from './text-filters' + +// ==================== NLP Provider 接口 ==================== + +export interface PosTagResult { + word: string + tag: string +} + +export interface NlpProvider { + tag(text: string): PosTagResult[] + isStopword(word: string, locale: string): boolean + meaningfulPosTags: Set +} + +// ==================== 内部常量 ==================== + +const RE_ELLIPSIS = /\.{2,}|…+|。{2,}/g +const RE_EXCLAMATION = /[!!]+/g +const RE_QUESTION = /[??]+/g +const RE_TILDE = /[~~]+/g +const RE_PERIOD = /[.。](?![.。])/g +const RE_ENDS_WITH_PUNCT = /[.。!!??~~…,,;;::、)\])】》"'」』\-—]$/ + +const NOUN_TAGS = new Set(['n', 'nr', 'ns', 'nt', 'nz', 'nw']) +const VERB_TAGS = new Set(['v', 'vn', 'vd', 'vg']) +const ADJ_TAGS = new Set(['a', 'an', 'ad', 'ag']) +const ADV_TAGS = new Set(['d']) +const MODAL_TAGS = new Set(['y', 'e']) + +const RE_URL = /https?:\/\/[^\s]+/g +const RE_MENTION = /@[^\s@]+/g +const RE_EMOJI = + /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu +const RE_PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~,。!?、;:""''()【】《》…—~·\s]/g +const RE_PURE_NUMBER = /^\d+$/ + +function cleanTextForNlp(text: string): string { + return text + .replace(RE_URL, ' ') + .replace(RE_MENTION, ' ') + .replace(RE_EMOJI, ' ') + .replace(RE_PUNCTUATION, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function countMatches(text: string, regex: RegExp): number { + regex.lastIndex = 0 + const m = text.match(regex) + return m ? m.length : 0 +} + +function cosineSimilarity(a: number[], b: number[]): number { + let dot = 0, + magA = 0, + magB = 0 + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i] + magA += a[i] * a[i] + magB += b[i] * b[i] + } + const denom = Math.sqrt(magA) * Math.sqrt(magB) + return denom === 0 ? 0 : dot / denom +} + +// ==================== 主入口 ==================== + +export interface LanguagePreferenceParams { + locale: string + timeFilter?: TimeFilter + nlpProvider?: NlpProvider +} + +export function getLanguagePreferenceAnalysis(db: DatabaseAdapter, params: LanguagePreferenceParams): any { + const { locale, timeFilter, nlpProvider } = params + + const { clause, params: filterParams } = buildTimeFilter(timeFilter) + let whereClause = clause + const textFilter = + " COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2" + if (whereClause.includes('WHERE')) { + whereClause += ' AND ' + textFilter + } else { + whereClause = ' WHERE ' + textFilter + } + + const rows = db + .prepare( + `SELECT m.id as memberId, COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, msg.content as content + FROM message msg JOIN member m ON msg.sender_id = m.id ${whereClause} ORDER BY m.id` + ) + .all(...filterParams) as Array<{ memberId: number; name: string; content: string }> + + if (rows.length === 0) return { members: [], sharedWords: [], similarityScore: 0 } + + const memberMessages = new Map() + for (const row of rows) { + if (isSystemPlaceholderContent(row.content)) continue + + let entry = memberMessages.get(row.memberId) + if (!entry) { + entry = { name: row.name, messages: [] } + memberMessages.set(row.memberId, entry) + } + entry.messages.push(row.content) + } + + const isChinese = locale.startsWith('zh') + const minWordLength = isChinese ? 2 : 3 + + const memberProfiles: any[] = [] + + for (const [memberId, { name, messages }] of memberMessages) { + const wordFreq = new Map() + const posCount = { noun: 0, verb: 0, adjective: 0, adverb: 0, modalParticle: 0, interjection: 0, other: 0 } + const modalFreq = new Map() + let totalWordCount = 0 + const punct = { ellipsis: 0, exclamation: 0, question: 0, tilde: 0, period: 0, noPunct: 0, total: 0 } + const phraseFreq = new Map() + + for (const content of messages) { + punct.ellipsis += countMatches(content, RE_ELLIPSIS) + punct.exclamation += countMatches(content, RE_EXCLAMATION) + punct.question += countMatches(content, RE_QUESTION) + punct.tilde += countMatches(content, RE_TILDE) + punct.period += countMatches(content, RE_PERIOD) + const trimmed = content.trim() + + if (trimmed.length > 0 && !RE_ENDS_WITH_PUNCT.test(trimmed)) punct.noPunct++ + punct.total++ + + if (trimmed.length >= 2) phraseFreq.set(trimmed, (phraseFreq.get(trimmed) || 0) + 1) + + const cleaned = cleanTextForNlp(content) + if (!cleaned) continue + + if (isChinese && nlpProvider) { + try { + const tagged = nlpProvider.tag(cleaned) + for (const { word, tag } of tagged) { + if (!word || word.trim().length === 0 || RE_PURE_NUMBER.test(word)) continue + if (word.length < minWordLength && !MODAL_TAGS.has(tag)) continue + + if (NOUN_TAGS.has(tag)) posCount.noun++ + else if (VERB_TAGS.has(tag)) posCount.verb++ + else if (ADJ_TAGS.has(tag)) posCount.adjective++ + else if (ADV_TAGS.has(tag)) posCount.adverb++ + else if (tag === 'y') posCount.modalParticle++ + else if (tag === 'e') posCount.interjection++ + else posCount.other++ + + if (MODAL_TAGS.has(tag)) modalFreq.set(word, (modalFreq.get(word) || 0) + 1) + + if (nlpProvider.meaningfulPosTags.has(tag) || MODAL_TAGS.has(tag)) { + if (!nlpProvider.isStopword(word, locale)) { + wordFreq.set(word, (wordFreq.get(word) || 0) + 1) + totalWordCount++ + } + } + } + } catch { + /* jieba failure — skip */ + } + } else { + try { + const segmenter = new Intl.Segmenter(locale, { granularity: 'word' }) + for (const seg of segmenter.segment(cleaned)) { + if (!seg.isWordLike) continue + const w = seg.segment.toLowerCase() + if (w.length < minWordLength || RE_PURE_NUMBER.test(w)) continue + if (nlpProvider?.isStopword(w, locale)) continue + wordFreq.set(w, (wordFreq.get(w) || 0) + 1) + totalWordCount++ + posCount.other++ + } + } catch { + /* fallback */ + } + } + } + + const filteredWords = [...wordFreq.entries()].filter(([, c]) => c >= 2).sort((a, b) => b[1] - a[1]) + const uniqueWords = filteredWords.length + const topWords = filteredWords.slice(0, 100).map(([word, count]) => ({ word, count })) + const lexicalDiversity = totalWordCount > 0 ? Math.round((uniqueWords / totalWordCount) * 10000) / 100 : 0 + + const modalParticles = [...modalFreq.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([word, count]) => ({ word, count })) + const catchphrases = [...phraseFreq.entries()] + .filter(([, c]) => c >= 2) + .sort((a, b) => b[1] - a[1]) + .slice(0, 50) + .map(([content, count]) => ({ content, count })) + + memberProfiles.push({ + memberId, + name, + totalMessages: messages.length, + totalWords: totalWordCount, + uniqueWords, + lexicalDiversity, + topWords, + posDistribution: posCount, + modalParticles, + punctuation: punct, + catchphrases, + }) + } + + memberProfiles.sort((a, b) => b.totalMessages - a.totalMessages) + + let sharedWords: any[] = [] + let similarityScore = 0 + + if (memberProfiles.length >= 2) { + const a = memberProfiles[0] + const b = memberProfiles[1] + const wordsA = new Map(a.topWords.map((w: any) => [w.word, w.count])) + const wordsB = new Map(b.topWords.map((w: any) => [w.word, w.count])) + const shared: Array<{ word: string; countA: number; countB: number }> = [] + for (const [word, countA] of wordsA) { + const countB = wordsB.get(word) + if (countB) shared.push({ word, countA, countB }) + } + shared.sort((x, y) => y.countA + y.countB - (x.countA + x.countB)) + sharedWords = shared.slice(0, 30) + + const posKeys = ['noun', 'verb', 'adjective', 'adverb', 'modalParticle', 'interjection', 'other'] as const + const vecA = posKeys.map((k) => a.posDistribution[k] as number) + const vecB = posKeys.map((k) => b.posDistribution[k] as number) + similarityScore = Math.round(cosineSimilarity(vecA, vecB) * 100) + } + + return { members: memberProfiles, sharedWords, similarityScore } +} diff --git a/packages/core/src/query/advanced/ranking.ts b/packages/core/src/query/advanced/ranking.ts new file mode 100644 index 000000000..4eeda9808 --- /dev/null +++ b/packages/core/src/query/advanced/ranking.ts @@ -0,0 +1,1095 @@ +/** + * 榜单分析模块(平台无关) + * + * 包含:龙王、潜水、打卡、斗图、夜猫、复读分析。 + * 算法从原前端 pluginCompute 移植,行为与现状保持一致(含原有时区/边界处理)。 + * 所有函数接收 DatabaseAdapter,不依赖全局状态。 + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '../../interfaces' + +// ==================== 类型定义 ==================== + +export type NightOwlTitle = '养生达人' | '偶尔失眠' | '经常失眠' | '夜猫子' | '秃头预备役' | '修仙练习生' | '守夜冠军' + +export interface NightOwlRankItem { + memberId: number + platformId: string + name: string + totalNightMessages: number + title: NightOwlTitle + hourlyBreakdown: { + h23: number + h0: number + h1: number + h2: number + h3to4: number + } + percentage: number +} + +export interface TimeRankItem { + memberId: number + platformId: string + name: string + count: number + avgTime: string + extremeTime: string + percentage: number +} + +export interface ConsecutiveNightRecord { + memberId: number + platformId: string + name: string + maxConsecutiveDays: number + currentStreak: number +} + +export interface NightOwlChampion { + memberId: number + platformId: string + name: string + score: number + nightMessages: number + lastSpeakerCount: number + consecutiveDays: number +} + +export interface NightOwlAnalysis { + nightOwlRank: NightOwlRankItem[] + lastSpeakerRank: TimeRankItem[] + firstSpeakerRank: TimeRankItem[] + consecutiveRecords: ConsecutiveNightRecord[] + champions: NightOwlChampion[] + totalDays: number +} + +export interface DragonKingRankItem { + memberId: number + platformId: string + name: string + count: number + percentage: number +} + +export interface DragonKingAnalysis { + rank: DragonKingRankItem[] + totalDays: number +} + +export interface DivingRankItem { + memberId: number + platformId: string + name: string + lastMessageTs: number + daysSinceLastMessage: number +} + +export interface DivingAnalysis { + rank: DivingRankItem[] +} + +export interface RepeatStatItem { + memberId: number + platformId: string + name: string + count: number + percentage: number +} + +export interface RepeatRateItem { + memberId: number + platformId: string + name: string + count: number + totalMessages: number + rate: number +} + +export interface ChainLengthDistribution { + length: number + count: number +} + +export interface HotRepeatContent { + content: string + count: number + maxChainLength: number + originatorName: string + lastTs: number + firstMessageId: number +} + +export interface FastestRepeaterItem { + memberId: number + platformId: string + name: string + count: number + avgTimeDiff: number +} + +export interface RepeatAnalysis { + originators: RepeatStatItem[] + initiators: RepeatStatItem[] + breakers: RepeatStatItem[] + fastestRepeaters: FastestRepeaterItem[] + originatorRates: RepeatRateItem[] + initiatorRates: RepeatRateItem[] + breakerRates: RepeatRateItem[] + chainLengthDistribution: ChainLengthDistribution[] + hotContents: HotRepeatContent[] + avgChainLength: number + totalRepeatChains: number +} + +export interface MemeBattleRankItem { + memberId: number + platformId: string + name: string + count: number + percentage: number +} + +export interface MemeBattleRecord { + startTime: number + endTime: number + totalImages: number + participantCount: number + participants: Array<{ + memberId: number + name: string + imageCount: number + }> +} + +export interface MemeBattleAnalysis { + topBattles: MemeBattleRecord[] + rankByCount: MemeBattleRankItem[] + rankByImageCount: MemeBattleRankItem[] + totalBattles: number +} + +export interface StreakRankItem { + memberId: number + name: string + maxStreak: number + maxStreakStart: string + maxStreakEnd: string + currentStreak: number +} + +export interface LoyaltyRankItem { + memberId: number + name: string + totalDays: number + percentage: number +} + +export interface CheckInAnalysis { + streakRank: StreakRankItem[] + loyaltyRank: LoyaltyRankItem[] + totalDays: number +} + +// ==================== 过滤条件构建 ==================== + +/** 系统消息过滤条件(始终排除),与前端历史实现保持一致 */ +const SYSTEM_FILTER = "AND COALESCE(m.account_name, '') != '系统消息'" + +/** 构建 AND 形式的时间/成员过滤条件(msg 别名),与原前端 SQL 保持等价 */ +function buildFilter(filter?: TimeFilter): { conditions: string; params: (number | string)[] } { + const parts: string[] = [] + const params: (number | string)[] = [] + if (filter?.startTs != null) { + parts.push('AND msg.ts >= ?') + params.push(filter.startTs) + } + if (filter?.endTs != null) { + parts.push('AND msg.ts <= ?') + params.push(filter.endTs) + } + if (filter?.memberId != null) { + parts.push('AND msg.sender_id = ?') + params.push(filter.memberId) + } + return { conditions: parts.join(' '), params } +} + +// ==================== 龙王分析 ==================== + +export function getDragonKingAnalysis(db: DatabaseAdapter, filter?: TimeFilter): DragonKingAnalysis { + const { conditions, params } = buildFilter(filter) + + const rankRows = db + .prepare( + `WITH daily_counts AS ( + SELECT + strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date, + msg.sender_id, + m.platform_id, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + COUNT(*) as msg_count + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE 1=1 ${SYSTEM_FILTER} ${conditions} + GROUP BY date, msg.sender_id + ), + daily_max AS ( + SELECT date, MAX(msg_count) as max_count + FROM daily_counts + GROUP BY date + ) + SELECT dc.sender_id, dc.platform_id, dc.name, COUNT(*) as dragon_days + FROM daily_counts dc + JOIN daily_max dm ON dc.date = dm.date AND dc.msg_count = dm.max_count + GROUP BY dc.sender_id + ORDER BY dragon_days DESC` + ) + .all(...params) as Array<{ sender_id: number; platform_id: string; name: string; dragon_days: number }> + + const totalRow = db + .prepare( + `SELECT COUNT(DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime')) as total + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE 1=1 ${SYSTEM_FILTER} ${conditions}` + ) + .get(...params) as { total: number } | undefined + + const totalDays = totalRow?.total ?? 0 + const rank: DragonKingRankItem[] = rankRows.map((item) => ({ + memberId: item.sender_id, + platformId: item.platform_id, + name: item.name, + count: item.dragon_days, + percentage: totalDays > 0 ? Math.round((item.dragon_days / totalDays) * 10000) / 100 : 0, + })) + + return { rank, totalDays } +} + +// ==================== 潜水分析 ==================== + +export function getDivingAnalysis(db: DatabaseAdapter, filter?: TimeFilter): DivingAnalysis { + const { conditions, params } = buildFilter(filter) + + const rows = db + .prepare( + `SELECT + m.id as member_id, + m.platform_id, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + MAX(msg.ts) as last_ts + FROM member m + JOIN message msg ON m.id = msg.sender_id + WHERE 1=1 ${SYSTEM_FILTER} ${conditions} + GROUP BY m.id + ORDER BY last_ts ASC` + ) + .all(...params) as Array<{ member_id: number; platform_id: string; name: string; last_ts: number }> + + const now = Math.floor(Date.now() / 1000) + const rank: DivingRankItem[] = rows.map((item) => ({ + memberId: item.member_id, + platformId: item.platform_id, + name: item.name, + lastMessageTs: item.last_ts, + daysSinceLastMessage: Math.floor((now - item.last_ts) / 86400), + })) + + return { rank } +} + +// ==================== 打卡分析 ==================== + +function computeCheckIn(dailyActivity: Array<{ senderId: number; name: string; day: string }>): CheckInAnalysis { + if (dailyActivity.length === 0) { + return { streakRank: [], loyaltyRank: [], totalDays: 0 } + } + + const allDays = new Set(dailyActivity.map((r) => r.day)) + const totalDays = allDays.size + const sortedDays = Array.from(allDays).sort() + const lastDay = sortedDays[sortedDays.length - 1] + + const memberDays = new Map }>() + for (const record of dailyActivity) { + if (!memberDays.has(record.senderId)) { + memberDays.set(record.senderId, { name: record.name, days: new Set() }) + } + memberDays.get(record.senderId)!.days.add(record.day) + } + + const streakData: StreakRankItem[] = [] + const loyaltyData: Array<{ memberId: number; name: string; totalDays: number }> = [] + + for (const [memberId, data] of memberDays) { + const sortedMemberDays = Array.from(data.days).sort() + const totalMemberDays = sortedMemberDays.length + + let maxStreak = 1 + let maxStreakStart = sortedMemberDays[0] + let maxStreakEnd = sortedMemberDays[0] + let currentStreakCount = 1 + let currentStreakStart = sortedMemberDays[0] + + for (let i = 1; i < sortedMemberDays.length; i++) { + const prevDate = new Date(sortedMemberDays[i - 1]) + const currDate = new Date(sortedMemberDays[i]) + const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)) + + if (diffDays === 1) { + currentStreakCount++ + } else { + if (currentStreakCount > maxStreak) { + maxStreak = currentStreakCount + maxStreakStart = currentStreakStart + maxStreakEnd = sortedMemberDays[i - 1] + } + currentStreakCount = 1 + currentStreakStart = sortedMemberDays[i] + } + } + + if (currentStreakCount > maxStreak) { + maxStreak = currentStreakCount + maxStreakStart = currentStreakStart + maxStreakEnd = sortedMemberDays[sortedMemberDays.length - 1] + } + + let finalCurrentStreak = 0 + if (sortedMemberDays[sortedMemberDays.length - 1] === lastDay) { + finalCurrentStreak = 1 + for (let i = sortedMemberDays.length - 2; i >= 0; i--) { + const currDate = new Date(sortedMemberDays[i + 1]) + const prevDate = new Date(sortedMemberDays[i]) + const diffDays = Math.round((currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24)) + if (diffDays === 1) { + finalCurrentStreak++ + } else { + break + } + } + } + + streakData.push({ + memberId, + name: data.name, + maxStreak, + maxStreakStart, + maxStreakEnd, + currentStreak: finalCurrentStreak, + }) + + loyaltyData.push({ + memberId, + name: data.name, + totalDays: totalMemberDays, + }) + } + + const streakRank = streakData.sort((a, b) => b.maxStreak - a.maxStreak) + const sortedLoyalty = loyaltyData.sort((a, b) => b.totalDays - a.totalDays) + const maxLoyaltyDays = sortedLoyalty.length > 0 ? sortedLoyalty[0].totalDays : 1 + const loyaltyRank: LoyaltyRankItem[] = sortedLoyalty.map((item) => ({ + ...item, + percentage: Math.round((item.totalDays / maxLoyaltyDays) * 100), + })) + + return { streakRank, loyaltyRank, totalDays } +} + +export function getCheckInAnalysis(db: DatabaseAdapter, filter?: TimeFilter): CheckInAnalysis { + const { conditions, params } = buildFilter(filter) + + const dailyActivity = db + .prepare( + `SELECT + msg.sender_id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + DATE(msg.ts, 'unixepoch', 'localtime') as day + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE 1=1 ${SYSTEM_FILTER} ${conditions} + GROUP BY msg.sender_id, day + ORDER BY msg.sender_id, day` + ) + .all(...params) as Array<{ senderId: number; name: string; day: string }> + + if (dailyActivity.length === 0) { + return { streakRank: [], loyaltyRank: [], totalDays: 0 } + } + + return computeCheckIn(dailyActivity) +} + +// ==================== 斗图分析 ==================== + +function computeMemeBattle( + messages: Array<{ senderId: number; type: number; ts: number; platformId: string; name: string }> +): MemeBattleAnalysis { + const emptyResult: MemeBattleAnalysis = { + topBattles: [], + rankByCount: [], + rankByImageCount: [], + totalBattles: 0, + } + + const battles: Array<{ + startTime: number + endTime: number + msgs: Array<{ senderId: number; name: string; platformId: string }> + }> = [] + + let currentChain: Array<{ senderId: number; name: string; platformId: string; ts: number }> = [] + + const processChain = () => { + if (currentChain.length >= 3) { + const senders = new Set(currentChain.map((m) => m.senderId)) + if (senders.size >= 2) { + battles.push({ + startTime: currentChain[0].ts, + endTime: currentChain[currentChain.length - 1].ts, + msgs: currentChain.map(({ senderId, name, platformId }) => ({ senderId, name, platformId })), + }) + } + } + currentChain = [] + } + + for (const msg of messages) { + if (msg.type === 1 || msg.type === 5) { + currentChain.push({ + senderId: msg.senderId, + name: msg.name, + platformId: msg.platformId, + ts: msg.ts, + }) + } else { + processChain() + } + } + processChain() + + if (battles.length === 0) return emptyResult + + const topBattles: MemeBattleRecord[] = battles + .map((battle) => ({ + startTime: battle.startTime, + endTime: battle.endTime, + totalImages: battle.msgs.length, + participantCount: new Set(battle.msgs.map((m) => m.senderId)).size, + participants: Object.values( + battle.msgs.reduce((acc: Record, curr) => { + if (!acc[curr.senderId]) { + acc[curr.senderId] = { memberId: curr.senderId, name: curr.name, imageCount: 0 } + } + acc[curr.senderId].imageCount++ + return acc + }, {}) + ).sort((a, b) => b.imageCount - a.imageCount), + })) + .sort((a, b) => b.totalImages - a.totalImages) + .slice(0, 30) + + const memberStats = new Map< + number, + { memberId: number; platformId: string; name: string; battleCount: number; imageCount: number } + >() + + for (const battle of battles) { + const participantsInBattle = new Set() + for (const msg of battle.msgs) { + if (!memberStats.has(msg.senderId)) { + memberStats.set(msg.senderId, { + memberId: msg.senderId, + platformId: msg.platformId, + name: msg.name, + battleCount: 0, + imageCount: 0, + }) + } + memberStats.get(msg.senderId)!.imageCount++ + participantsInBattle.add(msg.senderId) + } + for (const memberId of participantsInBattle) { + memberStats.get(memberId)!.battleCount++ + } + } + + const allStats = Array.from(memberStats.values()) + + const rankByCount: MemeBattleRankItem[] = [...allStats] + .sort((a, b) => b.battleCount - a.battleCount) + .map((item) => ({ + memberId: item.memberId, + platformId: item.platformId, + name: item.name, + count: item.battleCount, + percentage: battles.length > 0 ? Math.round((item.battleCount / battles.length) * 10000) / 100 : 0, + })) + + const totalBattleImages = battles.reduce((sum, b) => sum + b.msgs.length, 0) + const rankByImageCount: MemeBattleRankItem[] = [...allStats] + .sort((a, b) => b.imageCount - a.imageCount) + .map((item) => ({ + memberId: item.memberId, + platformId: item.platformId, + name: item.name, + count: item.imageCount, + percentage: totalBattleImages > 0 ? Math.round((item.imageCount / totalBattleImages) * 10000) / 100 : 0, + })) + + return { + topBattles, + rankByCount, + rankByImageCount, + totalBattles: battles.length, + } +} + +export function getMemeBattleAnalysis(db: DatabaseAdapter, filter?: TimeFilter): MemeBattleAnalysis { + // 斗图分析过滤条件与众不同:不排除系统消息,仅排除 type=6(链接) + const { conditions, params } = buildFilter(filter) + + const messages = db + .prepare( + `SELECT + msg.sender_id as senderId, + msg.type, + msg.ts, + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.type != 6 ${conditions} + ORDER BY msg.ts ASC` + ) + .all(...params) as Array<{ senderId: number; type: number; ts: number; platformId: string; name: string }> + + if (messages.length === 0) { + return { topBattles: [], rankByCount: [], rankByImageCount: [], totalBattles: 0 } + } + + return computeMemeBattle(messages) +} + +// ==================== 夜猫分析 ==================== + +function computeNightOwl( + messages: Array<{ id: number; senderId: number; ts: number; platformId: string; name: string }> +): NightOwlAnalysis { + const emptyResult: NightOwlAnalysis = { + nightOwlRank: [], + lastSpeakerRank: [], + firstSpeakerRank: [], + consecutiveRecords: [], + champions: [], + totalDays: 0, + } + + if (messages.length === 0) return emptyResult + + function getNightOwlTitleByCount(count: number): NightOwlTitle { + if (count === 0) return '养生达人' + if (count <= 20) return '偶尔失眠' + if (count <= 50) return '经常失眠' + if (count <= 100) return '夜猫子' + if (count <= 200) return '秃头预备役' + if (count <= 500) return '修仙练习生' + return '守夜冠军' + } + + function getAdjustedDate(ts: number): string { + const date = new Date(ts * 1000) + const hour = date.getHours() + if (hour < 5) { + date.setDate(date.getDate() - 1) + } + return date.toISOString().split('T')[0] + } + + function formatMinutes(minutes: number): string { + const h = Math.floor(minutes / 60) + const m = Math.round(minutes % 60) + return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}` + } + + const memberInfo = new Map() + const nightStats = new Map< + number, + { total: number; h23: number; h0: number; h1: number; h2: number; h3to4: number; totalMessages: number } + >() + const dailyMessages = new Map>() + const memberNightDays = new Map>() + + for (const msg of messages) { + if (!memberInfo.has(msg.senderId)) { + memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) + } + + const date = new Date(msg.ts * 1000) + const hour = date.getHours() + const minute = date.getMinutes() + const adjustedDate = getAdjustedDate(msg.ts) + + if (!nightStats.has(msg.senderId)) { + nightStats.set(msg.senderId, { total: 0, h23: 0, h0: 0, h1: 0, h2: 0, h3to4: 0, totalMessages: 0 }) + } + const stats = nightStats.get(msg.senderId)! + stats.totalMessages++ + + if (hour === 23) { + stats.h23++ + stats.total++ + } else if (hour === 0) { + stats.h0++ + stats.total++ + } else if (hour === 1) { + stats.h1++ + stats.total++ + } else if (hour === 2) { + stats.h2++ + stats.total++ + } else if (hour >= 3 && hour < 5) { + stats.h3to4++ + stats.total++ + } + + if (hour >= 23 || hour < 5) { + if (!memberNightDays.has(msg.senderId)) { + memberNightDays.set(msg.senderId, new Set()) + } + memberNightDays.get(msg.senderId)!.add(adjustedDate) + } + + if (!dailyMessages.has(adjustedDate)) { + dailyMessages.set(adjustedDate, []) + } + dailyMessages.get(adjustedDate)!.push({ senderId: msg.senderId, ts: msg.ts, hour, minute }) + } + + const totalDays = dailyMessages.size + + const nightOwlRank: NightOwlAnalysis['nightOwlRank'] = [] + for (const [memberId, stats] of nightStats.entries()) { + if (stats.total === 0) continue + const info = memberInfo.get(memberId)! + nightOwlRank.push({ + memberId, + platformId: info.platformId, + name: info.name, + totalNightMessages: stats.total, + title: getNightOwlTitleByCount(stats.total), + hourlyBreakdown: { h23: stats.h23, h0: stats.h0, h1: stats.h1, h2: stats.h2, h3to4: stats.h3to4 }, + percentage: stats.totalMessages > 0 ? Math.round((stats.total / stats.totalMessages) * 10000) / 100 : 0, + }) + } + nightOwlRank.sort((a, b) => b.totalNightMessages - a.totalNightMessages) + + const lastSpeakerStats = new Map() + const firstSpeakerStats = new Map() + + for (const [, dayMessages] of dailyMessages.entries()) { + if (dayMessages.length === 0) continue + const lastMsg = dayMessages[dayMessages.length - 1] + if (!lastSpeakerStats.has(lastMsg.senderId)) { + lastSpeakerStats.set(lastMsg.senderId, { count: 0, times: [] }) + } + const lastStats = lastSpeakerStats.get(lastMsg.senderId)! + lastStats.count++ + lastStats.times.push(lastMsg.hour * 60 + lastMsg.minute) + + const firstMsg = dayMessages[0] + if (!firstSpeakerStats.has(firstMsg.senderId)) { + firstSpeakerStats.set(firstMsg.senderId, { count: 0, times: [] }) + } + const firstStats = firstSpeakerStats.get(firstMsg.senderId)! + firstStats.count++ + firstStats.times.push(firstMsg.hour * 60 + firstMsg.minute) + } + + const lastSpeakerRank: TimeRankItem[] = [] + for (const [memberId, stats] of lastSpeakerStats.entries()) { + const info = memberInfo.get(memberId)! + const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length + const maxMinutes = Math.max(...stats.times) + lastSpeakerRank.push({ + memberId, + platformId: info.platformId, + name: info.name, + count: stats.count, + avgTime: formatMinutes(avgMinutes), + extremeTime: formatMinutes(maxMinutes), + percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0, + }) + } + lastSpeakerRank.sort((a, b) => b.count - a.count) + + const firstSpeakerRank: TimeRankItem[] = [] + for (const [memberId, stats] of firstSpeakerStats.entries()) { + const info = memberInfo.get(memberId)! + const avgMinutes = stats.times.reduce((a, b) => a + b, 0) / stats.times.length + const minMinutes = Math.min(...stats.times) + firstSpeakerRank.push({ + memberId, + platformId: info.platformId, + name: info.name, + count: stats.count, + avgTime: formatMinutes(avgMinutes), + extremeTime: formatMinutes(minMinutes), + percentage: totalDays > 0 ? Math.round((stats.count / totalDays) * 10000) / 100 : 0, + }) + } + firstSpeakerRank.sort((a, b) => b.count - a.count) + + const consecutiveRecords: ConsecutiveNightRecord[] = [] + for (const [memberId, nightDaysSet] of memberNightDays.entries()) { + if (nightDaysSet.size === 0) continue + const info = memberInfo.get(memberId)! + const sortedNightDays = Array.from(nightDaysSet).sort() + + let maxStreak = 1 + let currentStreak = 1 + for (let i = 1; i < sortedNightDays.length; i++) { + const prevDate = new Date(sortedNightDays[i - 1]) + const currDate = new Date(sortedNightDays[i]) + const diffDays = (currDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) + if (diffDays === 1) { + currentStreak++ + maxStreak = Math.max(maxStreak, currentStreak) + } else { + currentStreak = 1 + } + } + + const lastNightDay = sortedNightDays[sortedNightDays.length - 1] + const today = new Date().toISOString().split('T')[0] + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0] + const isCurrentStreak = lastNightDay === today || lastNightDay === yesterday + + consecutiveRecords.push({ + memberId, + platformId: info.platformId, + name: info.name, + maxConsecutiveDays: maxStreak, + currentStreak: isCurrentStreak ? currentStreak : 0, + }) + } + consecutiveRecords.sort((a, b) => b.maxConsecutiveDays - a.maxConsecutiveDays) + + const championScores = new Map() + for (const item of nightOwlRank) { + if (!championScores.has(item.memberId)) { + championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) + } + championScores.get(item.memberId)!.nightMessages = item.totalNightMessages + } + for (const item of lastSpeakerRank) { + if (!championScores.has(item.memberId)) { + championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) + } + championScores.get(item.memberId)!.lastSpeakerCount = item.count + } + for (const item of consecutiveRecords) { + if (!championScores.has(item.memberId)) { + championScores.set(item.memberId, { nightMessages: 0, lastSpeakerCount: 0, consecutiveDays: 0 }) + } + championScores.get(item.memberId)!.consecutiveDays = item.maxConsecutiveDays + } + + const champions: NightOwlChampion[] = [] + for (const [memberId, scores] of championScores.entries()) { + const info = memberInfo.get(memberId)! + const score = scores.nightMessages * 1 + scores.lastSpeakerCount * 10 + scores.consecutiveDays * 20 + if (score > 0) { + champions.push({ + memberId, + platformId: info.platformId, + name: info.name, + score, + nightMessages: scores.nightMessages, + lastSpeakerCount: scores.lastSpeakerCount, + consecutiveDays: scores.consecutiveDays, + }) + } + } + champions.sort((a, b) => b.score - a.score) + + return { nightOwlRank, lastSpeakerRank, firstSpeakerRank, consecutiveRecords, champions, totalDays } +} + +export function getNightOwlAnalysis(db: DatabaseAdapter, filter?: TimeFilter): NightOwlAnalysis { + const { conditions, params } = buildFilter(filter) + + const messages = db + .prepare( + `SELECT + msg.id, + msg.sender_id as senderId, + msg.ts, + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE 1=1 ${SYSTEM_FILTER} ${conditions} + ORDER BY msg.ts ASC` + ) + .all(...params) as Array<{ id: number; senderId: number; ts: number; platformId: string; name: string }> + + if (messages.length === 0) { + return { + nightOwlRank: [], + lastSpeakerRank: [], + firstSpeakerRank: [], + consecutiveRecords: [], + champions: [], + totalDays: 0, + } + } + + return computeNightOwl(messages) +} + +// ==================== 复读分析 ==================== + +function computeRepeat( + messages: Array<{ id: number; senderId: number; content: string; ts: number; platformId: string; name: string }> +): RepeatAnalysis { + const originatorCount = new Map() + const initiatorCount = new Map() + const breakerCount = new Map() + const memberMessageCount = new Map() + const memberInfo = new Map() + const chainLengthCount = new Map() + const contentStats = new Map< + string, + { count: number; maxChainLength: number; originatorId: number; lastTs: number; firstMessageId: number } + >() + + let currentContent: string | null = null + let repeatChain: Array<{ id: number; senderId: number; content: string; ts: number }> = [] + let totalRepeatChains = 0 + let totalChainLength = 0 + + const fastestRepeaterStats = new Map() + + const processRepeatChain = ( + chain: Array<{ id: number; senderId: number; content: string; ts: number }>, + breakerId?: number + ) => { + if (chain.length < 3) return + + totalRepeatChains++ + const chainLength = chain.length + totalChainLength += chainLength + + const oId = chain[0].senderId + originatorCount.set(oId, (originatorCount.get(oId) || 0) + 1) + + const iId = chain[1].senderId + initiatorCount.set(iId, (initiatorCount.get(iId) || 0) + 1) + + if (breakerId !== undefined) { + breakerCount.set(breakerId, (breakerCount.get(breakerId) || 0) + 1) + } + + chainLengthCount.set(chainLength, (chainLengthCount.get(chainLength) || 0) + 1) + + const content = chain[0].content + const chainTs = chain[0].ts + const firstMsgId = chain[0].id + const existing = contentStats.get(content) + if (existing) { + existing.count++ + existing.lastTs = Math.max(existing.lastTs, chainTs) + if (chainLength > existing.maxChainLength) { + existing.maxChainLength = chainLength + existing.originatorId = oId + existing.firstMessageId = firstMsgId + } + } else { + contentStats.set(content, { + count: 1, + maxChainLength: chainLength, + originatorId: oId, + lastTs: chainTs, + firstMessageId: firstMsgId, + }) + } + + for (let i = 1; i < chain.length; i++) { + const currentMsg = chain[i] + const prevMsg = chain[i - 1] + const diff = (currentMsg.ts - prevMsg.ts) * 1000 + if (diff <= 20 * 1000) { + if (!fastestRepeaterStats.has(currentMsg.senderId)) { + fastestRepeaterStats.set(currentMsg.senderId, { totalDiff: 0, count: 0 }) + } + const stats = fastestRepeaterStats.get(currentMsg.senderId)! + stats.totalDiff += diff + stats.count++ + } + } + } + + for (const msg of messages) { + if (!memberInfo.has(msg.senderId)) { + memberInfo.set(msg.senderId, { platformId: msg.platformId, name: msg.name }) + } + memberMessageCount.set(msg.senderId, (memberMessageCount.get(msg.senderId) || 0) + 1) + + const content = msg.content.trim() + if (content === currentContent) { + const lastSender = repeatChain[repeatChain.length - 1]?.senderId + if (lastSender !== msg.senderId) { + repeatChain.push({ id: msg.id, senderId: msg.senderId, content, ts: msg.ts }) + } + } else { + processRepeatChain(repeatChain, msg.senderId) + currentContent = content + repeatChain = [{ id: msg.id, senderId: msg.senderId, content, ts: msg.ts }] + } + } + processRepeatChain(repeatChain) + + const buildRankList = (countMap: Map, total: number): RepeatStatItem[] => { + const items: RepeatStatItem[] = [] + for (const [memberId, count] of countMap.entries()) { + const info = memberInfo.get(memberId) + if (info) { + items.push({ + memberId, + platformId: info.platformId, + name: info.name, + count, + percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0, + }) + } + } + return items.sort((a, b) => b.count - a.count) + } + + const buildRateList = (countMap: Map): RepeatRateItem[] => { + const items: RepeatRateItem[] = [] + for (const [memberId, count] of countMap.entries()) { + const info = memberInfo.get(memberId) + const totalMessages = memberMessageCount.get(memberId) || 0 + if (info && totalMessages > 0) { + items.push({ + memberId, + platformId: info.platformId, + name: info.name, + count, + totalMessages, + rate: Math.round((count / totalMessages) * 10000) / 100, + }) + } + } + return items.sort((a, b) => b.rate - a.rate) + } + + const buildFastestList = (): FastestRepeaterItem[] => { + const items: FastestRepeaterItem[] = [] + for (const [memberId, stats] of fastestRepeaterStats.entries()) { + if (stats.count < 5) continue + const info = memberInfo.get(memberId) + if (info) { + items.push({ + memberId, + platformId: info.platformId, + name: info.name, + count: stats.count, + avgTimeDiff: Math.round(stats.totalDiff / stats.count), + }) + } + } + return items.sort((a, b) => a.avgTimeDiff - b.avgTimeDiff) + } + + const chainLengthDistribution: ChainLengthDistribution[] = [] + for (const [length, count] of chainLengthCount.entries()) { + chainLengthDistribution.push({ length, count }) + } + chainLengthDistribution.sort((a, b) => a.length - b.length) + + const hotContents: HotRepeatContent[] = [] + for (const [content, stats] of contentStats.entries()) { + const originatorInfo = memberInfo.get(stats.originatorId) + hotContents.push({ + content, + count: stats.count, + maxChainLength: stats.maxChainLength, + originatorName: originatorInfo?.name || '未知', + lastTs: stats.lastTs, + firstMessageId: stats.firstMessageId, + }) + } + hotContents.sort((a, b) => b.maxChainLength - a.maxChainLength) + const top50HotContents = hotContents.slice(0, 100) + + return { + originators: buildRankList(originatorCount, totalRepeatChains), + initiators: buildRankList(initiatorCount, totalRepeatChains), + breakers: buildRankList(breakerCount, totalRepeatChains), + fastestRepeaters: buildFastestList(), + originatorRates: buildRateList(originatorCount), + initiatorRates: buildRateList(initiatorCount), + breakerRates: buildRateList(breakerCount), + chainLengthDistribution, + hotContents: top50HotContents, + avgChainLength: totalRepeatChains > 0 ? Math.round((totalChainLength / totalRepeatChains) * 100) / 100 : 0, + totalRepeatChains, + } +} + +export function getRepeatAnalysis(db: DatabaseAdapter, filter?: TimeFilter): RepeatAnalysis { + const { conditions, params } = buildFilter(filter) + + const messages = db + .prepare( + `SELECT + msg.id, + msg.sender_id as senderId, + msg.content, + msg.ts, + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE 1=1 ${SYSTEM_FILTER} + AND msg.type = 0 + AND msg.content IS NOT NULL + AND TRIM(msg.content) != '' + ${conditions} + ORDER BY msg.ts ASC, msg.id ASC` + ) + .all(...params) as Array<{ + id: number + senderId: number + content: string + ts: number + platformId: string + name: string + }> + + if (messages.length === 0) { + return { + originators: [], + initiators: [], + breakers: [], + fastestRepeaters: [], + originatorRates: [], + initiatorRates: [], + breakerRates: [], + chainLengthDistribution: [], + hotContents: [], + avgChainLength: 0, + totalRepeatChains: 0, + } + } + + return computeRepeat(messages) +} diff --git a/packages/core/src/query/advanced/relationship.ts b/packages/core/src/query/advanced/relationship.ts new file mode 100644 index 000000000..ae7b7a333 --- /dev/null +++ b/packages/core/src/query/advanced/relationship.ts @@ -0,0 +1,347 @@ +/** + * 关系分析模块(私聊专属,平台无关) + * 基于会话索引统计双方的主动发起、收尾、破冰、响应时延、锲而不舍行为 + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '../../interfaces' + +export interface RelationshipMonthStats { + month: string + members: Array<{ memberId: number; name: string; initiateCount: number; closeCount: number }> + totalSessions: number +} + +export interface IceBreakerItem { + month: string + memberId: number + name: string + count: number +} + +export interface ResponseLatencyMember { + memberId: number + name: string + avgResponseTime: number + totalResponses: number +} + +export interface PerseveranceMember { + memberId: number + name: string + totalDoubleTexts: number +} + +export interface MonthlyResponseLatency { + month: string + members: Array<{ memberId: number; name: string; avgResponseTime: number; responseCount: number }> +} + +export interface MonthlyPerseverance { + month: string + members: Array<{ memberId: number; name: string; doubleTextCount: number }> +} + +export interface RelationshipOptions { + perseveranceThreshold?: number +} + +export interface RelationshipStats { + months: RelationshipMonthStats[] + members: Array<{ memberId: number; name: string; totalInitiateCount: number; totalCloseCount: number }> + totalSessions: number + hasSessionIndex: boolean + iceBreakers: IceBreakerItem[] + totalIceBreaks: number + responseLatency: ResponseLatencyMember[] + perseverance: PerseveranceMember[] + totalDoubleTexts: number + monthlyResponseLatency: MonthlyResponseLatency[] + monthlyPerseverance: MonthlyPerseverance[] + perseveranceThreshold: number +} + +const ICE_BREAK_THRESHOLD = 24 * 60 * 60 +const DEFAULT_PERSEVERANCE_THRESHOLD = 300 + +export function getRelationshipStats( + db: DatabaseAdapter, + filter?: TimeFilter, + options?: RelationshipOptions +): RelationshipStats { + const perseveranceThreshold = options?.perseveranceThreshold ?? DEFAULT_PERSEVERANCE_THRESHOLD + const emptyResult: RelationshipStats = { + months: [], + members: [], + totalSessions: 0, + hasSessionIndex: false, + iceBreakers: [], + totalIceBreaks: 0, + responseLatency: [], + perseverance: [], + totalDoubleTexts: 0, + monthlyResponseLatency: [], + monthlyPerseverance: [], + perseveranceThreshold, + } + + const sessionCount = db.prepare('SELECT COUNT(*) as count FROM segment').get() as { count: number } | undefined + if (!sessionCount || sessionCount.count === 0) return emptyResult + + const timeConditions: string[] = [] + const params: (number | string)[] = [] + + if (filter?.startTs !== undefined) { + timeConditions.push('cs.start_ts >= ?') + params.push(filter.startTs) + } + if (filter?.endTs !== undefined) { + timeConditions.push('cs.start_ts <= ?') + params.push(filter.endTs) + } + + const whereClause = timeConditions.length > 0 ? `WHERE ${timeConditions.join(' AND ')}` : '' + + const segmentRows = db + .prepare( + `SELECT cs.id AS segment_id, cs.start_ts, cs.end_ts, + (SELECT m.sender_id FROM message_context mc JOIN message m ON m.id = mc.message_id + WHERE mc.segment_id = cs.id ORDER BY m.ts ASC, m.id ASC LIMIT 1) AS initiator_id, + (SELECT m.sender_id FROM message_context mc JOIN message m ON m.id = mc.message_id + WHERE mc.segment_id = cs.id ORDER BY m.ts DESC, m.id DESC LIMIT 1) AS closer_id + FROM segment cs ${whereClause} ORDER BY cs.start_ts ASC` + ) + .all(...params) as Array<{ + segment_id: number + start_ts: number + end_ts: number + initiator_id: number | null + closer_id: number | null + }> + + const memberNames = new Map() + const memberRows = db + .prepare('SELECT id, COALESCE(group_nickname, account_name, platform_id) as name FROM member') + .all() as Array<{ id: number; name: string }> + for (const row of memberRows) memberNames.set(row.id, row.name) + + const monthMap = new Map< + string, + { initiateMap: Map; closeMap: Map; totalSessions: number } + >() + const memberInitTotals = new Map() + const memberCloseTotals = new Map() + const iceBreakMap = new Map>() + let totalIceBreaks = 0 + let prevEndTs: number | null = null + + for (const row of segmentRows) { + const month = toLocalMonth(row.start_ts) + if (!monthMap.has(month)) monthMap.set(month, { initiateMap: new Map(), closeMap: new Map(), totalSessions: 0 }) + const ms = monthMap.get(month)! + ms.totalSessions++ + + if (row.initiator_id !== null) { + ms.initiateMap.set(row.initiator_id, (ms.initiateMap.get(row.initiator_id) ?? 0) + 1) + memberInitTotals.set(row.initiator_id, (memberInitTotals.get(row.initiator_id) ?? 0) + 1) + } + if (row.closer_id !== null) { + ms.closeMap.set(row.closer_id, (ms.closeMap.get(row.closer_id) ?? 0) + 1) + memberCloseTotals.set(row.closer_id, (memberCloseTotals.get(row.closer_id) ?? 0) + 1) + } + if (prevEndTs !== null && row.initiator_id !== null && row.start_ts - prevEndTs > ICE_BREAK_THRESHOLD) { + if (!iceBreakMap.has(month)) iceBreakMap.set(month, new Map()) + const mMap = iceBreakMap.get(month)! + mMap.set(row.initiator_id, (mMap.get(row.initiator_id) ?? 0) + 1) + totalIceBreaks++ + } + prevEndTs = row.end_ts + } + + const allMemberIds = new Set() + for (const id of memberInitTotals.keys()) allMemberIds.add(id) + for (const id of memberCloseTotals.keys()) allMemberIds.add(id) + + const monthKeys = Array.from(monthMap.keys()).sort((a, b) => b.localeCompare(a)) + const months: RelationshipMonthStats[] = monthKeys.map((month) => { + const ms = monthMap.get(month)! + const members = Array.from(allMemberIds).map((memberId) => ({ + memberId, + name: memberNames.get(memberId) ?? `Unknown(${memberId})`, + initiateCount: ms.initiateMap.get(memberId) ?? 0, + closeCount: ms.closeMap.get(memberId) ?? 0, + })) + return { month, members, totalSessions: ms.totalSessions } + }) + + const members = Array.from(allMemberIds) + .map((memberId) => ({ + memberId, + name: memberNames.get(memberId) ?? `Unknown(${memberId})`, + totalInitiateCount: memberInitTotals.get(memberId) ?? 0, + totalCloseCount: memberCloseTotals.get(memberId) ?? 0, + })) + .sort((a, b) => b.totalInitiateCount - a.totalInitiateCount) + + const iceBreakers: IceBreakerItem[] = [] + for (const month of monthKeys) { + const mMap = iceBreakMap.get(month) + if (!mMap) continue + for (const [memberId, count] of mMap) { + iceBreakers.push({ month, memberId, name: memberNames.get(memberId) ?? `Unknown(${memberId})`, count }) + } + } + + const segmentIdList = segmentRows.map((r) => r.segment_id) + const msgStats = queryMessageLevelStats(db, segmentIdList, memberNames, perseveranceThreshold) + + return { + months, + members, + totalSessions: segmentRows.length, + hasSessionIndex: true, + iceBreakers, + totalIceBreaks, + ...msgStats, + perseveranceThreshold, + } +} + +function queryMessageLevelStats( + db: DatabaseAdapter, + segmentIds: number[], + memberNames: Map, + perseveranceThreshold: number +): { + responseLatency: ResponseLatencyMember[] + perseverance: PerseveranceMember[] + totalDoubleTexts: number + monthlyResponseLatency: MonthlyResponseLatency[] + monthlyPerseverance: MonthlyPerseverance[] +} { + const empty = { + responseLatency: [], + perseverance: [], + totalDoubleTexts: 0, + monthlyResponseLatency: [], + monthlyPerseverance: [], + } + if (segmentIds.length === 0) return empty + + const BATCH_SIZE = 500 + const responseTotals = new Map() + const dtTotals = new Map() + const monthlyRespMap = new Map>() + const monthlyDtMap = new Map>() + + for (let i = 0; i < segmentIds.length; i += BATCH_SIZE) { + const batch = segmentIds.slice(i, i + BATCH_SIZE) + const placeholders = batch.map(() => '?').join(',') + + const latencyRows = db + .prepare( + `WITH msg_lag AS ( + SELECT m.sender_id, m.ts, + LAG(m.sender_id) OVER (PARTITION BY mc.segment_id ORDER BY m.ts, m.id) AS prev_sender_id, + LAG(m.ts) OVER (PARTITION BY mc.segment_id ORDER BY m.ts, m.id) AS prev_ts + FROM message_context mc JOIN message m ON m.id = mc.message_id + WHERE mc.segment_id IN (${placeholders}) AND m.type = 0 + ) + SELECT strftime('%Y-%m', datetime(ts, 'unixepoch', 'localtime')) AS month, + sender_id AS responder_id, SUM(ts - prev_ts) AS total_time, COUNT(*) AS response_count + FROM msg_lag WHERE prev_sender_id IS NOT NULL AND sender_id != prev_sender_id + GROUP BY month, responder_id` + ) + .all(...batch) as Array<{ month: string; responder_id: number; total_time: number; response_count: number }> + + for (const row of latencyRows) { + const existing = responseTotals.get(row.responder_id) + if (existing) { + existing.sum += row.total_time + existing.count += row.response_count + } else responseTotals.set(row.responder_id, { sum: row.total_time, count: row.response_count }) + + if (!monthlyRespMap.has(row.month)) monthlyRespMap.set(row.month, new Map()) + const mMap = monthlyRespMap.get(row.month)! + const mExisting = mMap.get(row.responder_id) + if (mExisting) { + mExisting.sum += row.total_time + mExisting.count += row.response_count + } else mMap.set(row.responder_id, { sum: row.total_time, count: row.response_count }) + } + + const dtRows = db + .prepare( + `WITH msg_lag AS ( + SELECT m.sender_id, m.ts, + LAG(m.sender_id) OVER (PARTITION BY mc.segment_id ORDER BY m.ts, m.id) AS prev_sender_id, + LAG(m.ts) OVER (PARTITION BY mc.segment_id ORDER BY m.ts, m.id) AS prev_ts + FROM message_context mc JOIN message m ON m.id = mc.message_id + WHERE mc.segment_id IN (${placeholders}) AND m.type = 0 + ) + SELECT strftime('%Y-%m', datetime(ts, 'unixepoch', 'localtime')) AS month, + sender_id, COUNT(*) AS double_text_count + FROM msg_lag WHERE prev_sender_id IS NOT NULL AND sender_id = prev_sender_id AND (ts - prev_ts) >= ? + GROUP BY month, sender_id` + ) + .all(...batch, perseveranceThreshold) as Array<{ month: string; sender_id: number; double_text_count: number }> + + for (const row of dtRows) { + dtTotals.set(row.sender_id, (dtTotals.get(row.sender_id) ?? 0) + row.double_text_count) + if (!monthlyDtMap.has(row.month)) monthlyDtMap.set(row.month, new Map()) + const mMap = monthlyDtMap.get(row.month)! + mMap.set(row.sender_id, (mMap.get(row.sender_id) ?? 0) + row.double_text_count) + } + } + + const responseLatency: ResponseLatencyMember[] = Array.from(responseTotals.entries()) + .map(([memberId, { sum, count }]) => ({ + memberId, + name: memberNames.get(memberId) ?? `Unknown(${memberId})`, + avgResponseTime: Math.round(sum / count), + totalResponses: count, + })) + .sort((a, b) => a.avgResponseTime - b.avgResponseTime) + + let totalDoubleTexts = 0 + const perseverance: PerseveranceMember[] = Array.from(dtTotals.entries()) + .map(([memberId, count]) => { + totalDoubleTexts += count + return { memberId, name: memberNames.get(memberId) ?? `Unknown(${memberId})`, totalDoubleTexts: count } + }) + .sort((a, b) => b.totalDoubleTexts - a.totalDoubleTexts) + + const monthlyResponseLatency: MonthlyResponseLatency[] = Array.from(monthlyRespMap.entries()) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([month, mMap]) => ({ + month, + members: Array.from(mMap.entries()) + .map(([memberId, { sum, count }]) => ({ + memberId, + name: memberNames.get(memberId) ?? `Unknown(${memberId})`, + avgResponseTime: Math.round(sum / count), + responseCount: count, + })) + .sort((a, b) => a.avgResponseTime - b.avgResponseTime), + })) + + const monthlyPerseverance: MonthlyPerseverance[] = Array.from(monthlyDtMap.entries()) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([month, mMap]) => ({ + month, + members: Array.from(mMap.entries()) + .map(([memberId, count]) => ({ + memberId, + name: memberNames.get(memberId) ?? `Unknown(${memberId})`, + doubleTextCount: count, + })) + .sort((a, b) => b.doubleTextCount - a.doubleTextCount), + })) + + return { responseLatency, perseverance, totalDoubleTexts, monthlyResponseLatency, monthlyPerseverance } +} + +function toLocalMonth(ts: number): string { + const d = new Date(ts * 1000) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` +} diff --git a/packages/core/src/query/advanced/repeat.test.ts b/packages/core/src/query/advanced/repeat.test.ts new file mode 100644 index 000000000..41456a1f6 --- /dev/null +++ b/packages/core/src/query/advanced/repeat.test.ts @@ -0,0 +1,73 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { DatabaseAdapter, PreparedStatement } from '../../interfaces' +import { getLanguagePreferenceAnalysis } from './languagePreference' +import { getCatchphraseAnalysis } from './repeat' + +interface MockRow { + [key: string]: unknown + memberId: number + platformId?: string + name: string + content: string + count?: number +} + +function createRowsDb(rows: MockRow[]): DatabaseAdapter { + return { + prepare(): PreparedStatement { + return { + get() { + return undefined + }, + all() { + return rows + }, + run() { + return { changes: 0, lastInsertRowid: 0 } + }, + } + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + exec() {}, + transaction(fn: () => T) { + return fn() + }, + pragma() { + return undefined + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + close() {}, + } +} + +describe('getCatchphraseAnalysis', () => { + it('filters QQ reply placeholders from catchphrase results', () => { + const db = createRowsDb([ + { memberId: 1, platformId: 'alice', name: 'Alice', content: '[回复消息]', count: 10 }, + { memberId: 1, platformId: 'alice', name: 'Alice', content: '收到', count: 3 }, + ]) + + const result = getCatchphraseAnalysis(db) + + assert.deepEqual(result.members[0]?.catchphrases, [{ content: '收到', count: 3 }]) + }) +}) + +describe('getLanguagePreferenceAnalysis', () => { + it('filters QQ reply placeholders from phrase frequency results', () => { + const db = createRowsDb([ + { memberId: 1, name: 'Alice', content: '[回复消息]' }, + { memberId: 1, name: 'Alice', content: '[回复消息]' }, + { memberId: 2, name: 'Bob', content: 'noted' }, + { memberId: 2, name: 'Bob', content: 'noted' }, + ]) + + const result = getLanguagePreferenceAnalysis(db, { locale: 'en-US' }) + + assert.equal(result.members.length, 1) + assert.equal(result.members[0]?.name, 'Bob') + assert.equal(result.members[0]?.totalMessages, 2) + assert.deepEqual(result.members[0]?.catchphrases, [{ content: 'noted', count: 2 }]) + }) +}) diff --git a/packages/core/src/query/advanced/repeat.ts b/packages/core/src/query/advanced/repeat.ts new file mode 100644 index 000000000..f595c6a8a --- /dev/null +++ b/packages/core/src/query/advanced/repeat.ts @@ -0,0 +1,90 @@ +/** + * 口头禅分析模块(平台无关) + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '../../interfaces' +import { buildTimeFilter } from '../filters' +import { isSystemPlaceholderContent } from './text-filters' + +export interface CatchphraseItem { + content: string + count: number +} + +export interface MemberCatchphrase { + memberId: number + platformId: string + name: string + catchphrases: CatchphraseItem[] +} + +export interface CatchphraseAnalysis { + members: MemberCatchphrase[] +} + +export function getCatchphraseAnalysis(db: DatabaseAdapter, filter?: TimeFilter): CatchphraseAnalysis { + const { clause, params } = buildTimeFilter(filter) + + let whereClause = clause + if (whereClause.includes('WHERE')) { + whereClause += + " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2" + } else { + whereClause = + " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(TRIM(msg.content)) >= 2" + } + + const rows = db + .prepare( + ` + SELECT + m.id as memberId, + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + TRIM(msg.content) as content, + COUNT(*) as count + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${whereClause} + GROUP BY m.id, TRIM(msg.content) + ORDER BY m.id, count DESC + ` + ) + .all(...params) as Array<{ + memberId: number + platformId: string + name: string + content: string + count: number + }> + + const memberMap = new Map() + + for (const row of rows) { + if (isSystemPlaceholderContent(row.content)) continue + + if (!memberMap.has(row.memberId)) { + memberMap.set(row.memberId, { + memberId: row.memberId, + platformId: row.platformId, + name: row.name, + catchphrases: [], + }) + } + + const member = memberMap.get(row.memberId)! + if (member.catchphrases.length < 100) { + member.catchphrases.push({ content: row.content, count: row.count }) + } + } + + const members = Array.from(memberMap.values()) + members.sort((a, b) => { + const aTotal = a.catchphrases.reduce((sum, c) => sum + c.count, 0) + const bTotal = b.catchphrases.reduce((sum, c) => sum + c.count, 0) + return bTotal - aTotal + }) + + return { members } +} diff --git a/packages/core/src/query/advanced/social.ts b/packages/core/src/query/advanced/social.ts new file mode 100644 index 000000000..cc19f7b88 --- /dev/null +++ b/packages/core/src/query/advanced/social.ts @@ -0,0 +1,758 @@ +/** + * 社交分析模块(平台无关) + * 包含:@ 互动分析、含笑量分析、小团体关系图 + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '../../interfaces' +import { buildTimeFilter } from '../filters' + +// ==================== @ 互动分析 ==================== + +export function getMentionAnalysis(db: DatabaseAdapter, filter?: TimeFilter): any { + const emptyResult = { + topMentioners: [], + topMentioned: [], + oneWay: [], + twoWay: [], + totalMentions: 0, + memberDetails: [], + } + + const members = db + .prepare( + `SELECT id, platform_id as platformId, COALESCE(group_nickname, account_name, platform_id) as name + FROM member WHERE COALESCE(account_name, '') != '系统消息'` + ) + .all() as Array<{ id: number; platformId: string; name: string }> + + if (members.length === 0) return emptyResult + + const nameToMemberId = new Map() + const memberIdToInfo = new Map() + + for (const member of members) { + memberIdToInfo.set(member.id, { platformId: member.platformId, name: member.name }) + nameToMemberId.set(member.name, member.id) + + const history = db.prepare('SELECT name FROM member_name_history WHERE member_id = ?').all(member.id) as Array<{ + name: string + }> + + for (const h of history) { + if (!nameToMemberId.has(h.name)) { + nameToMemberId.set(h.name, member.id) + } + } + } + + const { clause, params } = buildTimeFilter(filter) + let whereClause = clause + if (whereClause.includes('WHERE')) { + whereClause += + " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'" + } else { + whereClause = + " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'" + } + + const messages = db + .prepare( + `SELECT msg.sender_id as senderId, msg.content + FROM message msg JOIN member m ON msg.sender_id = m.id ${whereClause}` + ) + .all(...params) as Array<{ senderId: number; content: string }> + + const mentionMatrix = new Map>() + const mentionedCount = new Map() + const mentionerCount = new Map() + let totalMentions = 0 + const mentionRegex = /@([^\s@]+)/g + + for (const msg of messages) { + const matches = msg.content.matchAll(mentionRegex) + const mentionedInThisMsg = new Set() + + for (const match of matches) { + const mentionedId = nameToMemberId.get(match[1]) + if (mentionedId && mentionedId !== msg.senderId && !mentionedInThisMsg.has(mentionedId)) { + mentionedInThisMsg.add(mentionedId) + totalMentions++ + + if (!mentionMatrix.has(msg.senderId)) mentionMatrix.set(msg.senderId, new Map()) + const fromMap = mentionMatrix.get(msg.senderId)! + fromMap.set(mentionedId, (fromMap.get(mentionedId) || 0) + 1) + + mentionerCount.set(msg.senderId, (mentionerCount.get(msg.senderId) || 0) + 1) + mentionedCount.set(mentionedId, (mentionedCount.get(mentionedId) || 0) + 1) + } + } + } + + if (totalMentions === 0) return emptyResult + + const topMentioners: any[] = [] + for (const [memberId, count] of mentionerCount.entries()) { + const info = memberIdToInfo.get(memberId)! + topMentioners.push({ + memberId, + platformId: info.platformId, + name: info.name, + count, + percentage: Math.round((count / totalMentions) * 10000) / 100, + }) + } + topMentioners.sort((a, b) => b.count - a.count) + + const topMentioned: any[] = [] + for (const [memberId, count] of mentionedCount.entries()) { + const info = memberIdToInfo.get(memberId)! + topMentioned.push({ + memberId, + platformId: info.platformId, + name: info.name, + count, + percentage: Math.round((count / totalMentions) * 10000) / 100, + }) + } + topMentioned.sort((a, b) => b.count - a.count) + + const oneWay: any[] = [] + const processedPairs = new Set() + for (const [fromId, toMap] of mentionMatrix.entries()) { + for (const [toId, fromToCount] of toMap.entries()) { + const pairKey = `${Math.min(fromId, toId)}-${Math.max(fromId, toId)}` + if (processedPairs.has(pairKey)) continue + processedPairs.add(pairKey) + const toFromCount = mentionMatrix.get(toId)?.get(fromId) || 0 + const total = fromToCount + toFromCount + if (total < 3) continue + const ratio = fromToCount / total + if (ratio >= 0.8) { + const fromInfo = memberIdToInfo.get(fromId)! + const toInfo = memberIdToInfo.get(toId)! + oneWay.push({ + fromMemberId: fromId, + fromName: fromInfo.name, + toMemberId: toId, + toName: toInfo.name, + fromToCount, + toFromCount, + ratio: Math.round(ratio * 100) / 100, + }) + } else if (ratio <= 0.2) { + const fromInfo = memberIdToInfo.get(fromId)! + const toInfo = memberIdToInfo.get(toId)! + oneWay.push({ + fromMemberId: toId, + fromName: toInfo.name, + toMemberId: fromId, + toName: fromInfo.name, + fromToCount: toFromCount, + toFromCount: fromToCount, + ratio: Math.round((1 - ratio) * 100) / 100, + }) + } + } + } + oneWay.sort((a, b) => b.fromToCount - a.fromToCount) + + const twoWay: any[] = [] + processedPairs.clear() + for (const [fromId, toMap] of mentionMatrix.entries()) { + for (const [toId, fromToCount] of toMap.entries()) { + const pairKey = `${Math.min(fromId, toId)}-${Math.max(fromId, toId)}` + if (processedPairs.has(pairKey)) continue + processedPairs.add(pairKey) + const toFromCount = mentionMatrix.get(toId)?.get(fromId) || 0 + const total = fromToCount + toFromCount + if (total < 5 || toFromCount === 0 || fromToCount === 0) continue + const ratio = Math.min(fromToCount, toFromCount) / Math.max(fromToCount, toFromCount) + if (ratio >= 0.3) { + const m1Info = memberIdToInfo.get(fromId)! + const m2Info = memberIdToInfo.get(toId)! + twoWay.push({ + member1Id: fromId, + member1Name: m1Info.name, + member2Id: toId, + member2Name: m2Info.name, + member1To2: fromToCount, + member2To1: toFromCount, + total, + balance: Math.round(ratio * 100) / 100, + }) + } + } + } + twoWay.sort((a, b) => b.total - a.total) + + const memberDetails: any[] = [] + for (const member of members) { + const info = memberIdToInfo.get(member.id)! + const topMentionedByThis: any[] = [] + const toMap = mentionMatrix.get(member.id) + if (toMap) { + for (const [toId, count] of toMap.entries()) { + const toInfo = memberIdToInfo.get(toId)! + topMentionedByThis.push({ + fromMemberId: member.id, + fromName: info.name, + toMemberId: toId, + toName: toInfo.name, + count, + }) + } + topMentionedByThis.sort((a, b) => b.count - a.count) + } + const topMentionersOfThis: any[] = [] + for (const [fromId, fToMap] of mentionMatrix.entries()) { + const count = fToMap.get(member.id) + if (count) { + const fromInfo = memberIdToInfo.get(fromId)! + topMentionersOfThis.push({ + fromMemberId: fromId, + fromName: fromInfo.name, + toMemberId: member.id, + toName: info.name, + count, + }) + } + } + topMentionersOfThis.sort((a, b) => b.count - a.count) + if (topMentionedByThis.length > 0 || topMentionersOfThis.length > 0) { + memberDetails.push({ + memberId: member.id, + name: info.name, + topMentioned: topMentionedByThis.slice(0, 5), + topMentioners: topMentionersOfThis.slice(0, 5), + }) + } + } + + return { topMentioners, topMentioned, oneWay, twoWay, totalMentions, memberDetails } +} + +// ==================== @ 互动关系图数据 ==================== + +export interface MentionGraphNode { + id: number + name: string + value: number + symbolSize: number +} + +export interface MentionGraphLink { + source: string + target: string + value: number +} + +export interface MentionGraphData { + nodes: MentionGraphNode[] + links: MentionGraphLink[] + maxLinkValue: number +} + +export function getMentionGraph(db: DatabaseAdapter, filter?: TimeFilter): MentionGraphData { + const emptyResult: MentionGraphData = { nodes: [], links: [], maxLinkValue: 0 } + + const { clause, params } = buildTimeFilter(filter) + const msgFilterBase = clause ? clause.replace('WHERE', 'AND') : '' + const msgFilterWithSystem = msgFilterBase + " AND COALESCE(m.account_name, '') != '系统消息'" + + const members = db + .prepare( + `SELECT m.id, m.platform_id as platformId, COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + COUNT(msg.id) as messageCount + FROM member m LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem} + WHERE COALESCE(m.account_name, '') != '系统消息' GROUP BY m.id` + ) + .all(...params) as Array<{ id: number; platformId: string; name: string; messageCount: number }> + + if (members.length === 0) return emptyResult + + const nameToMemberId = new Map() + const memberIdToInfo = new Map() + + for (const member of members) { + memberIdToInfo.set(member.id, { name: member.name, messageCount: member.messageCount }) + nameToMemberId.set(member.name, member.id) + const history = db.prepare('SELECT name FROM member_name_history WHERE member_id = ?').all(member.id) as Array<{ + name: string + }> + for (const h of history) { + if (!nameToMemberId.has(h.name)) nameToMemberId.set(h.name, member.id) + } + } + + let whereClause = clause + if (whereClause.includes('WHERE')) { + whereClause += + " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'" + } else { + whereClause = + " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL AND msg.content LIKE '%@%'" + } + + const messages = db + .prepare( + `SELECT msg.sender_id as senderId, msg.content FROM message msg JOIN member m ON msg.sender_id = m.id ${whereClause}` + ) + .all(...params) as Array<{ senderId: number; content: string }> + + const mentionMatrix = new Map>() + const mentionRegex = /@([^\s@]+)/g + + for (const msg of messages) { + const matches = msg.content.matchAll(mentionRegex) + const mentionedInThisMsg = new Set() + for (const match of matches) { + const mentionedId = nameToMemberId.get(match[1]) + if (mentionedId && mentionedId !== msg.senderId && !mentionedInThisMsg.has(mentionedId)) { + mentionedInThisMsg.add(mentionedId) + if (!mentionMatrix.has(msg.senderId)) mentionMatrix.set(msg.senderId, new Map()) + const fromMap = mentionMatrix.get(msg.senderId)! + fromMap.set(mentionedId, (fromMap.get(mentionedId) || 0) + 1) + } + } + } + + const involvedMemberIds = new Set() + for (const [fromId, toMap] of mentionMatrix.entries()) { + involvedMemberIds.add(fromId) + for (const toId of toMap.keys()) involvedMemberIds.add(toId) + } + + const maxMessageCount = Math.max(...members.filter((m) => involvedMemberIds.has(m.id)).map((m) => m.messageCount), 1) + const nodes: MentionGraphNode[] = [] + for (const memberId of involvedMemberIds) { + const info = memberIdToInfo.get(memberId) + if (info) { + const symbolSize = 20 + (info.messageCount / maxMessageCount) * 40 + nodes.push({ id: memberId, name: info.name, value: info.messageCount, symbolSize: Math.round(symbolSize) }) + } + } + + const links: MentionGraphLink[] = [] + let maxLinkValue = 0 + for (const [fromId, toMap] of mentionMatrix.entries()) { + const fromInfo = memberIdToInfo.get(fromId) + if (!fromInfo) continue + for (const [toId, count] of toMap.entries()) { + const toInfo = memberIdToInfo.get(toId) + if (!toInfo) continue + links.push({ source: fromInfo.name, target: toInfo.name, value: count }) + maxLinkValue = Math.max(maxLinkValue, count) + } + } + + return { nodes, links, maxLinkValue } +} + +// ==================== 含笑量分析 ==================== + +function keywordToPattern(keyword: string): string { + const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + if (keyword === '哈哈') return '哈哈+' + return escaped +} + +export function getLaughAnalysis(db: DatabaseAdapter, filter?: TimeFilter, keywords?: string[]): any { + const emptyResult = { + rankByRate: [], + rankByCount: [], + typeDistribution: [], + totalLaughs: 0, + totalMessages: 0, + groupLaughRate: 0, + } + const laughKeywords = keywords && keywords.length > 0 ? keywords : [] + const patterns = laughKeywords.map(keywordToPattern) + const laughRegex = new RegExp(`(${patterns.join('|')})`, 'gi') + + const { clause, params } = buildTimeFilter(filter) + let whereClause = clause + if (whereClause.includes('WHERE')) { + whereClause += " AND COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL" + } else { + whereClause = " WHERE COALESCE(m.account_name, '') != '系统消息' AND msg.type = 0 AND msg.content IS NOT NULL" + } + + const messages = db + .prepare( + `SELECT msg.sender_id as senderId, msg.content, m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name + FROM message msg JOIN member m ON msg.sender_id = m.id ${whereClause}` + ) + .all(...params) as Array<{ senderId: number; content: string; platformId: string; name: string }> + + if (messages.length === 0) return emptyResult + + const memberStats = new Map< + number, + { platformId: string; name: string; laughCount: number; messageCount: number; keywordCounts: Map } + >() + const typeCount = new Map() + let totalLaughs = 0 + + for (const msg of messages) { + if (!memberStats.has(msg.senderId)) { + memberStats.set(msg.senderId, { + platformId: msg.platformId, + name: msg.name, + laughCount: 0, + messageCount: 0, + keywordCounts: new Map(), + }) + } + const stats = memberStats.get(msg.senderId)! + stats.messageCount++ + const matches = msg.content.match(laughRegex) + if (matches) { + stats.laughCount += matches.length + totalLaughs += matches.length + for (const match of matches) { + let matchedType = '其他' + for (const keyword of laughKeywords) { + if (new RegExp(`^${keywordToPattern(keyword)}$`, 'i').test(match)) { + matchedType = keyword + break + } + } + typeCount.set(matchedType, (typeCount.get(matchedType) || 0) + 1) + stats.keywordCounts.set(matchedType, (stats.keywordCounts.get(matchedType) || 0) + 1) + } + } + } + + if (totalLaughs === 0) return emptyResult + + const rankItems: any[] = [] + for (const [memberId, stats] of memberStats.entries()) { + if (stats.laughCount > 0) { + const keywordDistribution: Array<{ keyword: string; count: number; percentage: number }> = [] + for (const keyword of laughKeywords) { + const count = stats.keywordCounts.get(keyword) || 0 + if (count > 0) + keywordDistribution.push({ keyword, count, percentage: Math.round((count / stats.laughCount) * 10000) / 100 }) + } + const otherCount = stats.keywordCounts.get('其他') || 0 + if (otherCount > 0) + keywordDistribution.push({ + keyword: '其他', + count: otherCount, + percentage: Math.round((otherCount / stats.laughCount) * 10000) / 100, + }) + + rankItems.push({ + memberId, + platformId: stats.platformId, + name: stats.name, + laughCount: stats.laughCount, + messageCount: stats.messageCount, + laughRate: Math.round((stats.laughCount / stats.messageCount) * 10000) / 100, + percentage: Math.round((stats.laughCount / totalLaughs) * 10000) / 100, + keywordDistribution, + }) + } + } + + const typeDistribution: any[] = [] + for (const [type, count] of typeCount.entries()) { + typeDistribution.push({ type, count, percentage: Math.round((count / totalLaughs) * 10000) / 100 }) + } + typeDistribution.sort((a, b) => b.count - a.count) + + return { + rankByRate: [...rankItems].sort((a, b) => b.laughRate - a.laughRate), + rankByCount: [...rankItems].sort((a, b) => b.laughCount - a.laughCount), + typeDistribution, + totalLaughs, + totalMessages: messages.length, + groupLaughRate: Math.round((totalLaughs / messages.length) * 10000) / 100, + } +} + +// ==================== 小团体关系图 ==================== + +export interface ClusterGraphOptions { + lookAhead?: number + decaySeconds?: number + topEdges?: number +} + +export interface ClusterGraphNode { + id: number + name: string + messageCount: number + symbolSize: number + degree: number + normalizedDegree: number +} + +export interface ClusterGraphLink { + source: string + target: string + value: number + rawScore: number + expectedScore: number + coOccurrenceCount: number +} + +export interface ClusterGraphData { + nodes: ClusterGraphNode[] + links: ClusterGraphLink[] + maxLinkValue: number + communities: Array<{ id: number; name: string; size: number }> + stats: { + totalMembers: number + totalMessages: number + involvedMembers: number + edgeCount: number + communityCount: number + } +} + +export interface CoOccurrenceMessage { + senderId: number + ts: number +} + +export interface CoOccurrencePairStats { + sourceId: number + targetId: number + rawScore: number + coOccurrenceCount: number + lastOccurrenceTs: number +} + +const DEFAULT_CLUSTER_OPTIONS = { lookAhead: 3, decaySeconds: 120, topEdges: 100 } + +function roundNum(value: number, digits = 4): number { + const factor = 10 ** digits + return Math.round(value * factor) / factor +} + +function clusterPairKey(aId: number, bId: number): string { + return aId < bId ? `${aId}-${bId}` : `${bId}-${aId}` +} + +export function accumulateCoOccurrencePairs( + messages: CoOccurrenceMessage[], + options?: ClusterGraphOptions +): CoOccurrencePairStats[] { + const opts = { ...DEFAULT_CLUSTER_OPTIONS, ...options } + const pairRawScore = new Map() + const pairCoOccurrence = new Map() + const pairLastOccurrenceTs = new Map() + + // 与小团体图保持同一口径:按消息顺序向后寻找不同发言人,并用时间衰减和位置权重累计关系强度。 + for (let i = 0; i < messages.length - 1; i++) { + const anchor = messages[i] + const seenPartners = new Set() + let partnersFound = 0 + for (let j = i + 1; j < messages.length && partnersFound < opts.lookAhead; j++) { + const candidate = messages[j] + if (candidate.senderId === anchor.senderId || seenPartners.has(candidate.senderId)) continue + seenPartners.add(candidate.senderId) + partnersFound++ + const deltaSeconds = candidate.ts - anchor.ts + const decayWeight = Math.exp(-deltaSeconds / opts.decaySeconds) + const positionWeight = 1 - (partnersFound - 1) * 0.2 + const weight = decayWeight * positionWeight + const key = clusterPairKey(anchor.senderId, candidate.senderId) + pairRawScore.set(key, (pairRawScore.get(key) || 0) + weight) + pairCoOccurrence.set(key, (pairCoOccurrence.get(key) || 0) + 1) + pairLastOccurrenceTs.set(key, Math.max(pairLastOccurrenceTs.get(key) ?? 0, candidate.ts)) + } + } + + const pairs: CoOccurrencePairStats[] = [] + for (const [key, rawScore] of pairRawScore) { + const [sourceIdStr, targetIdStr] = key.split('-') + pairs.push({ + sourceId: parseInt(sourceIdStr), + targetId: parseInt(targetIdStr), + rawScore, + coOccurrenceCount: pairCoOccurrence.get(key) || 0, + lastOccurrenceTs: pairLastOccurrenceTs.get(key) ?? 0, + }) + } + + return pairs +} + +export function getClusterGraph( + db: DatabaseAdapter, + filter?: TimeFilter, + options?: ClusterGraphOptions +): ClusterGraphData { + const opts = { ...DEFAULT_CLUSTER_OPTIONS, ...options } + const emptyResult: ClusterGraphData = { + nodes: [], + links: [], + maxLinkValue: 0, + communities: [], + stats: { totalMembers: 0, totalMessages: 0, involvedMembers: 0, edgeCount: 0, communityCount: 0 }, + } + + const members = db + .prepare( + `SELECT id, platform_id as platformId, COALESCE(group_nickname, account_name, platform_id) as name, + (SELECT COUNT(*) FROM message WHERE sender_id = member.id) as messageCount + FROM member WHERE COALESCE(account_name, '') != '系统消息'` + ) + .all() as Array<{ id: number; platformId: string; name: string; messageCount: number }> + + if (members.length < 2) return { ...emptyResult, stats: { ...emptyResult.stats, totalMembers: members.length } } + + const memberInfo = new Map() + for (const m of members) + memberInfo.set(m.id, { name: m.name, platformId: m.platformId, messageCount: m.messageCount }) + + const { clause, params } = buildTimeFilter(filter) + let whereClause = clause + if (whereClause.includes('WHERE')) { + whereClause += " AND COALESCE(m.account_name, '') != '系统消息'" + } else { + whereClause = " WHERE COALESCE(m.account_name, '') != '系统消息'" + } + + const messages = db + .prepare( + `SELECT msg.sender_id as senderId, msg.ts as ts FROM message msg JOIN member m ON msg.sender_id = m.id + ${whereClause} ORDER BY msg.ts ASC, msg.id ASC` + ) + .all(...params) as Array<{ senderId: number; ts: number }> + + if (messages.length < 2) + return { + ...emptyResult, + stats: { ...emptyResult.stats, totalMembers: members.length, totalMessages: messages.length }, + } + + const memberMsgCount = new Map() + for (const msg of messages) memberMsgCount.set(msg.senderId, (memberMsgCount.get(msg.senderId) || 0) + 1) + const totalMessages = messages.length + + const lookAheadFactor = opts.lookAhead * 0.8 + const rawEdges: Array<{ + sourceId: number + targetId: number + rawScore: number + expectedScore: number + normalizedScore: number + coOccurrenceCount: number + }> = [] + + for (const pair of accumulateCoOccurrencePairs(messages, opts)) { + const aId = pair.sourceId + const bId = pair.targetId + const aMsgCount = memberMsgCount.get(aId) || 0 + const bMsgCount = memberMsgCount.get(bId) || 0 + const expectedScore = ((aMsgCount * bMsgCount) / totalMessages) * lookAheadFactor + const normalizedScore = expectedScore > 0 ? pair.rawScore / expectedScore : 0 + rawEdges.push({ + sourceId: aId, + targetId: bId, + rawScore: pair.rawScore, + expectedScore, + normalizedScore, + coOccurrenceCount: pair.coOccurrenceCount, + }) + } + + const maxRawScore = Math.max(...rawEdges.map((e) => e.rawScore), 1) + const maxNormalizedScore = Math.max(...rawEdges.map((e) => e.normalizedScore), 1) + + const edges = rawEdges.map((e) => { + const hybridScore = 0.5 * (e.rawScore / maxRawScore) + 0.5 * (e.normalizedScore / maxNormalizedScore) + return { + ...e, + rawScore: roundNum(e.rawScore), + expectedScore: roundNum(e.expectedScore), + normalizedScore: roundNum(e.normalizedScore), + hybridScore: roundNum(hybridScore), + } + }) + + edges.sort((a, b) => b.hybridScore - a.hybridScore) + const keptEdges = edges.slice(0, opts.topEdges) + + if (keptEdges.length === 0) + return { + ...emptyResult, + stats: { ...emptyResult.stats, totalMembers: members.length, totalMessages: messages.length }, + } + + const involvedIds = new Set() + for (const edge of keptEdges) { + involvedIds.add(edge.sourceId) + involvedIds.add(edge.targetId) + } + + const nodeDegree = new Map() + for (const edge of keptEdges) { + nodeDegree.set(edge.sourceId, (nodeDegree.get(edge.sourceId) || 0) + edge.hybridScore) + nodeDegree.set(edge.targetId, (nodeDegree.get(edge.targetId) || 0) + edge.hybridScore) + } + const maxDegree = Math.max(...nodeDegree.values(), 1) + + const nameCount = new Map() + for (const id of involvedIds) { + const name = memberInfo.get(id)?.name || String(id) + nameCount.set(name, (nameCount.get(name) || 0) + 1) + } + + const displayNames = new Map() + for (const id of involvedIds) { + const info = memberInfo.get(id) + const baseName = info?.name || String(id) + displayNames.set( + id, + (nameCount.get(baseName) || 0) > 1 ? `${baseName}#${(info?.platformId || String(id)).slice(-4)}` : baseName + ) + } + + const maxMsgCount = Math.max(...[...involvedIds].map((id) => memberInfo.get(id)?.messageCount || 0), 1) + const nodes: ClusterGraphNode[] = [...involvedIds].map((id) => { + const info = memberInfo.get(id)! + const degree = nodeDegree.get(id) || 0 + const normalizedDegree = degree / maxDegree + const msgNorm = info.messageCount / maxMsgCount + const symbolSize = 20 + (0.7 * normalizedDegree + 0.3 * msgNorm) * 35 + return { + id, + name: displayNames.get(id)!, + messageCount: info.messageCount, + symbolSize: Math.round(symbolSize), + degree: roundNum(degree), + normalizedDegree: roundNum(normalizedDegree), + } + }) + nodes.sort((a, b) => b.degree - a.degree) + + const maxLinkValue = keptEdges.length > 0 ? Math.max(...keptEdges.map((e) => e.hybridScore)) : 0 + const links: ClusterGraphLink[] = keptEdges.map((e) => ({ + source: displayNames.get(e.sourceId)!, + target: displayNames.get(e.targetId)!, + value: e.hybridScore, + rawScore: e.rawScore, + expectedScore: e.expectedScore, + coOccurrenceCount: e.coOccurrenceCount, + })) + + return { + nodes, + links, + maxLinkValue: roundNum(maxLinkValue), + communities: [], + stats: { + totalMembers: members.length, + totalMessages: messages.length, + involvedMembers: involvedIds.size, + edgeCount: keptEdges.length, + communityCount: 0, + }, + } +} diff --git a/packages/core/src/query/advanced/text-filters.ts b/packages/core/src/query/advanced/text-filters.ts new file mode 100644 index 000000000..71c2984e1 --- /dev/null +++ b/packages/core/src/query/advanced/text-filters.ts @@ -0,0 +1,28 @@ +const SYSTEM_PLACEHOLDER_CONTENTS = new Set([ + '[图片]', + '[视频]', + '[语音]', + '[文件]', + '[动画表情]', + '[表情]', + '[链接]', + '[位置]', + '[地理位置]', + '[名片]', + '[红包]', + '[转账]', + '[音乐]', + '[回复消息]', + '[Image]', + '[Photo]', + '[Video]', + '[Voice]', + '[File]', + '[Sticker]', + '[Link]', + '[Location]', +]) + +export function isSystemPlaceholderContent(content: string): boolean { + return SYSTEM_PLACEHOLDER_CONTENTS.has(content.trim()) +} diff --git a/packages/core/src/query/basic-queries.ts b/packages/core/src/query/basic-queries.ts new file mode 100644 index 000000000..dfbe8a202 --- /dev/null +++ b/packages/core/src/query/basic-queries.ts @@ -0,0 +1,457 @@ +/** + * 基础统计查询模块(平台无关) + * + * 提供活跃度排行、时段分布等核心统计查询。 + * 所有函数接收 DatabaseAdapter 参数,不依赖全局状态。 + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '../interfaces' +import { buildTimeFilter, buildSystemMessageFilter } from './filters' + +export interface MemberActivity { + memberId: number + platformId: string + name: string + avatar: string | null + messageCount: number + percentage: number +} + +export interface HourlyActivity { + hour: number + messageCount: number +} + +export interface DailyActivity { + date: string + messageCount: number +} + +export interface WeekdayActivity { + weekday: number + messageCount: number +} + +export interface MonthlyActivity { + month: number + messageCount: number +} + +export interface YearlyActivity { + year: number + messageCount: number +} + +export interface MessageLengthDistribution { + detail: Array<{ len: number; count: number }> + grouped: Array<{ range: string; count: number }> +} + +export interface MessageTypeStats { + type: number + count: number +} + +export interface TextStats { + textCount: number + avgLength: number + maxLength: number + shortCount: number +} + +export interface TextLengthPercentiles { + p25: number + p50: number + p75: number + p90: number +} + +export interface MemberMonthlyTrend { + month: string + memberId: number + memberName: string + count: number +} + +/** + * 获取消息时间范围 + */ +export function getTimeRange(db: DatabaseAdapter): { start: number; end: number } | null { + const row = db.prepare('SELECT MIN(ts) as start, MAX(ts) as end FROM message').get() as + | { start: number | null; end: number | null } + | undefined + if (!row || row.start == null || row.end == null) return null + return { start: row.start, end: row.end } +} + +/** + * 获取可用的年份列表 + */ +export function getAvailableYears(db: DatabaseAdapter): number[] { + const rows = db + .prepare( + `SELECT DISTINCT CAST(strftime('%Y', ts, 'unixepoch', 'localtime') AS INTEGER) as year + FROM message + ORDER BY year DESC` + ) + .all() as Array<{ year: number }> + + return rows.map((r) => r.year) +} + +/** + * 获取成员活跃度排行 + */ +export function getMemberActivity(db: DatabaseAdapter, filter?: TimeFilter): MemberActivity[] { + const { clause, params } = buildTimeFilter(filter) + + const msgFilterBase = clause ? clause.replace('WHERE', 'AND') : '' + const msgFilterWithSystem = msgFilterBase + " AND COALESCE(m.account_name, '') != '系统消息'" + + const totalClauseWithSystem = buildSystemMessageFilter(clause) + const totalMessages = ( + db + .prepare( + `SELECT COUNT(*) as count + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${totalClauseWithSystem}` + ) + .get(...params) as { count: number } + ).count + + const rows = db + .prepare( + `SELECT + m.id as memberId, + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + m.avatar as avatar, + COUNT(msg.id) as messageCount + FROM member m + LEFT JOIN message msg ON m.id = msg.sender_id ${msgFilterWithSystem} + WHERE COALESCE(m.account_name, '') != '系统消息' + GROUP BY m.id + HAVING messageCount > 0 + ORDER BY messageCount DESC` + ) + .all(...params) as Array<{ + memberId: number + platformId: string + name: string + avatar: string | null + messageCount: number + }> + + return rows.map((row) => ({ + ...row, + percentage: totalMessages > 0 ? Math.round((row.messageCount / totalMessages) * 10000) / 100 : 0, + })) +} + +/** + * 获取每小时活跃度分布 + */ +export function getHourlyActivity(db: DatabaseAdapter, filter?: TimeFilter): HourlyActivity[] { + const { clause, params } = buildTimeFilter(filter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + const rows = db + .prepare( + `SELECT + CAST(strftime('%H', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as hour, + COUNT(*) as messageCount + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY hour + ORDER BY hour` + ) + .all(...params) as Array<{ hour: number; messageCount: number }> + + const result: HourlyActivity[] = [] + for (let h = 0; h < 24; h++) { + const found = rows.find((r) => r.hour === h) + result.push({ hour: h, messageCount: found ? found.messageCount : 0 }) + } + return result +} + +/** + * 获取每日活跃度趋势 + */ +export function getDailyActivity(db: DatabaseAdapter, filter?: TimeFilter): DailyActivity[] { + const { clause, params } = buildTimeFilter(filter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + return db + .prepare( + `SELECT + strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime') as date, + COUNT(*) as messageCount + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY date + ORDER BY date` + ) + .all(...params) as unknown as DailyActivity[] +} + +/** + * 获取星期活跃度分布 + */ +export function getWeekdayActivity(db: DatabaseAdapter, filter?: TimeFilter): WeekdayActivity[] { + const { clause, params } = buildTimeFilter(filter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + const rows = db + .prepare( + `SELECT + CASE + WHEN CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER) = 0 THEN 7 + ELSE CAST(strftime('%w', msg.ts, 'unixepoch', 'localtime') AS INTEGER) + END as weekday, + COUNT(*) as messageCount + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY weekday + ORDER BY weekday` + ) + .all(...params) as Array<{ weekday: number; messageCount: number }> + + const result: WeekdayActivity[] = [] + for (let w = 1; w <= 7; w++) { + const found = rows.find((r) => r.weekday === w) + result.push({ weekday: w, messageCount: found ? found.messageCount : 0 }) + } + return result +} + +/** + * 获取消息类型分布 + */ +export function getMessageTypeStats(db: DatabaseAdapter, filter?: TimeFilter): MessageTypeStats[] { + const { clause, params } = buildTimeFilter(filter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + return db + .prepare( + `SELECT + msg.type as type, + COUNT(*) as count + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY msg.type + ORDER BY count DESC` + ) + .all(...params) as unknown as MessageTypeStats[] +} + +/** + * 获取月份活跃度分布 + */ +export function getMonthlyActivity(db: DatabaseAdapter, filter?: TimeFilter): MonthlyActivity[] { + const { clause, params } = buildTimeFilter(filter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + const rows = db + .prepare( + `SELECT + CAST(strftime('%m', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as month, + COUNT(*) as messageCount + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY month + ORDER BY month` + ) + .all(...params) as Array<{ month: number; messageCount: number }> + + const result: MonthlyActivity[] = [] + for (let m = 1; m <= 12; m++) { + const found = rows.find((r) => r.month === m) + result.push({ month: m, messageCount: found ? found.messageCount : 0 }) + } + return result +} + +/** + * 获取年份活跃度分布 + */ +export function getYearlyActivity(db: DatabaseAdapter, filter?: TimeFilter): YearlyActivity[] { + const { clause, params } = buildTimeFilter(filter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + return db + .prepare( + `SELECT + CAST(strftime('%Y', msg.ts, 'unixepoch', 'localtime') AS INTEGER) as year, + COUNT(*) as messageCount + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY year + ORDER BY year` + ) + .all(...params) as unknown as YearlyActivity[] +} + +/** + * 获取消息长度分布(仅统计文字消息 type=0) + */ +export function getMessageLengthDistribution(db: DatabaseAdapter, filter?: TimeFilter): MessageLengthDistribution { + const { clause, params } = buildTimeFilter(filter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + const typeCondition = clauseWithSystem + ? clauseWithSystem + ' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 0' + : 'WHERE msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 0' + + const rows = db + .prepare( + `SELECT LENGTH(msg.content) as len, COUNT(*) as count + FROM message msg JOIN member m ON msg.sender_id = m.id + ${typeCondition} + GROUP BY len ORDER BY len` + ) + .all(...params) as Array<{ len: number; count: number }> + + const detail: Array<{ len: number; count: number }> = [] + for (let i = 1; i <= 25; i++) { + const found = rows.find((r) => r.len === i) + detail.push({ len: i, count: found ? found.count : 0 }) + } + + const ranges = [ + { min: 1, max: 5, label: '1-5' }, + { min: 6, max: 10, label: '6-10' }, + { min: 11, max: 15, label: '11-15' }, + { min: 16, max: 20, label: '16-20' }, + { min: 21, max: 25, label: '21-25' }, + { min: 26, max: 30, label: '26-30' }, + { min: 31, max: 35, label: '31-35' }, + { min: 36, max: 40, label: '36-40' }, + { min: 41, max: 45, label: '41-45' }, + { min: 46, max: 50, label: '46-50' }, + { min: 51, max: 60, label: '51-60' }, + { min: 61, max: 70, label: '61-70' }, + { min: 71, max: 80, label: '71-80' }, + { min: 81, max: 100, label: '81-100' }, + { min: 101, max: Infinity, label: '100+' }, + ] + + const grouped: Array<{ range: string; count: number }> = ranges.map((r) => ({ + range: r.label, + count: rows.filter((row) => row.len >= r.min && row.len <= r.max).reduce((sum, row) => sum + row.count, 0), + })) + + return { detail, grouped } +} + +/** + * 获取文字消息统计(仅 type=0):数量、平均长度、最大长度、短消息(≤5 字)数 + */ +export function getTextStats(db: DatabaseAdapter, filter?: TimeFilter): TextStats { + const { clause, params } = buildTimeFilter(filter) + const typeCondition = + buildSystemMessageFilter(clause) + ' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 0' + + const row = db + .prepare( + `SELECT + COUNT(*) as textCount, + ROUND(AVG(LENGTH(msg.content)), 1) as avgLength, + MAX(LENGTH(msg.content)) as maxLength, + SUM(CASE WHEN LENGTH(msg.content) <= 5 THEN 1 ELSE 0 END) as shortCount + FROM message msg JOIN member m ON msg.sender_id = m.id + ${typeCondition}` + ) + .get(...params) as + | { textCount: number; avgLength: number | null; maxLength: number | null; shortCount: number | null } + | undefined + + return { + textCount: row?.textCount ?? 0, + avgLength: row?.avgLength ?? 0, + maxLength: row?.maxLength ?? 0, + shortCount: row?.shortCount ?? 0, + } +} + +/** + * 获取长消息(小作文)数量,minLength 为字符阈值(默认 30),仅统计文字消息 + */ +export function getLongMessageCount(db: DatabaseAdapter, filter?: TimeFilter, minLength = 30): number { + const { clause, params } = buildTimeFilter(filter) + const typeCondition = + buildSystemMessageFilter(clause) + ' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) >= ?' + + const row = db + .prepare( + `SELECT COUNT(*) as cnt + FROM message msg JOIN member m ON msg.sender_id = m.id + ${typeCondition}` + ) + .get(...params, minLength) as { cnt: number } | undefined + + return row?.cnt ?? 0 +} + +/** + * 获取成员月度消息趋势(按月 × 发送者) + */ +export function getMemberMonthlyTrend(db: DatabaseAdapter, filter?: TimeFilter): MemberMonthlyTrend[] { + const { clause, params } = buildTimeFilter(filter) + const clauseWithSystem = buildSystemMessageFilter(clause) + + return db + .prepare( + `SELECT + strftime('%Y-%m', msg.ts, 'unixepoch', 'localtime') as month, + msg.sender_id as memberId, + m.account_name as memberName, + COUNT(*) as count + FROM message msg JOIN member m ON msg.sender_id = m.id + ${clauseWithSystem} + GROUP BY month, msg.sender_id + ORDER BY month` + ) + .all(...params) as unknown as MemberMonthlyTrend[] +} + +/** + * 获取文字消息长度百分位(P25/P50/P75/P90),仅统计文字消息 + */ +export function getTextLengthPercentiles(db: DatabaseAdapter, filter?: TimeFilter): TextLengthPercentiles { + const { clause, params } = buildTimeFilter(filter) + const typeCondition = + buildSystemMessageFilter(clause) + ' AND msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 0' + + const rows = db + .prepare( + `SELECT LENGTH(msg.content) as len + FROM message msg JOIN member m ON msg.sender_id = m.id + ${typeCondition} + ORDER BY len` + ) + .all(...params) as Array<{ len: number }> + + if (rows.length === 0) return { p25: 0, p50: 0, p75: 0, p90: 0 } + + const lengths = rows.map((r) => r.len) + const getPercentile = (arr: number[], p: number) => { + const idx = Math.ceil((p / 100) * arr.length) - 1 + return arr[Math.max(0, idx)] + } + + return { + p25: getPercentile(lengths, 25), + p50: getPercentile(lengths, 50), + p75: getPercentile(lengths, 75), + p90: getPercentile(lengths, 90), + } +} diff --git a/packages/core/src/query/contact-queries.ts b/packages/core/src/query/contact-queries.ts new file mode 100644 index 000000000..74eb80b5f --- /dev/null +++ b/packages/core/src/query/contact-queries.ts @@ -0,0 +1,530 @@ +import type { DatabaseAdapter } from '../interfaces' +import { hasColumn } from './filters' +import { accumulateCoOccurrencePairs } from './advanced/social' + +const SYSTEM_MESSAGE_TYPES = [80, 81] as const +const SYSTEM_MESSAGE_TYPES_SQL = SYSTEM_MESSAGE_TYPES.join(', ') +const LEGACY_SYSTEM_ACCOUNT_NAME = '系统消息' + +export interface ContactMemberRef { + id: number + platformId: string + name: string + aliases: string[] + avatar: string | null +} + +export interface ContactFactsOptions { + startTs?: number | null +} + +export type PrivateContactFacts = + | { + type: 'ok' + contact: ContactMemberRef + privateMessageCount: number + activeMonths: string[] + lastMessageTs: number | null + } + | { type: 'missing' } + | { type: 'ambiguous'; candidates: ContactMemberRef[] } + +export interface GroupContactFacts { + contact: ContactMemberRef + messageCount: number + coOccurrenceCount: number + coOccurrenceRawScore: number + replyInteractionCount: number + repliesFromOwnerToContact: number + repliesFromContactToOwner: number + lastInteractionTs: number | null +} + +export interface RelationshipGraphMemberFact { + contact: ContactMemberRef + messageCount: number + lastMessageTs: number | null +} + +export interface RelationshipGraphEdgeFact { + source: ContactMemberRef + target: ContactMemberRef + coOccurrenceCount: number + coOccurrenceRawScore: number + replyInteractionCount: number + repliesFromSourceToTarget: number + repliesFromTargetToSource: number + lastInteractionTs: number | null +} + +export interface GroupRelationshipGraphFacts { + members: RelationshipGraphMemberFact[] + edges: RelationshipGraphEdgeFact[] + ownerMessageCount: number +} + +export function isValidContactPlatformId(platformId: string | null | undefined): platformId is string { + return typeof platformId === 'string' && platformId.trim().length > 0 +} + +export function resolveOwnerMember(db: DatabaseAdapter): ContactMemberRef | null { + const meta = db.prepare('SELECT owner_id FROM meta LIMIT 1').get() as { owner_id: string | null } | undefined + if (!isValidContactPlatformId(meta?.owner_id)) return null + const aliasesSelect = hasColumn(db, 'member', 'aliases') ? 'aliases' : 'NULL as aliases' + + const row = db + .prepare( + `SELECT + id, + platform_id as platformId, + COALESCE(group_nickname, account_name, platform_id) as name, + ${aliasesSelect}, + avatar + FROM member m + WHERE platform_id = ? AND ${nonSystemContactMemberCondition(db, 'm')} + LIMIT 1` + ) + .get(meta.owner_id) as ContactMemberRow | undefined + + return row ? mapContactMemberRow(row) : null +} + +export function getNonSystemMembersForContacts(db: DatabaseAdapter): ContactMemberRef[] { + const aliasesSelect = hasColumn(db, 'member', 'aliases') ? 'aliases' : 'NULL as aliases' + const rows = db + .prepare( + `SELECT + id, + platform_id as platformId, + COALESCE(group_nickname, account_name, platform_id) as name, + ${aliasesSelect}, + avatar + FROM member m + WHERE ${nonSystemContactMemberCondition(db, 'm')} + ORDER BY id ASC` + ) + .all() as unknown as ContactMemberRow[] + + return rows.map(mapContactMemberRow).filter((row) => isValidContactPlatformId(row.platformId)) +} + +export function getLatestContactMessageTs(db: DatabaseAdapter): number | null { + const row = db + .prepare( + `SELECT MAX(msg.ts) as ts + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${nonSystemMessageCondition(db, 'msg', 'm')}` + ) + .get() as { ts: number | null } | undefined + + return row?.ts ?? null +} + +export function getPrivateContactFacts( + db: DatabaseAdapter, + ownerMemberId: number, + options: ContactFactsOptions = {} +): PrivateContactFacts { + const candidates = getNonSystemMembersForContacts(db).filter((member) => member.id !== ownerMemberId) + if (candidates.length === 0) return { type: 'missing' } + + const timeFilter = createMessageTimeFilter('msg', options.startTs) + const candidateById = new Map(candidates.map((candidate) => [candidate.id, candidate])) + const activeCandidateRows = db + .prepare( + `SELECT msg.sender_id as senderId + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${nonSystemMessageCondition(db, 'msg', 'm')} + AND msg.sender_id <> ?${timeFilter.sql} + GROUP BY msg.sender_id` + ) + .all(ownerMemberId, ...timeFilter.params) as Array<{ senderId: number }> + const activeCandidates = activeCandidateRows + .map((row) => candidateById.get(row.senderId)) + .filter((candidate): candidate is ContactMemberRef => Boolean(candidate)) + const resolvedCandidates = activeCandidates.length > 0 ? activeCandidates : candidates + if (resolvedCandidates.length > 1) return { type: 'ambiguous', candidates: resolvedCandidates } + + const countRow = db + .prepare( + `SELECT COUNT(*) as count + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${nonSystemMessageCondition(db, 'msg', 'm')}${timeFilter.sql}` + ) + .get(...timeFilter.params) as { count: number } | undefined + + const monthRows = db + .prepare( + `SELECT DISTINCT strftime('%Y-%m', msg.ts, 'unixepoch', 'localtime') as month + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${nonSystemMessageCondition(db, 'msg', 'm')}${timeFilter.sql} + ORDER BY month ASC` + ) + .all(...timeFilter.params) as Array<{ month: string }> + + const lastRow = db + .prepare( + `SELECT MAX(msg.ts) as lastMessageTs + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${nonSystemMessageCondition(db, 'msg', 'm')}${timeFilter.sql}` + ) + .get(...timeFilter.params) as { lastMessageTs: number | null } | undefined + + return { + type: 'ok', + contact: resolvedCandidates[0], + privateMessageCount: countRow?.count ?? 0, + activeMonths: monthRows.map((row) => row.month).filter(Boolean), + lastMessageTs: lastRow?.lastMessageTs ?? null, + } +} + +export function getGroupContactFacts( + db: DatabaseAdapter, + ownerMemberId: number, + options: ContactFactsOptions = {} +): GroupContactFacts[] { + const contacts = getNonSystemMembersForContacts(db).filter((member) => member.id !== ownerMemberId) + const messageTimeFilter = createMessageTimeFilter('msg', options.startTs) + const messageRows = db + .prepare( + `SELECT msg.sender_id as senderId, COUNT(*) as messageCount + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${nonSystemMessageCondition(db, 'msg', 'm')}${messageTimeFilter.sql} + GROUP BY msg.sender_id` + ) + .all(...messageTimeFilter.params) as Array<{ senderId: number; messageCount: number }> + + const messageCounts = new Map(messageRows.map((row) => [row.senderId, row.messageCount])) + const contactById = new Map(contacts.map((contact) => [contact.id, contact])) + const coOccurrenceRows = db + .prepare( + `SELECT msg.sender_id as senderId, msg.ts as ts + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${nonSystemMessageCondition(db, 'msg', 'm')}${messageTimeFilter.sql} + ORDER BY msg.ts ASC, msg.id ASC` + ) + .all(...messageTimeFilter.params) as Array<{ senderId: number; ts: number }> + const coOccurrenceStats = new Map< + number, + { coOccurrenceCount: number; coOccurrenceRawScore: number; lastOccurrenceTs: number } + >() + + // 共现算法会产出任意成员对;联系人页只消费 owner 与候选联系人的关系边。 + for (const pair of accumulateCoOccurrencePairs(coOccurrenceRows)) { + const contactId = + pair.sourceId === ownerMemberId && contactById.has(pair.targetId) + ? pair.targetId + : pair.targetId === ownerMemberId && contactById.has(pair.sourceId) + ? pair.sourceId + : null + if (contactId === null) continue + coOccurrenceStats.set(contactId, { + coOccurrenceCount: pair.coOccurrenceCount, + coOccurrenceRawScore: pair.rawScore, + lastOccurrenceTs: pair.lastOccurrenceTs, + }) + } + const replyStats = new Map< + number, + { + repliesFromOwnerToContact: number + repliesFromContactToOwner: number + lastInteractionTs: number | null + } + >() + + const replyTimeFilter = createReplyTimeFilter(options.startTs) + const replyRows = db + .prepare( + `SELECT + msg.sender_id as replySenderId, + msg.ts as replyTs, + target.sender_id as targetSenderId + FROM message msg + JOIN message target ON msg.reply_to_message_id = target.platform_message_id + JOIN member sender ON msg.sender_id = sender.id + JOIN member targetMember ON target.sender_id = targetMember.id + WHERE msg.reply_to_message_id IS NOT NULL + AND ${nonSystemMessageCondition(db, 'msg', 'sender')} + AND ${nonSystemMessageCondition(db, 'target', 'targetMember')}${replyTimeFilter.sql}` + ) + .all(...replyTimeFilter.params) as Array<{ replySenderId: number; replyTs: number; targetSenderId: number }> + + const ensureReplyStats = (contactId: number) => { + const existing = replyStats.get(contactId) + if (existing) return existing + const created = { repliesFromOwnerToContact: 0, repliesFromContactToOwner: 0, lastInteractionTs: null } + replyStats.set(contactId, created) + return created + } + + for (const row of replyRows) { + if (row.replySenderId === ownerMemberId && contactById.has(row.targetSenderId)) { + const stats = ensureReplyStats(row.targetSenderId) + stats.repliesFromOwnerToContact++ + stats.lastInteractionTs = Math.max(stats.lastInteractionTs ?? 0, row.replyTs) + } else if (row.targetSenderId === ownerMemberId && contactById.has(row.replySenderId)) { + const stats = ensureReplyStats(row.replySenderId) + stats.repliesFromContactToOwner++ + stats.lastInteractionTs = Math.max(stats.lastInteractionTs ?? 0, row.replyTs) + } + } + + return contacts.map((contact) => { + const stats = replyStats.get(contact.id) ?? { + repliesFromOwnerToContact: 0, + repliesFromContactToOwner: 0, + lastInteractionTs: null, + } + const coOccurrence = coOccurrenceStats.get(contact.id) + const replyInteractionCount = stats.repliesFromOwnerToContact + stats.repliesFromContactToOwner + return { + contact, + messageCount: messageCounts.get(contact.id) ?? 0, + coOccurrenceCount: coOccurrence?.coOccurrenceCount ?? 0, + coOccurrenceRawScore: coOccurrence?.coOccurrenceRawScore ?? 0, + replyInteractionCount, + repliesFromOwnerToContact: stats.repliesFromOwnerToContact, + repliesFromContactToOwner: stats.repliesFromContactToOwner, + lastInteractionTs: stats.lastInteractionTs ?? coOccurrence?.lastOccurrenceTs ?? null, + } + }) +} + +export function getGroupRelationshipGraphFacts( + db: DatabaseAdapter, + ownerMemberId: number, + options: ContactFactsOptions = {} +): GroupRelationshipGraphFacts { + const contacts = getNonSystemMembersForContacts(db).filter((member) => member.id !== ownerMemberId) + const contactById = new Map(contacts.map((contact) => [contact.id, contact])) + const messageTimeFilter = createMessageTimeFilter('msg', options.startTs) + const messageRows = db + .prepare( + `SELECT msg.sender_id as senderId, COUNT(*) as messageCount, MAX(msg.ts) as lastMessageTs + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${nonSystemMessageCondition(db, 'msg', 'm')}${messageTimeFilter.sql} + GROUP BY msg.sender_id` + ) + .all(...messageTimeFilter.params) as Array<{ senderId: number; messageCount: number; lastMessageTs: number | null }> + + const memberStats = new Map() + let ownerMessageCount = 0 + for (const row of messageRows) { + if (row.senderId === ownerMemberId) { + ownerMessageCount = row.messageCount + continue + } + if (!contactById.has(row.senderId)) continue + memberStats.set(row.senderId, { + messageCount: row.messageCount, + lastMessageTs: row.lastMessageTs ?? null, + }) + } + + const coOccurrenceRows = db + .prepare( + `SELECT msg.sender_id as senderId, msg.ts as ts + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE ${nonSystemMessageCondition(db, 'msg', 'm')}${messageTimeFilter.sql} + ORDER BY msg.ts ASC, msg.id ASC` + ) + .all(...messageTimeFilter.params) as Array<{ senderId: number; ts: number }> + + const edgeStats = new Map< + string, + { + sourceId: number + targetId: number + coOccurrenceCount: number + coOccurrenceRawScore: number + repliesFromSourceToTarget: number + repliesFromTargetToSource: number + lastInteractionTs: number | null + } + >() + + const ensureEdge = (aId: number, bId: number) => { + const sourceId = Math.min(aId, bId) + const targetId = Math.max(aId, bId) + const key = `${sourceId}:${targetId}` + const existing = edgeStats.get(key) + if (existing) return existing + const created = { + sourceId, + targetId, + coOccurrenceCount: 0, + coOccurrenceRawScore: 0, + repliesFromSourceToTarget: 0, + repliesFromTargetToSource: 0, + lastInteractionTs: null, + } + edgeStats.set(key, created) + return created + } + + for (const pair of accumulateCoOccurrencePairs(coOccurrenceRows)) { + if (!contactById.has(pair.sourceId) || !contactById.has(pair.targetId)) continue + const edge = ensureEdge(pair.sourceId, pair.targetId) + edge.coOccurrenceCount += pair.coOccurrenceCount + edge.coOccurrenceRawScore += pair.rawScore + edge.lastInteractionTs = Math.max(edge.lastInteractionTs ?? 0, pair.lastOccurrenceTs) + } + + const replyTimeFilter = createReplyTimeFilter(options.startTs) + const replyRows = db + .prepare( + `SELECT + msg.sender_id as replySenderId, + msg.ts as replyTs, + target.sender_id as targetSenderId + FROM message msg + JOIN message target ON msg.reply_to_message_id = target.platform_message_id + JOIN member sender ON msg.sender_id = sender.id + JOIN member targetMember ON target.sender_id = targetMember.id + WHERE msg.reply_to_message_id IS NOT NULL + AND ${nonSystemMessageCondition(db, 'msg', 'sender')} + AND ${nonSystemMessageCondition(db, 'target', 'targetMember')}${replyTimeFilter.sql}` + ) + .all(...replyTimeFilter.params) as Array<{ replySenderId: number; replyTs: number; targetSenderId: number }> + + for (const row of replyRows) { + if (!contactById.has(row.replySenderId) || !contactById.has(row.targetSenderId)) continue + if (row.replySenderId === row.targetSenderId) continue + const edge = ensureEdge(row.replySenderId, row.targetSenderId) + if (row.replySenderId === edge.sourceId) edge.repliesFromSourceToTarget++ + else edge.repliesFromTargetToSource++ + edge.lastInteractionTs = Math.max(edge.lastInteractionTs ?? 0, row.replyTs) + } + + const members = contacts.map((contact) => { + const stats = memberStats.get(contact.id) + return { + contact, + messageCount: stats?.messageCount ?? 0, + lastMessageTs: stats?.lastMessageTs ?? null, + } + }) + + const edges: RelationshipGraphEdgeFact[] = [] + for (const edge of edgeStats.values()) { + const source = contactById.get(edge.sourceId) + const target = contactById.get(edge.targetId) + if (!source || !target) continue + const replyInteractionCount = edge.repliesFromSourceToTarget + edge.repliesFromTargetToSource + if (edge.coOccurrenceCount <= 0 && replyInteractionCount <= 0) continue + edges.push({ + source, + target, + coOccurrenceCount: edge.coOccurrenceCount, + coOccurrenceRawScore: edge.coOccurrenceRawScore, + replyInteractionCount, + repliesFromSourceToTarget: edge.repliesFromSourceToTarget, + repliesFromTargetToSource: edge.repliesFromTargetToSource, + lastInteractionTs: edge.lastInteractionTs, + }) + } + + return { members, edges, ownerMessageCount } +} + +function createMessageTimeFilter( + alias: string, + startTs: number | null | undefined +): { sql: string; params: unknown[] } { + return typeof startTs === 'number' ? { sql: ` AND ${alias}.ts >= ?`, params: [startTs] } : { sql: '', params: [] } +} + +function createReplyTimeFilter(startTs: number | null | undefined): { sql: string; params: unknown[] } { + return typeof startTs === 'number' + ? { sql: ' AND msg.ts >= ? AND target.ts >= ?', params: [startTs, startTs] } + : { sql: '', params: [] } +} + +interface ContactMemberRow { + id: number + platformId: string + name: string + aliases: string | null + avatar: string | null +} + +function mapContactMemberRow(row: ContactMemberRow): ContactMemberRef { + return { + id: row.id, + platformId: row.platformId, + name: row.name, + aliases: parseContactAliases(row.aliases), + avatar: row.avatar ?? null, + } +} + +function parseContactAliases(value: string | null): string[] { + if (!value) return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) + ? parsed.filter((alias): alias is string => typeof alias === 'string' && alias.length > 0) + : [] + } catch { + return [] + } +} +function nonSystemContactMemberCondition(db: DatabaseAdapter, memberAlias: string): string { + // 系统消息名称会随平台和导出语言变化;联系人候选优先用稳定 sender identity 和消息类型识别伪成员。 + return `(${nonSystemMemberIdentityCondition(db, memberAlias)} + AND ( + NOT EXISTS ( + SELECT 1 FROM message system_msg + WHERE system_msg.sender_id = ${memberAlias}.id + AND system_msg.type IN (${SYSTEM_MESSAGE_TYPES_SQL}) + ) + OR EXISTS ( + SELECT 1 FROM message non_system_msg + WHERE non_system_msg.sender_id = ${memberAlias}.id + AND non_system_msg.type NOT IN (${SYSTEM_MESSAGE_TYPES_SQL}) + ) + ))` +} + +function nonSystemMessageCondition(db: DatabaseAdapter, messageAlias: string, memberAlias: string): string { + return `(${messageAlias}.type NOT IN (${SYSTEM_MESSAGE_TYPES_SQL}) + AND ${nonSystemMemberIdentityCondition(db, memberAlias)})` +} + +function nonSystemMemberIdentityCondition(db: DatabaseAdapter, memberAlias: string): string { + return `(LOWER(COALESCE(${memberAlias}.platform_id, '')) != 'system' + AND COALESCE(${memberAlias}.account_name, '') != '${LEGACY_SYSTEM_ACCOUNT_NAME}' + AND ${notGroupSelfMemberCondition(db, memberAlias)})` +} + +function notGroupSelfMemberCondition(db: DatabaseAdapter, memberAlias: string): string { + const clauses = [ + `(TRIM(COALESCE(session_meta.name, '')) != '' + AND LOWER(TRIM(COALESCE(${memberAlias}.platform_id, ''))) = LOWER(TRIM(session_meta.name)) + AND LOWER(TRIM(COALESCE(${memberAlias}.account_name, ''))) = LOWER(TRIM(session_meta.name)))`, + ] + + if (hasColumn(db, 'meta', 'group_id')) { + clauses.unshift( + `(TRIM(COALESCE(session_meta.group_id, '')) != '' + AND LOWER(TRIM(COALESCE(${memberAlias}.platform_id, ''))) = LOWER(TRIM(session_meta.group_id)))` + ) + } + + return `NOT EXISTS ( + SELECT 1 FROM meta session_meta + WHERE LOWER(COALESCE(session_meta.type, '')) = 'group' + AND (${clauses.join(' OR ')}) + )` +} diff --git a/packages/core/src/query/contact-scoring.ts b/packages/core/src/query/contact-scoring.ts new file mode 100644 index 000000000..a807a4347 --- /dev/null +++ b/packages/core/src/query/contact-scoring.ts @@ -0,0 +1,221 @@ +import type { ContactScoreBreakdown } from '@openchatlab/shared-types' + +export const MIN_PRIVATE_SESSIONS_FOR_CONTACTS = 10 + +const FRIEND_SCORE_WEIGHTS = { + privateMessage: 0.55, + privateRegularity: 0.25, + commonGroup: 0.2, +} + +const NON_FRIEND_SCORE_WEIGHTS = { + coOccurrence: 0.5, + commonGroup: 0.25, + replyInteraction: 0.25, +} + +export interface FriendScoreInput { + privateMessageCount: number + activeMonths: readonly string[] + commonGroupCount: number +} + +export interface NonFriendScoreInput { + coOccurrenceRawScore: number + commonGroupCount: number + replyInteractionCount: number + coOccurrenceCount?: number + repliesFromOwnerToContact?: number + repliesFromContactToOwner?: number +} + +export interface FriendScoreComponents { + privateMessageScore: number + privateRegularityScore: number + commonGroupScore: number + privateMessageCount?: number + activePrivateMonths?: number + commonGroupCount?: number +} + +export interface NonFriendScoreComponents { + coOccurrenceScore: number + commonGroupScore: number + replyInteractionScore: number + commonGroupCount?: number + coOccurrenceCount?: number + coOccurrenceRawScore?: number + replyInteractionCount?: number + repliesFromOwnerToContact?: number + repliesFromContactToOwner?: number +} + +export interface ContactScoringResult { + score: number + scoreBreakdown: ContactScoreBreakdown +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0 + if (value < 0) return 0 + if (value > 1) return 1 + return value +} + +function nonNegative(value: number | null | undefined): number { + return Number.isFinite(value) && value !== null && value !== undefined ? Math.max(0, value) : 0 +} + +function parseMonthIndex(month: string): number | null { + const match = /^(\d{4})-(\d{2})$/.exec(month) + if (!match) return null + + const year = Number.parseInt(match[1], 10) + const monthNumber = Number.parseInt(match[2], 10) + if (!Number.isFinite(year) || monthNumber < 1 || monthNumber > 12) return null + + return year * 12 + monthNumber - 1 +} + +function getActiveMonthIndexes(activeMonths: readonly string[]): number[] { + return [...new Set(activeMonths.map(parseMonthIndex).filter((month): month is number => month !== null))].sort( + (a, b) => a - b + ) +} + +export function rankPercentiles(items: readonly T[], valueSelector: (item: T) => number): Map { + const result = new Map() + if (items.length === 0) return result + + const values = items.map((item) => ({ item, value: nonNegative(valueSelector(item)) })) + const min = Math.min(...values.map((entry) => entry.value)) + const max = Math.max(...values.map((entry) => entry.value)) + + if (min === max) { + const percentile = max > 0 ? 1 : 0 + for (const entry of values) result.set(entry.item, percentile) + return result + } + + const sorted = [...values].sort((a, b) => a.value - b.value) + let index = 0 + while (index < sorted.length) { + let end = index + while (end + 1 < sorted.length && sorted[end + 1].value === sorted[index].value) end++ + const percentile = (index + end) / 2 / (sorted.length - 1) + for (let i = index; i <= end; i++) result.set(sorted[i].item, percentile) + index = end + 1 + } + + return result +} + +export function computePrivateRegularity(activeMonths: readonly string[]): number { + const monthIndexes = getActiveMonthIndexes(activeMonths) + if (monthIndexes.length === 0) return 0 + + const activeCount = monthIndexes.length + const spanMonths = monthIndexes[monthIndexes.length - 1] - monthIndexes[0] + 1 + + // 定期性衡量持续规律:活跃月越多越高,跨度很大但只零星出现会被均匀度压低。 + return activeCount * (activeCount / spanMonths) +} + +export function computeFriendScore(components: FriendScoreComponents): ContactScoringResult { + const privateMessageScore = clamp01(components.privateMessageScore) + const privateRegularityScore = clamp01(components.privateRegularityScore) + const commonGroupScore = clamp01(components.commonGroupScore) + const score = + FRIEND_SCORE_WEIGHTS.privateMessage * privateMessageScore + + FRIEND_SCORE_WEIGHTS.privateRegularity * privateRegularityScore + + FRIEND_SCORE_WEIGHTS.commonGroup * commonGroupScore + + return { + score, + scoreBreakdown: { + privateMessageScore, + privateRegularityScore, + commonGroupScore, + privateMessageCount: components.privateMessageCount, + activePrivateMonths: components.activePrivateMonths, + commonGroupCount: components.commonGroupCount, + }, + } +} + +export function computeFriendScores(items: readonly T[]): Map { + const messageScores = rankPercentiles(items, (item) => Math.log1p(nonNegative(item.privateMessageCount))) + const regularityByItem = new Map(items.map((item) => [item, computePrivateRegularity(item.activeMonths)])) + const regularityScores = rankPercentiles(items, (item) => regularityByItem.get(item) ?? 0) + const groupScores = rankPercentiles(items, (item) => nonNegative(item.commonGroupCount)) + const result = new Map() + + for (const item of items) { + result.set( + item, + computeFriendScore({ + privateMessageScore: messageScores.get(item) ?? 0, + privateRegularityScore: regularityScores.get(item) ?? 0, + commonGroupScore: groupScores.get(item) ?? 0, + privateMessageCount: nonNegative(item.privateMessageCount), + activePrivateMonths: getActiveMonthIndexes(item.activeMonths).length, + commonGroupCount: nonNegative(item.commonGroupCount), + }) + ) + } + + return result +} + +export function computeNonFriendScore(components: NonFriendScoreComponents): ContactScoringResult { + const coOccurrenceScore = clamp01(components.coOccurrenceScore) + const commonGroupScore = clamp01(components.commonGroupScore) + const replyInteractionScore = clamp01(components.replyInteractionScore) + const score = + NON_FRIEND_SCORE_WEIGHTS.coOccurrence * coOccurrenceScore + + NON_FRIEND_SCORE_WEIGHTS.commonGroup * commonGroupScore + + NON_FRIEND_SCORE_WEIGHTS.replyInteraction * replyInteractionScore + + return { + score, + scoreBreakdown: { + coOccurrenceScore, + commonGroupScore, + replyInteractionScore, + commonGroupCount: components.commonGroupCount, + coOccurrenceCount: components.coOccurrenceCount, + coOccurrenceRawScore: components.coOccurrenceRawScore, + replyInteractionCount: components.replyInteractionCount, + repliesFromOwnerToContact: components.repliesFromOwnerToContact, + repliesFromContactToOwner: components.repliesFromContactToOwner, + }, + } +} + +export function computeNonFriendScores( + items: readonly T[] +): Map { + const coOccurrenceScores = rankPercentiles(items, (item) => nonNegative(item.coOccurrenceRawScore)) + const groupScores = rankPercentiles(items, (item) => nonNegative(item.commonGroupCount)) + const replyScores = rankPercentiles(items, (item) => nonNegative(item.replyInteractionCount)) + const result = new Map() + + for (const item of items) { + result.set( + item, + computeNonFriendScore({ + coOccurrenceScore: coOccurrenceScores.get(item) ?? 0, + commonGroupScore: groupScores.get(item) ?? 0, + replyInteractionScore: replyScores.get(item) ?? 0, + commonGroupCount: nonNegative(item.commonGroupCount), + coOccurrenceCount: nonNegative(item.coOccurrenceCount), + coOccurrenceRawScore: nonNegative(item.coOccurrenceRawScore), + replyInteractionCount: nonNegative(item.replyInteractionCount), + repliesFromOwnerToContact: nonNegative(item.repliesFromOwnerToContact), + repliesFromContactToOwner: nonNegative(item.repliesFromContactToOwner), + }) + ) + } + + return result +} diff --git a/packages/core/src/query/filters.ts b/packages/core/src/query/filters.ts new file mode 100644 index 000000000..eaa62730c --- /dev/null +++ b/packages/core/src/query/filters.ts @@ -0,0 +1,72 @@ +/** + * SQL 查询过滤条件构建工具 + * + * 平台无关的 WHERE 子句构建器和 schema 检测工具,供所有查询模块复用。 + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '../interfaces' + +/** + * 构建时间过滤 WHERE 子句 + * @param filter 时间过滤器(包含时间范围和成员筛选) + * @param tableAlias 表别名,用于多表 JOIN 场景避免列名歧义(如 'msg') + */ +export function buildTimeFilter( + filter?: TimeFilter, + tableAlias?: string +): { clause: string; params: (number | string)[] } { + const conditions: string[] = [] + const params: (number | string)[] = [] + + const tsColumn = tableAlias ? `${tableAlias}.ts` : 'ts' + const senderIdColumn = tableAlias ? `${tableAlias}.sender_id` : 'sender_id' + + if (filter?.startTs !== undefined) { + conditions.push(`${tsColumn} >= ?`) + params.push(filter.startTs) + } + if (filter?.endTs !== undefined) { + conditions.push(`${tsColumn} <= ?`) + params.push(filter.endTs) + } + if (filter?.memberId !== undefined && filter?.memberId !== null) { + conditions.push(`${senderIdColumn} = ?`) + params.push(filter.memberId) + } + + return { + clause: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '', + params, + } +} + +/** + * Check if a table exists in the database + */ +export function hasTable(db: DatabaseAdapter, tableName: string): boolean { + const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(tableName) + return row !== undefined +} + +/** + * Check if a column exists in a table + */ +export function hasColumn(db: DatabaseAdapter, tableName: string, columnName: string): boolean { + const rows = db.pragma(`table_info(${tableName})`) as Array<{ name: string }> + if (!Array.isArray(rows)) return false + return rows.some((r) => r.name === columnName) +} + +/** + * 构建排除系统消息的过滤条件 + */ +export function buildSystemMessageFilter(existingClause: string): string { + const systemFilter = "COALESCE(m.account_name, '') != '系统消息'" + + if (existingClause.includes('WHERE')) { + return existingClause + ' AND ' + systemFilter + } else { + return ' WHERE ' + systemFilter + } +} diff --git a/packages/core/src/query/index.ts b/packages/core/src/query/index.ts new file mode 100644 index 000000000..928246414 --- /dev/null +++ b/packages/core/src/query/index.ts @@ -0,0 +1,244 @@ +export { buildTimeFilter, buildSystemMessageFilter, hasTable, hasColumn } from './filters' + +export { + isChatSessionDb, + getSessionMeta, + getSessionOverview, + getDatabaseSchema, + getChatOverview, + getSegmentMessages, + getSegmentSummaries, + buildSessionInfo, + getSessionInfo, + getSummaryCount, + getLastPlatformMessageId, + // Session index (segment) helpers + DEFAULT_SESSION_GAP_THRESHOLD, + hasSessionIndex, + getSessionIndexStats, + getChatSessionList, + getSessionsByTimeRange, + getRecentChatSessions, + loadSegmentMessages, + getSegmentSummary, + saveSegmentSummary, + updateSessionGapThreshold, + updateSessionOwnerId, + renameSession, + clearSessionIndex, + generateSessionIndex, + generateIncrementalSessionIndex, + getPrivateChatMemberAvatar, + getExportSessionData, +} from './session-queries' +export type { + SessionMeta, + SessionOverview, + SessionInfo, + CoreSessionInfo, + ChatOverviewData, + SegmentMessagesData, + SegmentSummaryData, + ChatSessionItem, + SessionIndexStats, + SessionPreviewMessage, + ExportSessionData, +} from './session-queries' + +export { + getTimeRange, + getAvailableYears, + getMemberActivity, + getHourlyActivity, + getDailyActivity, + getWeekdayActivity, + getMessageTypeStats, + getMonthlyActivity, + getYearlyActivity, + getMessageLengthDistribution, + getTextStats, + getLongMessageCount, + getMemberMonthlyTrend, + getTextLengthPercentiles, +} from './basic-queries' +export type { + MemberActivity, + HourlyActivity, + DailyActivity, + WeekdayActivity, + MessageTypeStats, + MonthlyActivity, + YearlyActivity, + MessageLengthDistribution, + TextStats, + TextLengthPercentiles, + MemberMonthlyTrend, +} from './basic-queries' + +export { + getGroupContactFacts, + getGroupRelationshipGraphFacts, + getLatestContactMessageTs, + getNonSystemMembersForContacts, + getPrivateContactFacts, + isValidContactPlatformId, + resolveOwnerMember, +} from './contact-queries' +export type { + ContactFactsOptions, + ContactMemberRef, + GroupContactFacts, + GroupRelationshipGraphFacts, + PrivateContactFacts, + RelationshipGraphEdgeFact, + RelationshipGraphMemberFact, +} from './contact-queries' + +export { + MIN_PRIVATE_SESSIONS_FOR_CONTACTS, + computeFriendScore, + computeFriendScores, + computeNonFriendScore, + computeNonFriendScores, + computePrivateRegularity, + rankPercentiles, +} from './contact-scoring' +export type { + ContactScoringResult, + FriendScoreComponents, + FriendScoreInput, + NonFriendScoreComponents, + NonFriendScoreInput, +} from './contact-scoring' + +export { + queryMessages, + searchMessagesLike, + searchMessagesByKeywords, + getRecentMessages, + getMembers, + getMembersDetailed, + executeReadonlySql, + executeSql, + getSchemaDetailed, + getMessageContext, + getSearchMessageContext, + getConversationBetween, + getMemberNameHistory, + getMembersWithAliases, + getMembersPaginated, + executeParameterizedSql, +} from './message-queries' +export type { + QueryMessagesOptions, + QueryMessagesResult, + MessageResult, + PaginatedMessages, + MemberDetailed, + ContextMessage, + ConversationData, + MemberNameHistoryEntry, + MemberWithAliases, + MembersPaginationParams, + MembersPaginatedResult, + SqlExecutionOptions, + SqlExecutionResult, + TableSchema, +} from './message-queries' + +// Shared full-message SQL, types, and mapper +export { + FULL_MSG_COLUMNS, + FULL_MSG_FROM, + FULL_MSG_SELECT, + MSG_COUNT_FROM, + SYSTEM_MSG_FILTER, + TEXT_ONLY_FILTER, + mapMessageRow, + buildMsgConditions, +} from './message-sql' +export type { FullMessageRow, MappedMessage, MsgQueryConditions } from './message-sql' + +// Shared async message query functions (platform-agnostic) +export { + fetchMessagesBefore, + fetchMessagesAfter, + searchMessagesLikeAsync, + searchMessagesWithFtsAsync, + fetchMessageContext, + fetchSearchMessageContext, + fetchAllRecentMessages, + fetchRecentTextMessages, + fetchConversationBetween, +} from './message-query-functions' +export type { + AsyncSqlExecutor, + AsyncPaginatedMessages, + AsyncMessagesWithTotal, + AsyncConversationData, +} from './message-query-functions' + +// Member write operations (merge, delete, update aliases, DDL migration) +export { updateMemberAliases, mergeMembers, deleteMember, ensureAliasesColumn, ensureAvatarColumn } from './member-ops' + +// Advanced analytics +export { + getCatchphraseAnalysis, + getMentionAnalysis, + getMentionGraph, + getLaughAnalysis, + getClusterGraph, + getRelationshipStats, + getLanguagePreferenceAnalysis, + getDragonKingAnalysis, + getDivingAnalysis, + getCheckInAnalysis, + getMemeBattleAnalysis, + getNightOwlAnalysis, + getRepeatAnalysis, +} from './advanced' +export type { + CatchphraseAnalysis, + MemberCatchphrase, + CatchphraseItem, + MentionGraphData, + MentionGraphNode, + MentionGraphLink, + ClusterGraphData, + ClusterGraphNode, + ClusterGraphLink, + ClusterGraphOptions, + RelationshipStats, + RelationshipMonthStats, + IceBreakerItem, + ResponseLatencyMember, + PerseveranceMember, + MonthlyResponseLatency, + MonthlyPerseverance, + RelationshipOptions, + NlpProvider, + PosTagResult, + LanguagePreferenceParams, + NightOwlTitle, + NightOwlRankItem, + TimeRankItem, + ConsecutiveNightRecord, + NightOwlChampion, + NightOwlAnalysis, + DragonKingRankItem, + DragonKingAnalysis, + DivingRankItem, + DivingAnalysis, + RepeatStatItem, + RepeatRateItem, + ChainLengthDistribution, + HotRepeatContent, + FastestRepeaterItem, + RepeatAnalysis, + MemeBattleRankItem, + MemeBattleRecord, + MemeBattleAnalysis, + StreakRankItem, + LoyaltyRankItem, + CheckInAnalysis, +} from './advanced' diff --git a/packages/core/src/query/member-ops.ts b/packages/core/src/query/member-ops.ts new file mode 100644 index 000000000..1c08408ee --- /dev/null +++ b/packages/core/src/query/member-ops.ts @@ -0,0 +1,150 @@ +/** + * Member write operations (merge, delete, update aliases). + * + * All functions accept a DatabaseAdapter, keeping them platform-agnostic. + * The caller is responsible for opening/closing the DB connection. + */ + +import type { DatabaseAdapter } from '../interfaces/database-adapter' + +// ==================== Helpers ==================== + +function parseAliases(raw: string | null): string[] { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + } catch { + return [] + } +} + +// ==================== Public API ==================== + +export function updateMemberAliases(db: DatabaseAdapter, memberId: number, aliases: string[]): boolean { + try { + db.prepare('UPDATE member SET aliases = ? WHERE id = ?').run(JSON.stringify(aliases), memberId) + return true + } catch { + return false + } +} + +interface MemberMergeRow { + id: number + platformId: string + accountName: string | null + groupNickname: string | null + aliases: string | null + avatar: string | null + messageCount: number +} + +/** + * Merge two members — messages and name history are reassigned to the one + * with more messages (or the lower id on tie). The secondary member is deleted. + */ +export function mergeMembers(db: DatabaseAdapter, memberId1: number, memberId2: number): boolean { + if (memberId1 === memberId2) return false + + try { + const rows = db + .prepare( + ` + SELECT + m.id, + m.platform_id as platformId, + m.account_name as accountName, + m.group_nickname as groupNickname, + m.aliases, + m.avatar, + COUNT(msg.id) as messageCount + FROM member m + LEFT JOIN message msg ON m.id = msg.sender_id + WHERE m.id IN (?, ?) + GROUP BY m.id + ` + ) + .all(memberId1, memberId2) as unknown as MemberMergeRow[] + + if (rows.length !== 2) return false + + const [memberA, memberB] = rows + let primary = memberA + let secondary = memberB + + if ( + memberB.messageCount > memberA.messageCount || + (memberB.messageCount === memberA.messageCount && memberB.id < memberA.id) + ) { + primary = memberB + secondary = memberA + } + + const mergedAliases = Array.from(new Set([...parseAliases(primary.aliases), ...parseAliases(secondary.aliases)])) + const mergedAccountName = primary.accountName || secondary.accountName + const mergedGroupNickname = primary.groupNickname || secondary.groupNickname + const mergedAvatar = primary.avatar || secondary.avatar + + db.transaction(() => { + db.prepare('UPDATE message SET sender_id = ? WHERE sender_id = ?').run(primary.id, secondary.id) + db.prepare('UPDATE member_name_history SET member_id = ? WHERE member_id = ?').run(primary.id, secondary.id) + db.prepare('UPDATE meta SET owner_id = ? WHERE owner_id = ?').run(primary.platformId, secondary.platformId) + db.prepare(`UPDATE member SET account_name = ?, group_nickname = ?, avatar = ?, aliases = ? WHERE id = ?`).run( + mergedAccountName, + mergedGroupNickname, + mergedAvatar, + JSON.stringify(mergedAliases), + primary.id + ) + db.prepare('DELETE FROM member WHERE id = ?').run(secondary.id) + }) + + return true + } catch { + return false + } +} + +/** + * Delete a member and all their messages / name history. + */ +export function deleteMember(db: DatabaseAdapter, memberId: number): boolean { + try { + db.transaction(() => { + db.prepare('DELETE FROM message WHERE sender_id = ?').run(memberId) + db.prepare('DELETE FROM member_name_history WHERE member_id = ?').run(memberId) + db.prepare('DELETE FROM member WHERE id = ?').run(memberId) + }) + return true + } catch { + return false + } +} + +/** + * Ensure the `aliases` column exists on the `member` table (DDL migration). + */ +export function ensureAliasesColumn(db: DatabaseAdapter): boolean { + const columns = db.prepare('PRAGMA table_info(member)').all() as unknown as Array<{ name: string }> + const has = columns.some((col) => col.name === 'aliases') + if (!has) { + db.exec("ALTER TABLE member ADD COLUMN aliases TEXT DEFAULT '[]'") + return true + } + return false +} + +/** + * Ensure the `avatar` column exists on the `member` table (DDL migration). + */ +export function ensureAvatarColumn(db: DatabaseAdapter): boolean { + const columns = db.prepare('PRAGMA table_info(member)').all() as unknown as Array<{ name: string }> + const has = columns.some((col) => col.name === 'avatar') + if (!has) { + db.exec('ALTER TABLE member ADD COLUMN avatar TEXT') + return true + } + return false +} diff --git a/packages/core/src/query/message-queries.ts b/packages/core/src/query/message-queries.ts new file mode 100644 index 000000000..4d35c9e5b --- /dev/null +++ b/packages/core/src/query/message-queries.ts @@ -0,0 +1,895 @@ +/** + * 消息查询模块(平台无关) + * + * 提供消息搜索、分页等基础查询能力。 + * 复杂的 FTS 搜索留在 Electron/Server 层处理(依赖分词器)。 + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '../interfaces' +import { buildTimeFilter, hasTable, hasColumn } from './filters' +import { + FULL_MSG_SELECT, + mapMessageRow, + buildMsgConditions, + type FullMessageRow, + type MappedMessage, +} from './message-sql' + +export interface MessageResult { + id: number + senderId: number + senderName: string + senderPlatformId: string + content: string + timestamp: number + type: number +} + +export interface PaginatedMessages { + messages: MessageResult[] + hasMore: boolean + total?: number +} + +export interface QueryMessagesOptions { + keyword?: string + startTs?: number + endTs?: number + senderId?: number + limit?: number + offset?: number +} + +export interface QueryMessagesResult { + messages: MessageResult[] + total: number + page: number + limit: number + totalPages: number +} + +/** + * 通用分页消息查询 + * 支持关键词、时间范围、发送者过滤 + */ +export function queryMessages(db: DatabaseAdapter, options?: QueryMessagesOptions): QueryMessagesResult { + const limit = Math.min(1000, Math.max(1, options?.limit ?? 100)) + const offset = options?.offset ?? 0 + const page = Math.floor(offset / limit) + 1 + + const conditions: string[] = ["COALESCE(m.account_name, '') != '系统消息'"] + const params: unknown[] = [] + + if (options?.keyword) { + conditions.push('msg.content LIKE ?') + params.push(`%${options.keyword}%`) + } + if (options?.startTs !== undefined) { + conditions.push('msg.ts >= ?') + params.push(options.startTs) + } + if (options?.endTs !== undefined) { + conditions.push('msg.ts <= ?') + params.push(options.endTs) + } + if (options?.senderId !== undefined) { + conditions.push('msg.sender_id = ?') + params.push(options.senderId) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + const countRow = db + .prepare( + `SELECT COUNT(*) as total + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${where}` + ) + .get(...params) as { total: number } + + const rows = db + .prepare( + `SELECT + msg.id as id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content as content, + msg.ts as timestamp, + msg.type as type + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${where} + ORDER BY msg.ts DESC + LIMIT ? OFFSET ?` + ) + .all(...params, limit, offset) as Array<{ + id: number + senderId: number + senderName: string + senderPlatformId: string + content: string + timestamp: number + type: number + }> + + const messages = rows.map((row) => ({ + id: Number(row.id), + senderId: Number(row.senderId), + senderName: String(row.senderName || ''), + senderPlatformId: String(row.senderPlatformId || ''), + content: row.content != null ? String(row.content) : '', + timestamp: Number(row.timestamp), + type: Number(row.type), + })) + + const total = countRow.total + return { + messages, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + } +} + +/** + * 基于 LIKE 的简单关键词搜索 + * 不依赖 FTS 索引,适用于 CLI/MCP 场景 + */ +export function searchMessagesLike( + db: DatabaseAdapter, + keyword: string, + options?: { limit?: number; offset?: number } +): PaginatedMessages { + const limit = options?.limit ?? 50 + const offset = options?.offset ?? 0 + + const countRow = db + .prepare( + `SELECT COUNT(*) as total + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.content LIKE ? AND COALESCE(m.account_name, '') != '系统消息'` + ) + .get(`%${keyword}%`) as { total: number } + + const rows = db + .prepare( + `SELECT + msg.id as id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content as content, + msg.ts as timestamp, + msg.type as type + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.content LIKE ? AND COALESCE(m.account_name, '') != '系统消息' + ORDER BY msg.ts DESC + LIMIT ? OFFSET ?` + ) + .all(`%${keyword}%`, limit + 1, offset) as Array<{ + id: number + senderId: number + senderName: string + senderPlatformId: string + content: string + timestamp: number + type: number + }> + + const hasMore = rows.length > limit + const messages = rows.slice(0, limit).map((row) => ({ + id: Number(row.id), + senderId: Number(row.senderId), + senderName: String(row.senderName || ''), + senderPlatformId: String(row.senderPlatformId || ''), + content: row.content != null ? String(row.content) : '', + timestamp: Number(row.timestamp), + type: Number(row.type), + })) + + return { messages, hasMore, total: countRow.total } +} + +/** + * 多关键词 LIKE 子串搜索(关键词之间 OR,命中任一即返回) + * + * 适用于 CLI/MCP/Web 等无 FTS 的场景,行为与 Electron worker 的 + * searchMessagesLikeAsync 一致:支持时间范围、发送者过滤,并排除系统消息。 + */ +export function searchMessagesByKeywords( + db: DatabaseAdapter, + keywords: string[], + options?: { startTs?: number; endTs?: number; senderId?: number; limit?: number; offset?: number } +): PaginatedMessages { + const limit = options?.limit ?? 50 + const offset = options?.offset ?? 0 + const cleaned = keywords.map((k) => k.trim()).filter((k) => k.length > 0) + + const { clause, params } = buildMsgConditions({ + startTs: options?.startTs, + endTs: options?.endTs, + senderId: options?.senderId, + keywords: cleaned.length > 0 ? cleaned : undefined, + systemFilter: true, + }) + + const countRow = db + .prepare(`SELECT COUNT(*) as total FROM message msg JOIN member m ON msg.sender_id = m.id WHERE 1=1 ${clause}`) + .get(...params) as { total: number } + + const rows = db + .prepare(`${FULL_MSG_SELECT} WHERE 1=1 ${clause} ORDER BY msg.ts DESC LIMIT ? OFFSET ?`) + .all(...params, limit + 1, offset) as unknown as FullMessageRow[] + + const hasMore = rows.length > limit + const messages = rows.slice(0, limit).map((row) => { + const mapped = mapMessageRow(row) + return { + id: mapped.id, + senderId: mapped.senderId, + senderName: mapped.senderName, + senderPlatformId: mapped.senderPlatformId, + content: mapped.content, + timestamp: mapped.timestamp, + type: mapped.type, + } + }) + + return { messages, hasMore, total: countRow.total } +} + +/** + * 获取最近 N 条消息 + */ +export function getRecentMessages(db: DatabaseAdapter, options?: { limit?: number }): MessageResult[] { + const limit = options?.limit ?? 50 + + const rows = db + .prepare( + `SELECT + msg.id as id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content as content, + msg.ts as timestamp, + msg.type as type + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE COALESCE(m.account_name, '') != '系统消息' + ORDER BY msg.ts DESC + LIMIT ?` + ) + .all(limit) as Array<{ + id: number + senderId: number + senderName: string + senderPlatformId: string + content: string + timestamp: number + type: number + }> + + return rows.map((row) => ({ + id: Number(row.id), + senderId: Number(row.senderId), + senderName: String(row.senderName || ''), + senderPlatformId: String(row.senderPlatformId || ''), + content: row.content != null ? String(row.content) : '', + timestamp: Number(row.timestamp), + type: Number(row.type), + })) +} + +/** + * 获取成员列表 + */ +export function getMembers( + db: DatabaseAdapter +): Array<{ id: number; platformId: string; name: string; messageCount: number }> { + return db + .prepare( + `SELECT + m.id as id, + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + (SELECT COUNT(*) FROM message WHERE sender_id = m.id) as messageCount + FROM member m + WHERE COALESCE(m.account_name, '') != '系统消息' + ORDER BY messageCount DESC` + ) + .all() as Array<{ id: number; platformId: string; name: string; messageCount: number }> +} + +export interface MemberDetailed { + id: number + platformId: string + accountName: string + groupNickname: string | null + messageCount: number +} + +/** + * 获取完整字段的成员列表(含 accountName、groupNickname) + */ +export function getMembersDetailed(db: DatabaseAdapter): MemberDetailed[] { + const rows = db + .prepare( + `SELECT + m.id as id, + m.platform_id as platformId, + COALESCE(m.account_name, m.platform_id) as accountName, + m.group_nickname as groupNickname, + (SELECT COUNT(*) FROM message WHERE sender_id = m.id) as messageCount + FROM member m + WHERE COALESCE(m.account_name, '') != '系统消息' + ORDER BY messageCount DESC` + ) + .all() as Array<{ + id: number + platformId: string + accountName: string + groupNickname: string | null + messageCount: number + }> + + return rows.map((row) => ({ + id: Number(row.id), + platformId: String(row.platformId), + accountName: String(row.accountName || row.platformId), + groupNickname: row.groupNickname ? String(row.groupNickname) : null, + messageCount: Number(row.messageCount), + })) +} + +/** + * 执行只读 SQL 查询(SQL Lab)— 保留向后兼容签名 + */ +export function executeReadonlySql( + db: DatabaseAdapter, + sql: string, + maxRows: number = 1000 +): { columns: string[]; rows: Record[]; rowCount: number; truncated: boolean } { + const result = executeSql(db, sql, { maxRows }) + return { + columns: result.columns, + rows: result.rows as Record[], + rowCount: result.rowCount, + truncated: result.truncated, + } +} + +// ==================== Unified SQL Execution ==================== + +export interface SqlExecutionOptions { + /** Maximum rows to return. 0 = no limit. Default: 1000. */ + maxRows?: number + /** Return rows as 2D arrays instead of objects. Default: false. */ + columnar?: boolean + /** Include execution duration in result. Default: false. */ + timing?: boolean +} + +export interface SqlExecutionResult { + columns: string[] + rows: Record[] | unknown[][] + rowCount: number + truncated: boolean + duration?: number +} + +/** + * Unified readonly SQL execution. + * + * Safety: uses stmt.readonly when available (better-sqlite3 native check), + * falls back to keyword denylist for adapters that don't expose it. + */ +export function executeSql(db: DatabaseAdapter, sql: string, options?: SqlExecutionOptions): SqlExecutionResult { + const maxRows = options?.maxRows ?? 1000 + const columnar = options?.columnar ?? false + const timing = options?.timing ?? false + + const trimmed = sql.trim() + + const startTime = timing ? Date.now() : 0 + + const stmt = db.prepare(trimmed) + + if (stmt.readonly === false) { + throw new Error('Only read-only statements are allowed (SELECT / WITH)') + } + + if (stmt.readonly === undefined) { + const forbidden = /^\s*(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH|DETACH|REINDEX|VACUUM|PRAGMA)/i + if (forbidden.test(trimmed)) { + throw new Error('Only SELECT queries are allowed') + } + } + + const needsLimit = maxRows > 0 && !/\bLIMIT\b/i.test(trimmed) + let allRows: Record[] + + if (needsLimit) { + const safeSql = `${trimmed} LIMIT ${maxRows + 1}` + allRows = db.prepare(safeSql).all() as Record[] + } else { + allRows = stmt.all() as Record[] + } + + const truncated = maxRows > 0 && allRows.length > maxRows + const resultRows = truncated ? allRows.slice(0, maxRows) : allRows + const columns = resultRows.length > 0 ? Object.keys(resultRows[0]) : [] + + const duration = timing ? Date.now() - startTime : undefined + + if (columnar) { + const rows2d = resultRows.map((row) => columns.map((col) => row[col])) + return { columns, rows: rows2d, rowCount: rows2d.length, truncated, duration } + } + + return { columns, rows: resultRows, rowCount: resultRows.length, truncated, duration } +} + +/** Table schema with column details */ +export interface TableSchema { + name: string + columns: Array<{ + name: string + type: string + notnull: boolean + pk: boolean + }> +} + +/** + * Get database schema with column-level details. + * Returns table names + full column info via PRAGMA table_info. + */ +export function getSchemaDetailed(db: DatabaseAdapter): TableSchema[] { + const tables = db + .prepare( + `SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY name` + ) + .all() as Array<{ name: string }> + + return tables.map((table) => { + const columns = db.prepare(`PRAGMA table_info('${table.name}')`).all() as Array<{ + cid: number + name: string + type: string + notnull: number + dflt_value: unknown + pk: number + }> + + return { + name: table.name, + columns: columns.map((col) => ({ + name: col.name, + type: col.type, + notnull: col.notnull === 1, + pk: col.pk === 1, + })), + } + }) +} + +// ==================== Context & Conversation Queries ==================== + +export interface ContextMessage { + id: number + senderId: number + senderName: string + senderPlatformId: string + content: string | null + timestamp: number +} + +export interface ConversationData { + messages: ContextMessage[] + total: number + member1Name: string + member2Name: string +} + +export interface MemberNameHistoryEntry { + nameType: string + name: string + startTs: number + endTs: number | null +} + +function getMemberNameHistoryFromMessages(db: DatabaseAdapter, memberId: number): MemberNameHistoryEntry[] { + const rows = db + .prepare( + `SELECT + sender_account_name as accountName, + sender_group_nickname as groupNickname, + MIN(ts) as startTs, + MAX(ts) as endTs + FROM message + WHERE sender_id = ? + GROUP BY sender_account_name, sender_group_nickname + ORDER BY startTs` + ) + .all(memberId) as unknown as Array<{ + accountName: string | null + groupNickname: string | null + startTs: number + endTs: number | null + }> + + const history: MemberNameHistoryEntry[] = [] + for (const row of rows) { + if (row.accountName) { + history.push({ nameType: 'account_name', name: row.accountName, startTs: row.startTs, endTs: row.endTs }) + } + if (row.groupNickname) { + history.push({ nameType: 'group_nickname', name: row.groupNickname, startTs: row.startTs, endTs: row.endTs }) + } + } + return history +} + +export interface MemberWithAliases { + id: number + platformId: string + accountName: string | null + groupNickname: string | null + aliases: string[] + avatar: string | null + messageCount: number +} + +export interface MembersPaginationParams { + page?: number + pageSize?: number + search?: string + sortOrder?: 'asc' | 'desc' +} + +export interface MembersPaginatedResult { + members: MemberWithAliases[] + total: number + page: number + pageSize: number + totalPages: number +} + +/** + * Batch-load messages by IDs with full sender info (avatar, aliases, reply, etc.) + */ +function hydrateMessagesByIds(db: DatabaseAdapter, ids: number[]): MappedMessage[] { + if (ids.length === 0) return [] + const placeholders = ids.map(() => '?').join(', ') + const rows = db + .prepare(`${FULL_MSG_SELECT} WHERE msg.id IN (${placeholders}) ORDER BY msg.id ASC`) + .all(...ids) as unknown as FullMessageRow[] + return rows.map(mapMessageRow) +} + +/** + * Get surrounding context messages for given message IDs. + * Uses simple id-based ordering (not session-aware). + */ +export function getMessageContext( + db: DatabaseAdapter, + messageIds: number[], + contextSize: number = 20 +): MappedMessage[] { + if (messageIds.length === 0) return [] + + const contextIds = new Set() + + for (const messageId of messageIds) { + contextIds.add(messageId) + + const beforeRows = db + .prepare('SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?') + .all(messageId, contextSize) as { id: number }[] + beforeRows.forEach((r) => contextIds.add(r.id)) + + const afterRows = db + .prepare('SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?') + .all(messageId, contextSize) as { id: number }[] + afterRows.forEach((r) => contextIds.add(r.id)) + } + + return hydrateMessagesByIds(db, Array.from(contextIds)) +} + +/** + * Get context messages around search results. + * Session-aware when message_context table is available, falls back to id-based ordering. + */ +export function getSearchMessageContext( + db: DatabaseAdapter, + messageIds: number[], + contextBefore: number = 2, + contextAfter: number = 2 +): MappedMessage[] { + if (messageIds.length === 0) return [] + + const contextIds = new Set() + + const hasSessionData = + hasTable(db, 'message_context') && + (db.prepare('SELECT 1 FROM message_context LIMIT 1').get() as Record | undefined) !== undefined + + for (const messageId of messageIds) { + contextIds.add(messageId) + + if (hasSessionData) { + const sessionRow = db.prepare('SELECT segment_id FROM message_context WHERE message_id = ?').get(messageId) as + | { segment_id: number } + | undefined + + if (sessionRow) { + if (contextBefore > 0) { + const rows = db + .prepare( + `SELECT mc.message_id as id FROM message_context mc + WHERE mc.segment_id = ? AND mc.message_id < ? + ORDER BY mc.message_id DESC LIMIT ?` + ) + .all(sessionRow.segment_id, messageId, contextBefore) as { id: number }[] + rows.forEach((r) => contextIds.add(r.id)) + } + if (contextAfter > 0) { + const rows = db + .prepare( + `SELECT mc.message_id as id FROM message_context mc + WHERE mc.segment_id = ? AND mc.message_id > ? + ORDER BY mc.message_id ASC LIMIT ?` + ) + .all(sessionRow.segment_id, messageId, contextAfter) as { id: number }[] + rows.forEach((r) => contextIds.add(r.id)) + } + continue + } + } + + if (contextBefore > 0) { + const rows = db + .prepare('SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?') + .all(messageId, contextBefore) as { id: number }[] + rows.forEach((r) => contextIds.add(r.id)) + } + if (contextAfter > 0) { + const rows = db + .prepare('SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?') + .all(messageId, contextAfter) as { id: number }[] + rows.forEach((r) => contextIds.add(r.id)) + } + } + + return hydrateMessagesByIds(db, Array.from(contextIds)) +} + +/** + * Get conversation messages between two members + */ +export function getConversationBetween( + db: DatabaseAdapter, + memberId1: number, + memberId2: number, + filter?: TimeFilter, + limit: number = 100 +): ConversationData { + const member1 = db + .prepare('SELECT COALESCE(group_nickname, account_name, platform_id) as name FROM member WHERE id = ?') + .get(memberId1) as { name: string } | undefined + + const member2 = db + .prepare('SELECT COALESCE(group_nickname, account_name, platform_id) as name FROM member WHERE id = ?') + .get(memberId2) as { name: string } | undefined + + if (!member1 || !member2) { + return { messages: [], total: 0, member1Name: '', member2Name: '' } + } + + const { clause: timeClause, params: timeParams } = buildTimeFilter(filter, 'msg') + const timeCondition = timeClause ? timeClause.replace('WHERE', 'AND') : '' + + const countSql = ` + SELECT COUNT(*) as total FROM message msg + WHERE msg.sender_id IN (?, ?) ${timeCondition} + AND msg.content IS NOT NULL AND msg.content != '' + ` + const totalRow = db.prepare(countSql).get(memberId1, memberId2, ...timeParams) as { total: number } + + const sql = ` + SELECT + msg.id as id, m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + msg.content as content, msg.ts as timestamp + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.sender_id IN (?, ?) ${timeCondition} + AND msg.content IS NOT NULL AND msg.content != '' + ORDER BY msg.ts DESC LIMIT ? + ` + const rows = db.prepare(sql).all(memberId1, memberId2, ...timeParams, limit) as unknown as ContextMessage[] + + return { + messages: rows.reverse(), + total: totalRow?.total || 0, + member1Name: member1.name, + member2Name: member2.name, + } +} + +/** + * Get name change history for a member + */ +export function getMemberNameHistory(db: DatabaseAdapter, memberId: number): MemberNameHistoryEntry[] { + if (!hasTable(db, 'member_name_history')) return getMemberNameHistoryFromMessages(db, memberId) + + const history = db + .prepare( + `SELECT name_type as nameType, name, start_ts as startTs, end_ts as endTs + FROM member_name_history WHERE member_id = ? ORDER BY start_ts DESC` + ) + .all(memberId) as unknown as MemberNameHistoryEntry[] + + return history.length > 0 ? history : getMemberNameHistoryFromMessages(db, memberId) +} + +/** + * Get member list with aliases, avatar and detailed info + */ +export function getMembersWithAliases(db: DatabaseAdapter): MemberWithAliases[] { + const aliasesAvailable = hasColumn(db, 'member', 'aliases') + const avatarAvailable = hasColumn(db, 'member', 'avatar') + + const aliasesSelect = aliasesAvailable ? 'm.aliases' : 'NULL as aliases' + const avatarSelect = avatarAvailable ? 'm.avatar' : 'NULL as avatar' + const rows = db + .prepare( + `SELECT + m.id, m.platform_id as platformId, + m.account_name as accountName, m.group_nickname as groupNickname, + ${aliasesSelect}, ${avatarSelect}, + COUNT(msg.id) as messageCount + FROM member m + LEFT JOIN message msg ON m.id = msg.sender_id + WHERE COALESCE(m.group_nickname, m.account_name, m.platform_id) != '系统消息' + GROUP BY m.id ORDER BY messageCount DESC` + ) + .all() as Array<{ + id: number + platformId: string + accountName: string | null + groupNickname: string | null + aliases: string | null + avatar: string | null + messageCount: number + }> + + return rows.map(mapMemberRow) +} + +/** + * Paginated member list with search and sort. + */ +export function getMembersPaginated(db: DatabaseAdapter, params: MembersPaginationParams): MembersPaginatedResult { + const page = Math.max(1, params.page ?? 1) + const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20)) + const search = params.search?.trim() || '' + const sortDirection = params.sortOrder === 'asc' ? 'ASC' : 'DESC' + + const aliasesAvailable = hasColumn(db, 'member', 'aliases') + const avatarAvailable = hasColumn(db, 'member', 'avatar') + const aliasesSelect = aliasesAvailable ? 'm.aliases' : 'NULL as aliases' + const avatarSelect = avatarAvailable ? 'm.avatar' : 'NULL as avatar' + + let searchClause = '' + const searchParams: unknown[] = [] + if (search) { + const clauses = ['m.account_name LIKE ?', 'm.group_nickname LIKE ?', 'm.platform_id LIKE ?'] + const like = `%${search}%` + searchParams.push(like, like, like) + if (aliasesAvailable) { + clauses.push('m.aliases LIKE ?') + searchParams.push(like) + } + searchClause = `AND (${clauses.join(' OR ')})` + } + + const systemFilter = "COALESCE(m.group_nickname, m.account_name, m.platform_id) != '系统消息'" + + const countRow = db + .prepare( + `SELECT COUNT(*) as total FROM ( + SELECT m.id FROM member m + LEFT JOIN message msg ON m.id = msg.sender_id + WHERE ${systemFilter} ${searchClause} + GROUP BY m.id + )` + ) + .get(...searchParams) as { total: number } | undefined + + const total = countRow?.total ?? 0 + const totalPages = Math.ceil(total / pageSize) + const offset = (page - 1) * pageSize + + const rows = db + .prepare( + `SELECT + m.id, m.platform_id as platformId, + m.account_name as accountName, m.group_nickname as groupNickname, + ${aliasesSelect}, ${avatarSelect}, + COUNT(msg.id) as messageCount + FROM member m + LEFT JOIN message msg ON m.id = msg.sender_id + WHERE ${systemFilter} ${searchClause} + GROUP BY m.id + ORDER BY messageCount ${sortDirection} + LIMIT ? OFFSET ?` + ) + .all(...searchParams, pageSize, offset) as Array<{ + id: number + platformId: string + accountName: string | null + groupNickname: string | null + aliases: string | null + avatar: string | null + messageCount: number + }> + + return { members: rows.map(mapMemberRow), total, page, pageSize, totalPages } +} + +function mapMemberRow(row: { + id: number + platformId: string + accountName: string | null + groupNickname: string | null + aliases: string | null + avatar: string | null + messageCount: number +}): MemberWithAliases { + return { + id: row.id, + platformId: row.platformId, + accountName: row.accountName, + groupNickname: row.groupNickname, + aliases: row.aliases ? JSON.parse(row.aliases) : [], + avatar: row.avatar ?? null, + messageCount: row.messageCount, + } +} + +/** + * Execute a parameterized read-only SQL query with named bindings. + * Used by SQL analysis tools to run predefined queries with user-supplied parameters. + */ +export function executeParameterizedSql>( + db: DatabaseAdapter, + query: string, + params: Record = {} +): T[] { + const trimmed = query.trim() + + const forbidden = /^\s*(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH|DETACH|REINDEX|VACUUM|PRAGMA)/i + if (forbidden.test(trimmed)) { + throw new Error('Only SELECT queries are allowed') + } + + const stmt = db.prepare(trimmed) + + if (stmt.readonly === false) { + throw new Error('Only READ-ONLY statements are allowed') + } + + return stmt.all(params) as T[] +} diff --git a/packages/core/src/query/message-query-functions.ts b/packages/core/src/query/message-query-functions.ts new file mode 100644 index 000000000..f6d91ea70 --- /dev/null +++ b/packages/core/src/query/message-query-functions.ts @@ -0,0 +1,371 @@ +/** + * Shared async message query functions. + * + * Platform-agnostic query logic that both Electron (Worker) and CLI Web (pluginQuery) + * consume via their respective AsyncSqlExecutor implementations. + * + * SQL templates, row mapping, and condition builders come from ./message-sql.ts. + * FTS/tokenisation search remains platform-specific — only LIKE-based search is shared here. + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import { + FULL_MSG_SELECT, + FULL_MSG_FROM, + MSG_COUNT_FROM, + mapMessageRow, + buildMsgConditions, + type FullMessageRow, + type MappedMessage, +} from './message-sql' + +export interface AsyncSqlExecutor { + all(sql: string, params?: unknown[]): Promise + get(sql: string, params?: unknown[]): Promise +} + +// ==================== Result types ==================== + +export interface AsyncPaginatedMessages { + messages: MappedMessage[] + hasMore: boolean +} + +export interface AsyncMessagesWithTotal { + messages: MappedMessage[] + total: number +} + +export interface AsyncConversationData { + messages: MappedMessage[] + total: number + member1Name: string + member2Name: string +} + +// ==================== Internal helpers ==================== + +function filterConditions( + filter?: TimeFilter, + senderId?: number, + keywords?: string[] +): { clause: string; params: unknown[] } { + return buildMsgConditions({ + startTs: filter?.startTs, + endTs: filter?.endTs, + senderId, + memberId: filter?.memberId, + keywords, + }) +} + +// ==================== Query functions ==================== + +/** + * Fetch N messages before a given id (infinite scroll upward). + * Results are returned in ascending order (oldest → newest). + */ +export async function fetchMessagesBefore( + executor: AsyncSqlExecutor, + beforeId: number, + limit: number = 50, + filter?: TimeFilter, + senderId?: number, + keywords?: string[] +): Promise { + const { clause, params } = filterConditions(filter, senderId, keywords) + const sql = `${FULL_MSG_SELECT} WHERE msg.id < ? ${clause} ORDER BY msg.id DESC LIMIT ?` + const rows = await executor.all(sql, [beforeId, ...params, limit + 1]) + const hasMore = rows.length > limit + const sliced = hasMore ? rows.slice(0, limit) : rows + return { messages: sliced.map(mapMessageRow).reverse(), hasMore } +} + +/** + * Fetch N messages after a given id (infinite scroll downward). + * Results are returned in ascending order. + */ +export async function fetchMessagesAfter( + executor: AsyncSqlExecutor, + afterId: number, + limit: number = 50, + filter?: TimeFilter, + senderId?: number, + keywords?: string[] +): Promise { + const { clause, params } = filterConditions(filter, senderId, keywords) + const sql = `${FULL_MSG_SELECT} WHERE msg.id > ? ${clause} ORDER BY msg.id ASC LIMIT ?` + const rows = await executor.all(sql, [afterId, ...params, limit + 1]) + const hasMore = rows.length > limit + const sliced = hasMore ? rows.slice(0, limit) : rows + return { messages: sliced.map(mapMessageRow), hasMore } +} + +/** + * LIKE-based keyword search with count + pagination. + * FTS search is NOT included here — platforms should handle FTS themselves + * and fall back to this function when FTS is unavailable. + */ +/** + * FTS5-based message search. The matchQuery must be pre-tokenized for the platform's FTS tokenizer. + */ +export async function searchMessagesWithFtsAsync( + executor: AsyncSqlExecutor, + matchQuery: string, + filter?: TimeFilter, + limit: number = 20, + offset: number = 0, + senderId?: number +): Promise { + const { clause, params } = filterConditions(filter, senderId) + + const countSql = `SELECT COUNT(*) as total ${MSG_COUNT_FROM} WHERE msg.id IN (SELECT rowid FROM message_fts WHERE content MATCH ?) ${clause}` + const countRow = await executor.get<{ total: number }>(countSql, [matchQuery, ...params]) + const total = countRow?.total ?? 0 + + const sql = `${FULL_MSG_SELECT} WHERE msg.id IN (SELECT rowid FROM message_fts WHERE content MATCH ?) ${clause} ORDER BY msg.ts DESC LIMIT ? OFFSET ?` + const rows = await executor.all(sql, [matchQuery, ...params, limit, offset]) + return { messages: rows.map(mapMessageRow), total } +} + +export async function searchMessagesLikeAsync( + executor: AsyncSqlExecutor, + keywords: string[], + filter?: TimeFilter, + limit: number = 20, + offset: number = 0, + senderId?: number +): Promise { + const { clause, params } = filterConditions(filter, senderId, keywords) + + const countSql = `SELECT COUNT(*) as total ${MSG_COUNT_FROM} WHERE 1=1 ${clause}` + const countRow = await executor.get<{ total: number }>(countSql, params) + const total = countRow?.total ?? 0 + + const sql = `${FULL_MSG_SELECT} WHERE 1=1 ${clause} ORDER BY msg.ts DESC LIMIT ? OFFSET ?` + const rows = await executor.all(sql, [...params, limit, offset]) + return { messages: rows.map(mapMessageRow), total } +} + +/** + * Get surrounding context messages for given message IDs. + * Uses simple id-based ordering (not session-aware). + */ +export async function fetchMessageContext( + executor: AsyncSqlExecutor, + messageIds: number | number[], + contextSize: number = 20 +): Promise { + const ids = Array.isArray(messageIds) ? messageIds : [messageIds] + if (ids.length === 0) return [] + + const allIds = new Set() + + for (const id of ids) { + allIds.add(id) + if (contextSize > 0) { + const before = await executor.all<{ id: number }>( + 'SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?', + [id, contextSize] + ) + before.forEach((r) => allIds.add(r.id)) + + const after = await executor.all<{ id: number }>('SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?', [ + id, + contextSize, + ]) + after.forEach((r) => allIds.add(r.id)) + } + } + + const idList = Array.from(allIds).sort((a, b) => a - b) + if (idList.length === 0) return [] + + const placeholders = idList.map(() => '?').join(', ') + const sql = `${FULL_MSG_SELECT} WHERE msg.id IN (${placeholders}) ORDER BY msg.id ASC` + const rows = await executor.all(sql, idList) + return rows.map(mapMessageRow) +} + +/** + * Get context messages around search results. + * Session-aware when message_context table is available, falls back to id-based. + */ +export async function fetchSearchMessageContext( + executor: AsyncSqlExecutor, + messageIds: number[], + contextBefore: number = 2, + contextAfter: number = 2 +): Promise { + if (messageIds.length === 0) return [] + + const contextIds = new Set() + + const sessionCheck = await executor.get>( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='message_context'", + [] + ) + let hasSessionData = false + if (sessionCheck) { + const dataCheck = await executor.get>('SELECT 1 FROM message_context LIMIT 1', []) + hasSessionData = dataCheck !== undefined + } + + for (const messageId of messageIds) { + contextIds.add(messageId) + + if (hasSessionData) { + const sessionRow = await executor.get<{ segment_id: number }>( + 'SELECT segment_id FROM message_context WHERE message_id = ?', + [messageId] + ) + + if (sessionRow) { + if (contextBefore > 0) { + const rows = await executor.all<{ id: number }>( + `SELECT mc.message_id as id FROM message_context mc + WHERE mc.segment_id = ? AND mc.message_id < ? + ORDER BY mc.message_id DESC LIMIT ?`, + [sessionRow.segment_id, messageId, contextBefore] + ) + rows.forEach((r) => contextIds.add(r.id)) + } + if (contextAfter > 0) { + const rows = await executor.all<{ id: number }>( + `SELECT mc.message_id as id FROM message_context mc + WHERE mc.segment_id = ? AND mc.message_id > ? + ORDER BY mc.message_id ASC LIMIT ?`, + [sessionRow.segment_id, messageId, contextAfter] + ) + rows.forEach((r) => contextIds.add(r.id)) + } + continue + } + } + + if (contextBefore > 0) { + const rows = await executor.all<{ id: number }>('SELECT id FROM message WHERE id < ? ORDER BY id DESC LIMIT ?', [ + messageId, + contextBefore, + ]) + rows.forEach((r) => contextIds.add(r.id)) + } + if (contextAfter > 0) { + const rows = await executor.all<{ id: number }>('SELECT id FROM message WHERE id > ? ORDER BY id ASC LIMIT ?', [ + messageId, + contextAfter, + ]) + rows.forEach((r) => contextIds.add(r.id)) + } + } + + const idList = Array.from(contextIds).sort((a, b) => a - b) + if (idList.length === 0) return [] + + const placeholders = idList.map(() => '?').join(', ') + const sql = `${FULL_MSG_SELECT} WHERE msg.id IN (${placeholders}) ORDER BY msg.ts ASC, msg.id ASC` + const rows = await executor.all(sql, idList) + return rows.map(mapMessageRow) +} + +/** + * Get all recent messages (message viewer — includes all message types). + * Results are returned in ascending order (oldest → newest). + */ +export async function fetchAllRecentMessages( + executor: AsyncSqlExecutor, + filter?: TimeFilter, + limit: number = 100 +): Promise { + const { clause, params } = filterConditions(filter) + + const countSql = `SELECT COUNT(*) as total ${MSG_COUNT_FROM} WHERE 1=1 ${clause}` + const countRow = await executor.get<{ total: number }>(countSql, params) + const total = countRow?.total ?? 0 + + const sql = `${FULL_MSG_SELECT} WHERE 1=1 ${clause} ORDER BY msg.ts DESC LIMIT ?` + const rows = await executor.all(sql, [...params, limit]) + return { messages: rows.map(mapMessageRow).reverse(), total } +} + +/** + * Get recent text-only messages (AI Agent use — excludes system messages and non-text). + * Results are returned in ascending order (oldest → newest). + */ +export async function fetchRecentTextMessages( + executor: AsyncSqlExecutor, + filter?: TimeFilter, + limit: number = 100 +): Promise { + const { clause, params } = buildMsgConditions({ + startTs: filter?.startTs, + endTs: filter?.endTs, + memberId: filter?.memberId, + systemFilter: true, + textOnlyFilter: true, + }) + + const countSql = `SELECT COUNT(*) as total ${MSG_COUNT_FROM} WHERE 1=1 ${clause}` + const countRow = await executor.get<{ total: number }>(countSql, params) + const total = countRow?.total ?? 0 + + const sql = `${FULL_MSG_SELECT} WHERE 1=1 ${clause} ORDER BY msg.ts DESC LIMIT ?` + const rows = await executor.all(sql, [...params, limit]) + return { messages: rows.map(mapMessageRow).reverse(), total } +} + +/** + * Get conversation messages between two members. + */ +export async function fetchConversationBetween( + executor: AsyncSqlExecutor, + memberId1: number, + memberId2: number, + filter?: TimeFilter, + limit: number = 100 +): Promise { + const member1 = await executor.get<{ name: string }>( + 'SELECT COALESCE(group_nickname, account_name, platform_id) as name FROM member WHERE id = ?', + [memberId1] + ) + const member2 = await executor.get<{ name: string }>( + 'SELECT COALESCE(group_nickname, account_name, platform_id) as name FROM member WHERE id = ?', + [memberId2] + ) + + if (!member1 || !member2) { + return { messages: [], total: 0, member1Name: '', member2Name: '' } + } + + const { clause, params } = buildMsgConditions({ + startTs: filter?.startTs, + endTs: filter?.endTs, + memberId: filter?.memberId, + }) + + const countSql = ` + SELECT COUNT(*) as total ${FULL_MSG_FROM} + WHERE msg.sender_id IN (?, ?) + ${clause} + AND msg.content IS NOT NULL AND msg.content != '' + ` + const countRow = await executor.get<{ total: number }>(countSql, [memberId1, memberId2, ...params]) + const total = countRow?.total ?? 0 + + const sql = ` + ${FULL_MSG_SELECT} + WHERE msg.sender_id IN (?, ?) + ${clause} + AND msg.content IS NOT NULL AND msg.content != '' + ORDER BY msg.ts DESC + LIMIT ? + ` + const rows = await executor.all(sql, [memberId1, memberId2, ...params, limit]) + + return { + messages: rows.map(mapMessageRow).reverse(), + total, + member1Name: member1.name, + member2Name: member2.name, + } +} diff --git a/packages/core/src/query/message-sql.ts b/packages/core/src/query/message-sql.ts new file mode 100644 index 000000000..59916d528 --- /dev/null +++ b/packages/core/src/query/message-sql.ts @@ -0,0 +1,148 @@ +/** + * Shared full-message SQL template, types, and row mapper. + * + * Single source of truth for all platforms (Electron worker, CLI Web FetchMessageAdapter). + * Each platform imports these constants and the mapper; only the SQL execution + * mechanism differs (direct db.prepare vs pluginQuery HTTP). + */ + +// ==================== SQL fragments ==================== + +export const FULL_MSG_COLUMNS = ` + msg.id, + m.id as senderId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + m.platform_id as senderPlatformId, + COALESCE(m.aliases, '[]') as aliasesJson, + m.avatar as senderAvatar, + msg.content, + msg.ts as timestamp, + msg.type, + msg.reply_to_message_id as replyToMessageId, + reply_msg.content as replyToContent, + COALESCE(reply_m.group_nickname, reply_m.account_name, reply_m.platform_id) as replyToSenderName` + +export const FULL_MSG_FROM = ` + FROM message msg + JOIN member m ON msg.sender_id = m.id + LEFT JOIN message reply_msg ON msg.reply_to_message_id = reply_msg.platform_message_id + LEFT JOIN member reply_m ON reply_msg.sender_id = reply_m.id` + +export const FULL_MSG_SELECT = `SELECT ${FULL_MSG_COLUMNS} ${FULL_MSG_FROM}` + +export const MSG_COUNT_FROM = `FROM message msg JOIN member m ON msg.sender_id = m.id` + +export const SYSTEM_MSG_FILTER = "COALESCE(m.account_name, '') != '系统消息'" +export const TEXT_ONLY_FILTER = "msg.type = 0 AND msg.content IS NOT NULL AND msg.content != ''" + +// ==================== Types ==================== + +export interface FullMessageRow { + id: number + senderId: number + senderName: string + senderPlatformId: string + aliasesJson: string + senderAvatar: string | null + content: string | null + timestamp: number + type: number + replyToMessageId: string | null + replyToContent: string | null + replyToSenderName: string | null +} + +export interface MappedMessage { + id: number + senderId: number + senderName: string + senderPlatformId: string + senderAliases: string[] + senderAvatar: string | null + content: string + timestamp: number + type: number + replyToMessageId: string | null + replyToContent: string | null + replyToSenderName: string | null +} + +// ==================== Row mapper ==================== + +export function mapMessageRow(row: FullMessageRow): MappedMessage { + let senderAliases: string[] = [] + try { + const parsed = JSON.parse(row.aliasesJson || '[]') + if (Array.isArray(parsed)) senderAliases = parsed + } catch { + /* ignore malformed JSON */ + } + + return { + id: Number(row.id), + senderId: Number(row.senderId), + senderName: String(row.senderName || ''), + senderPlatformId: String(row.senderPlatformId || ''), + senderAliases, + senderAvatar: row.senderAvatar || null, + content: row.content != null ? String(row.content) : '', + timestamp: Number(row.timestamp), + type: Number(row.type), + replyToMessageId: row.replyToMessageId || null, + replyToContent: row.replyToContent || null, + replyToSenderName: row.replyToSenderName || null, + } +} + +// ==================== Query builders ==================== + +export interface MsgQueryConditions { + clause: string + params: unknown[] +} + +export function buildMsgConditions(options?: { + startTs?: number + endTs?: number + senderId?: number + memberId?: number | null + keywords?: string[] + systemFilter?: boolean + textOnlyFilter?: boolean +}): MsgQueryConditions { + const conds: string[] = [] + const params: unknown[] = [] + + if (options?.startTs != null) { + conds.push('msg.ts >= ?') + params.push(options.startTs) + } + if (options?.endTs != null) { + conds.push('msg.ts <= ?') + params.push(options.endTs) + } + if (options?.senderId != null) { + conds.push('msg.sender_id = ?') + params.push(options.senderId) + } + if (options?.memberId != null) { + conds.push('msg.sender_id = ?') + params.push(options.memberId) + } + if (options?.keywords && options.keywords.length > 0) { + const kwConds = options.keywords.map(() => 'msg.content LIKE ?') + conds.push(`(${kwConds.join(' OR ')})`) + params.push(...options.keywords.map((k) => `%${k}%`)) + } + if (options?.systemFilter) { + conds.push(SYSTEM_MSG_FILTER) + } + if (options?.textOnlyFilter) { + conds.push(TEXT_ONLY_FILTER) + } + + return { + clause: conds.length > 0 ? 'AND ' + conds.join(' AND ') : '', + params, + } +} diff --git a/packages/core/src/query/session-queries.ts b/packages/core/src/query/session-queries.ts new file mode 100644 index 000000000..b513ca7ed --- /dev/null +++ b/packages/core/src/query/session-queries.ts @@ -0,0 +1,868 @@ +/** + * 会话查询模块(平台无关) + * + * 纯 SQL 查询函数,接收 DatabaseAdapter 参数,不依赖全局状态。 + * 这些函数是 CLI/MCP/HTTP API 查询会话数据的基础。 + */ + +import type { TimeFilter } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '../interfaces' +import { hasTable } from './filters' +import { getMemberActivity } from './basic-queries' + +export interface SessionMeta { + name: string + platform: string + type: string + importedAt: number + groupId: string | null + groupAvatar: string | null + ownerId: string | null +} + +export interface SessionOverview { + totalMessages: number + totalMembers: number + firstMessageTs: number | null + lastMessageTs: number | null +} + +export interface SessionInfo extends SessionMeta { + id: string + overview: SessionOverview +} + +// ==================== Flat session info (for AnalysisSession-like consumers) ==================== + +/** + * Platform-agnostic session info built from meta + overview. + * Covers all AnalysisSession fields that are queryable from a single DB. + * Platform-specific fields (dbPath, memberAvatar, aiConversationCount) are left to callers. + */ +export interface CoreSessionInfo { + name: string + platform: string + type: string + importedAt: number + messageCount: number + memberCount: number + groupId: string | null + groupAvatar: string | null + ownerId: string | null + firstMessageTs: number | null + lastMessageTs: number | null + summaryCount: number +} + +/** + * Pure mapper: compose SessionMeta + SessionOverview into flat CoreSessionInfo. + * Callers provide the inputs which may come from cache or fresh SQL. + */ +export function buildSessionInfo( + meta: SessionMeta, + overview: SessionOverview, + summaryCount: number = 0 +): CoreSessionInfo { + return { + name: meta.name, + platform: meta.platform, + type: meta.type, + importedAt: meta.importedAt, + messageCount: overview.totalMessages, + memberCount: overview.totalMembers, + groupId: meta.groupId, + groupAvatar: meta.groupAvatar, + ownerId: meta.ownerId, + firstMessageTs: overview.firstMessageTs, + lastMessageTs: overview.lastMessageTs, + summaryCount, + } +} + +/** + * Convenience: read meta + overview from DB and return flat CoreSessionInfo. + */ +export function getSessionInfo(db: DatabaseAdapter): CoreSessionInfo | null { + const meta = getSessionMeta(db) + if (!meta) return null + const overview = getSessionOverview(db) + const sc = getSummaryCount(db) + return buildSessionInfo(meta, overview, sc) +} + +/** + * Count of chat sessions that have an AI-generated summary. + */ +export function getSummaryCount(db: DatabaseAdapter): number { + if (!hasTable(db, 'segment')) return 0 + const row = db.prepare("SELECT COUNT(*) as count FROM segment WHERE summary IS NOT NULL AND summary != ''").get() as + | { count: number } + | undefined + return row?.count ?? 0 +} + +/** + * Get the latest platform_message_id (used as incremental import boundary). + */ +export function getLastPlatformMessageId(db: DatabaseAdapter): string | null { + const row = db + .prepare('SELECT platform_message_id FROM message WHERE platform_message_id IS NOT NULL ORDER BY ts DESC LIMIT 1') + .get() as { platform_message_id: string } | undefined + return row?.platform_message_id ?? null +} + +// ==================== Core identification ==================== + +/** + * 判断数据库是否为聊天会话数据库 + * 通过核心三表(meta/member/message)存在性快速识别 + */ +export function isChatSessionDb(db: DatabaseAdapter): boolean { + const row = db + .prepare("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name IN ('meta', 'member', 'message')") + .get() as { cnt: number } | undefined + return row?.cnt === 3 +} + +/** + * 读取会话元信息 + */ +export function getSessionMeta(db: DatabaseAdapter): SessionMeta | null { + const row = db.prepare('SELECT * FROM meta LIMIT 1').get() as Record | undefined + if (!row) return null + + return { + name: row.name as string, + platform: row.platform as string, + type: row.type as string, + importedAt: row.imported_at as number, + groupId: (row.group_id as string) || null, + groupAvatar: (row.group_avatar as string) || null, + ownerId: (row.owner_id as string) || null, + } +} + +/** + * 查询会话基础统计(消息数、成员数、时间范围) + */ +export function getSessionOverview(db: DatabaseAdapter): SessionOverview { + const msgRow = db + .prepare( + `SELECT COUNT(*) as count + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE COALESCE(m.account_name, '') != '系统消息'` + ) + .get() as { count: number } + + const memberRow = db + .prepare( + `SELECT COUNT(*) as count + FROM member + WHERE COALESCE(account_name, '') != '系统消息'` + ) + .get() as { count: number } + + const firstTs = (db.prepare('SELECT MIN(ts) as v FROM message').get() as { v: number | null })?.v ?? null + const lastTs = (db.prepare('SELECT MAX(ts) as v FROM message').get() as { v: number | null })?.v ?? null + + return { + totalMessages: msgRow.count, + totalMembers: memberRow.count, + firstMessageTs: firstTs, + lastMessageTs: lastTs, + } +} + +/** + * 获取数据库中的表结构(Schema) + */ +export function getDatabaseSchema(db: DatabaseAdapter): Array<{ name: string; sql: string }> { + return db + .prepare("SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + .all() as Array<{ name: string; sql: string }> +} + +// ==================== Chat Overview & Session Queries ==================== + +export interface ChatOverviewData { + name: string + platform: string + type: string + totalMessages: number + totalMembers: number + firstMessageTs: number | null + lastMessageTs: number | null + topMembers: Array<{ id: number; name: string; count: number }> + summaryCount: number +} + +export interface SessionPreviewMessage { + id: number + senderId: number + senderName: string + senderPlatformId: string + content: string | null + timestamp: number +} + +export interface SegmentMessagesData { + segmentId: number + startTs: number + endTs: number + messageCount: number + returnedCount: number + participants: string[] + messages: SessionPreviewMessage[] +} + +export interface SegmentSummaryData { + id: number + startTs: number + endTs: number + messageCount: number + participants: string[] + summary: string | null +} + +/** + * Get chat overview by composing meta, overview stats, and top members. + * Simpler than Electron version — no cache layer, direct SQL. + */ +export function getChatOverview(db: DatabaseAdapter, topN: number = 10): ChatOverviewData | null { + const meta = getSessionMeta(db) + if (!meta) return null + + const overview = getSessionOverview(db) + const members = getMemberActivity(db) + const summaryCount = getSummaryCount(db) + + const topMembers = members.slice(0, topN).map((m) => ({ + id: m.memberId, + name: m.name, + count: m.messageCount, + })) + + return { + name: meta.name, + platform: meta.platform, + type: meta.type, + totalMessages: overview.totalMessages, + totalMembers: overview.totalMembers, + firstMessageTs: overview.firstMessageTs, + lastMessageTs: overview.lastMessageTs, + topMembers, + summaryCount, + } +} + +/** + * Get messages for a specific chat session + */ +export function getSegmentMessages( + db: DatabaseAdapter, + segmentId: number, + limit: number = 500 +): SegmentMessagesData | null { + if (!hasTable(db, 'segment')) return null + + const session = db + .prepare( + `SELECT id, start_ts as startTs, end_ts as endTs, message_count as messageCount + FROM segment WHERE id = ?` + ) + .get(segmentId) as { id: number; startTs: number; endTs: number; messageCount: number } | undefined + + if (!session) return null + + const messages = db + .prepare( + `SELECT m.id, mb.id as senderId, + COALESCE(mb.group_nickname, mb.account_name, mb.platform_id) as senderName, + mb.platform_id as senderPlatformId, + m.content, m.ts as timestamp + FROM message_context mc + JOIN message m ON m.id = mc.message_id + JOIN member mb ON mb.id = m.sender_id + WHERE mc.segment_id = ? ORDER BY m.ts ASC LIMIT ?` + ) + .all(segmentId, limit) as unknown as SessionPreviewMessage[] + + const participantsSet = new Set() + for (const msg of messages) { + participantsSet.add(msg.senderName) + } + + return { + segmentId: session.id, + startTs: session.startTs, + endTs: session.endTs, + messageCount: session.messageCount, + returnedCount: messages.length, + participants: Array.from(participantsSet), + messages, + } +} + +/** + * Get session summaries (only sessions that have AI-generated summaries) + */ +export function getSegmentSummaries( + db: DatabaseAdapter, + options?: { limit?: number; timeFilter?: TimeFilter } +): SegmentSummaryData[] { + if (!hasTable(db, 'segment')) return [] + + const { limit = 50, timeFilter } = options ?? {} + + let sql = ` + SELECT cs.id, cs.start_ts as startTs, cs.end_ts as endTs, + cs.message_count as messageCount, cs.summary + FROM segment cs + WHERE cs.summary IS NOT NULL AND cs.summary != '' + ` + const params: unknown[] = [] + + if (timeFilter?.startTs !== undefined) { + sql += ' AND cs.start_ts >= ?' + params.push(timeFilter.startTs) + } + if (timeFilter?.endTs !== undefined) { + sql += ' AND cs.start_ts <= ?' + params.push(timeFilter.endTs) + } + + sql += ' ORDER BY cs.start_ts DESC LIMIT ?' + params.push(limit) + + const sessions = db.prepare(sql).all(...params) as Array<{ + id: number + startTs: number + endTs: number + messageCount: number + summary: string | null + }> + + const participantsSql = ` + SELECT DISTINCT COALESCE(mb.group_nickname, mb.account_name, mb.platform_id) as name + FROM message_context mc + JOIN message m ON m.id = mc.message_id + JOIN member mb ON mb.id = m.sender_id + WHERE mc.segment_id = ? LIMIT 10 + ` + + return sessions.map((session) => { + const participants = db.prepare(participantsSql).all(session.id) as Array<{ name: string }> + + return { + id: session.id, + startTs: session.startTs, + endTs: session.endTs, + messageCount: session.messageCount, + participants: participants.map((p) => p.name), + summary: session.summary, + } + }) +} + +// ==================== Session Index (segment) ==================== + +/** Default gap threshold for session segmentation: 30 minutes (seconds) */ +export const DEFAULT_SESSION_GAP_THRESHOLD = 1800 + +export interface ChatSessionItem { + id: number + startTs: number + endTs: number + messageCount: number + firstMessageId: number + summary?: string | null +} + +export interface SessionIndexStats { + sessionCount: number + hasIndex: boolean + gapThreshold: number +} + +/** + * Check whether the segment table exists and has at least one row. + */ +export function hasSessionIndex(db: DatabaseAdapter): boolean { + if (!hasTable(db, 'segment')) return false + try { + const row = db.prepare('SELECT COUNT(*) as count FROM segment').get() as { count: number } | undefined + return (row?.count ?? 0) > 0 + } catch { + return false + } +} + +/** + * Session index statistics: count, existence flag, and gap threshold from meta. + */ +export function getSessionIndexStats(db: DatabaseAdapter): SessionIndexStats { + let sessionCount = 0 + if (hasTable(db, 'segment')) { + try { + const row = db.prepare('SELECT COUNT(*) as count FROM segment').get() as { count: number } | undefined + sessionCount = row?.count ?? 0 + } catch { + /* table may not exist */ + } + } + + let gapThreshold = DEFAULT_SESSION_GAP_THRESHOLD + try { + const meta = db.prepare('SELECT session_gap_threshold FROM meta LIMIT 1').get() as + | { session_gap_threshold: number | null } + | undefined + if (meta?.session_gap_threshold) { + gapThreshold = meta.session_gap_threshold + } + } catch { + /* column may not exist */ + } + + return { sessionCount, hasIndex: sessionCount > 0, gapThreshold } +} + +/** + * Query chat sessions within a time range. + */ +export function getSessionsByTimeRange(db: DatabaseAdapter, startTs: number, endTs: number): ChatSessionItem[] { + if (!hasTable(db, 'segment')) return [] + try { + return db + .prepare( + `SELECT + id, start_ts as startTs, end_ts as endTs, + message_count as messageCount, summary, + (SELECT mc.message_id FROM message_context mc + WHERE mc.segment_id = cs.id ORDER BY mc.message_id LIMIT 1) as firstMessageId + FROM segment cs + WHERE start_ts >= ? AND start_ts <= ? + ORDER BY start_ts DESC` + ) + .all(startTs, endTs) as unknown as ChatSessionItem[] + } catch { + return [] + } +} + +/** + * Get the most recent N chat sessions. + */ +export function getRecentChatSessions(db: DatabaseAdapter, limit: number): ChatSessionItem[] { + if (!hasTable(db, 'segment')) return [] + try { + return db + .prepare( + `SELECT + id, start_ts as startTs, end_ts as endTs, + message_count as messageCount, summary, + (SELECT mc.message_id FROM message_context mc + WHERE mc.segment_id = cs.id ORDER BY mc.message_id LIMIT 1) as firstMessageId + FROM segment cs + ORDER BY start_ts DESC + LIMIT ?` + ) + .all(limit) as unknown as ChatSessionItem[] + } catch { + return [] + } +} + +/** + * Timeline list of chat sessions with first message id and summary. + */ +export function getChatSessionList(db: DatabaseAdapter): ChatSessionItem[] { + if (!hasTable(db, 'segment')) return [] + try { + return db + .prepare( + `SELECT + cs.id, + cs.start_ts as startTs, + cs.end_ts as endTs, + cs.message_count as messageCount, + cs.summary, + (SELECT mc.message_id FROM message_context mc + WHERE mc.segment_id = cs.id ORDER BY mc.message_id LIMIT 1) as firstMessageId + FROM segment cs + ORDER BY cs.start_ts ASC` + ) + .all() as unknown as ChatSessionItem[] + } catch { + return [] + } +} + +/** + * Load messages for a chat session (for summary generation). + */ +export function loadSegmentMessages( + db: DatabaseAdapter, + segmentId: number, + limit: number = 500 +): Array<{ senderName: string; content: string | null }> | null { + try { + return db + .prepare( + `SELECT + COALESCE(mb.group_nickname, mb.account_name, mb.platform_id) as senderName, + m.content + FROM message_context mc + JOIN message m ON m.id = mc.message_id + JOIN member mb ON mb.id = m.sender_id + WHERE mc.segment_id = ? + ORDER BY m.ts ASC + LIMIT ?` + ) + .all(segmentId, limit) as unknown as Array<{ senderName: string; content: string | null }> + } catch { + return null + } +} + +/** + * Get summary text for a single chat session. + */ +export function getSegmentSummary(db: DatabaseAdapter, segmentId: number): string | null { + try { + const row = db.prepare('SELECT summary FROM segment WHERE id = ?').get(segmentId) as + | { summary: string | null } + | undefined + return row?.summary ?? null + } catch { + return null + } +} + +/** + * Save summary text for a chat session. + */ +export function saveSegmentSummary(db: DatabaseAdapter, segmentId: number, summary: string): void { + db.prepare('UPDATE segment SET summary = ? WHERE id = ?').run(summary, segmentId) +} + +/** + * Update gap threshold in meta table. + */ +export function updateSessionGapThreshold(db: DatabaseAdapter, gapThreshold: number | null): void { + db.prepare('UPDATE meta SET session_gap_threshold = ?').run(gapThreshold) +} + +/** + * Update session owner_id in meta table. + */ +export function updateSessionOwnerId(db: DatabaseAdapter, ownerId: string | null): void { + db.prepare('UPDATE meta SET owner_id = ?').run(ownerId) +} + +/** + * Rename a session (update name in meta table). + */ +export function renameSession(db: DatabaseAdapter, newName: string): void { + db.prepare('UPDATE meta SET name = ?').run(newName) +} + +/** + * Delete all session index data (segment + message_context). + */ +function clearSessionIndexRows(db: DatabaseAdapter): void { + db.exec('DELETE FROM message_context') + db.exec('DELETE FROM segment') +} + +export function clearSessionIndex(db: DatabaseAdapter): void { + db.transaction(() => clearSessionIndexRows(db)) +} + +/** + * Generate session index using gap-based segmentation. + * Pure SQL+JS algorithm — caller provides an open DatabaseAdapter. + * + * @returns number of sessions created + */ +export function generateSessionIndex( + db: DatabaseAdapter, + gapThreshold: number = DEFAULT_SESSION_GAP_THRESHOLD, + onProgress?: (current: number, total: number) => void +): number { + const countRow = db.prepare('SELECT COUNT(*) as count FROM message').get() as { count: number } | undefined + if (!countRow || countRow.count === 0) return 0 + + const sessionMarkSQL = ` + WITH message_ordered AS ( + SELECT id, ts, LAG(ts) OVER (ORDER BY ts, id) AS prev_ts FROM message + ), + session_marks AS ( + SELECT id, ts, + CASE WHEN prev_ts IS NULL OR (ts - prev_ts) > ? THEN 1 ELSE 0 END AS is_new_session + FROM message_ordered + ), + session_ids AS ( + SELECT id, ts, SUM(is_new_session) OVER (ORDER BY ts, id) AS session_num + FROM session_marks + ) + SELECT id, ts, session_num FROM session_ids + ` + + const messages = db.prepare(sessionMarkSQL).all(gapThreshold) as Array<{ + id: number + ts: number + session_num: number + }> + + if (messages.length === 0) return 0 + + const sessionMap = new Map() + for (const msg of messages) { + const session = sessionMap.get(msg.session_num) + if (!session) { + sessionMap.set(msg.session_num, { startTs: msg.ts, endTs: msg.ts, messageIds: [msg.id] }) + } else { + session.endTs = msg.ts + session.messageIds.push(msg.id) + } + } + + const insertSession = db.prepare( + 'INSERT INTO segment (start_ts, end_ts, message_count, is_manual, summary) VALUES (?, ?, ?, 0, NULL)' + ) + const insertContext = db.prepare('INSERT INTO message_context (message_id, segment_id, topic_id) VALUES (?, ?, NULL)') + + return db.transaction(() => { + clearSessionIndexRows(db) + + let processed = 0 + const total = sessionMap.size + for (const [, data] of sessionMap) { + const result = insertSession.run(data.startTs, data.endTs, data.messageIds.length) + const newId = (result.lastInsertRowid ?? 0) as number + for (const mid of data.messageIds) { + insertContext.run(mid, newId) + } + processed++ + if (onProgress && processed % 100 === 0) onProgress(processed, total) + } + if (onProgress) onProgress(total, total) + return total + }) +} + +/** + * Incremental session index generation — only processes unindexed messages. + * Preserves existing sessions and their summaries. + * + * @returns number of NEW sessions created (appended messages don't count) + */ +export function generateIncrementalSessionIndex( + db: DatabaseAdapter, + gapThreshold: number = DEFAULT_SESSION_GAP_THRESHOLD +): number { + const indexedIds = new Set() + if (hasTable(db, 'message_context')) { + const rows = db.prepare('SELECT message_id FROM message_context').all() as Array<{ message_id: number }> + for (const r of rows) indexedIds.add(r.message_id) + } + + const allMessages = db.prepare('SELECT id, ts FROM message ORDER BY ts, id').all() as Array<{ + id: number + ts: number + }> + const newMessages = allMessages.filter((m) => !indexedIds.has(m.id)) + if (newMessages.length === 0) return 0 + + const lastSession = hasTable(db, 'segment') + ? (db.prepare('SELECT id, end_ts FROM segment ORDER BY end_ts DESC LIMIT 1').get() as + | { id: number; end_ts: number } + | undefined) + : undefined + + newMessages.sort((a, b) => a.ts - b.ts || a.id - b.id) + + const insertSession = db.prepare( + 'INSERT INTO segment (start_ts, end_ts, message_count, is_manual, summary) VALUES (?, ?, ?, 0, NULL)' + ) + const insertContext = db.prepare('INSERT INTO message_context (message_id, segment_id, topic_id) VALUES (?, ?, NULL)') + const updateSession = db.prepare('UPDATE segment SET end_ts = ?, message_count = message_count + ? WHERE id = ?') + + return db.transaction(() => { + let newSessionCount = 0 + let currentSessionId: number | null = null + let currentEndTs = 0 + let appendCount = 0 + + for (let i = 0; i < newMessages.length; i++) { + const msg = newMessages[i] + let needNew = false + + if (i === 0) { + if (lastSession && msg.ts - lastSession.end_ts <= gapThreshold) { + currentSessionId = lastSession.id + currentEndTs = lastSession.end_ts + appendCount = 0 + } else { + needNew = true + } + } else { + if (msg.ts - newMessages[i - 1].ts > gapThreshold) { + if (currentSessionId && appendCount > 0) { + updateSession.run(currentEndTs, appendCount, currentSessionId) + appendCount = 0 + } + needNew = true + } + } + + if (needNew) { + const result = insertSession.run(msg.ts, msg.ts, 1) + currentSessionId = (result.lastInsertRowid ?? 0) as number + currentEndTs = msg.ts + newSessionCount++ + appendCount = 0 + } else { + currentEndTs = msg.ts + appendCount++ + } + + insertContext.run(msg.id, currentSessionId) + } + + if (currentSessionId && appendCount > 0) { + updateSession.run(currentEndTs, appendCount, currentSessionId) + } + + return newSessionCount + }) +} + +// ==================== Export data ==================== + +export interface ExportSessionData { + meta: { + name: string + platform: string + type: string + groupId?: string + groupAvatar?: string + } + members: Array<{ + platformId: string + accountName: string + groupNickname?: string + avatar?: string + }> + messages: Array<{ + sender: string + accountName: string + groupNickname?: string + timestamp: number + type: number + content: string | null + }> +} + +/** + * Query all data needed for session export (meta + members + messages). + */ +export function getExportSessionData(db: DatabaseAdapter): ExportSessionData { + const meta = db.prepare('SELECT * FROM meta').get() as { + name: string + platform: string + type: string + group_id?: string + group_avatar?: string + } + if (!meta) throw new Error('Cannot read session meta') + + const members = db.prepare('SELECT platform_id, account_name, group_nickname, avatar FROM member').all() as Array<{ + platform_id: string + account_name?: string + group_nickname?: string + avatar?: string + }> + + const messages = db + .prepare( + `SELECT + m.platform_id as sender, + msg.sender_account_name as accountName, + msg.sender_group_nickname as groupNickname, + msg.ts as timestamp, + msg.type, + msg.content + FROM message msg + JOIN member m ON msg.sender_id = m.id + ORDER BY msg.ts` + ) + .all() as unknown as Array<{ + sender: string + accountName?: string + groupNickname?: string + timestamp: number + type: number + content?: string + }> + + return { + meta: { + name: meta.name, + platform: meta.platform, + type: meta.type, + groupId: meta.group_id, + groupAvatar: meta.group_avatar, + }, + members: members.map((m) => ({ + platformId: m.platform_id, + accountName: m.account_name || m.platform_id, + groupNickname: m.group_nickname || undefined, + avatar: m.avatar, + })), + messages: messages.map((msg) => ({ + sender: msg.sender, + accountName: msg.accountName || msg.sender, + groupNickname: msg.groupNickname || undefined, + timestamp: msg.timestamp, + type: msg.type, + content: msg.content ?? null, + })), + } +} + +/** + * Get private chat partner's avatar. + * Finds the "other" member (not the owner) in a private chat, falling back to name match or first with avatar. + */ +export function getPrivateChatMemberAvatar( + db: DatabaseAdapter, + sessionName: string, + ownerId: string | null | undefined +): string | null { + const members = db + .prepare( + `SELECT + m.platform_id as platformId, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name, + m.avatar + FROM member m + WHERE COALESCE(m.account_name, '') != '系统消息' + ORDER BY (SELECT COUNT(*) FROM message WHERE sender_id = m.id) DESC` + ) + .all() as unknown as Array<{ platformId: string; name: string; avatar: string | null }> + + if (members.length === 0) return null + + if (ownerId) { + const other = members.find((m) => m.platformId !== ownerId) + if (other?.avatar) return other.avatar + } + + const sameName = members.find((m) => m.name === sessionName) + if (sameName?.avatar) return sameName.avatar + + const firstWithAvatar = members.find((m) => m.avatar) + return firstWithAvatar?.avatar || null +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts new file mode 100644 index 000000000..5f8e67a5c --- /dev/null +++ b/packages/core/src/schema/index.ts @@ -0,0 +1,3 @@ +export { CURRENT_SCHEMA_VERSION, CHAT_DB_TABLES, CHAT_DB_INDEXES, CHAT_DB_SCHEMA, FTS_TABLE_SCHEMA } from './tables' +export { getSchemaVersion, setSchemaVersion, needsMigration, runMigrations } from './migrations' +export type { Migration } from './migrations' diff --git a/packages/core/src/schema/migrations.ts b/packages/core/src/schema/migrations.ts new file mode 100644 index 000000000..bf751ff67 --- /dev/null +++ b/packages/core/src/schema/migrations.ts @@ -0,0 +1,83 @@ +/** + * 数据库迁移框架(平台无关) + * + * 提供 Schema 版本检测和通用迁移执行逻辑。 + * 具体的迁移脚本由各平台运行时注册(因为某些迁移依赖 NLP 分词器等平台特性)。 + */ + +import type { DatabaseAdapter } from '../interfaces' + +/** 迁移脚本接口 */ +export interface Migration { + version: number + description: string + up: (db: DatabaseAdapter) => void +} + +/** + * 读取数据库的 schema 版本 + */ +export function getSchemaVersion(db: DatabaseAdapter): number { + try { + const tableInfo = db.pragma('table_info(meta)') as Array<{ name: string }> + const hasVersionColumn = tableInfo.some((col) => col.name === 'schema_version') + if (!hasVersionColumn) return 0 + + const result = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as + | { schema_version: number | null } + | undefined + return result?.schema_version ?? 0 + } catch { + return 0 + } +} + +/** + * 设置数据库的 schema 版本 + */ +export function setSchemaVersion(db: DatabaseAdapter, version: number): void { + const tableInfo = db.pragma('table_info(meta)') as Array<{ name: string }> + const hasVersionColumn = tableInfo.some((col) => col.name === 'schema_version') + + if (!hasVersionColumn) { + db.exec('ALTER TABLE meta ADD COLUMN schema_version INTEGER DEFAULT 0') + } + + db.prepare('UPDATE meta SET schema_version = ?').run(version) +} + +/** + * 检查数据库是否需要迁移 + */ +export function needsMigration(db: DatabaseAdapter, targetVersion: number): boolean { + return getSchemaVersion(db) < targetVersion +} + +/** + * 执行数据库迁移 + * + * @param db 数据库适配器 + * @param migrationsList 迁移脚本列表(由调用方提供,可包含平台特定逻辑) + * @param forceRepair 是否强制修复 + * @returns 是否执行了迁移 + */ +export function runMigrations(db: DatabaseAdapter, migrationsList: Migration[], forceRepair = false): boolean { + const currentVersion = getSchemaVersion(db) + + if (!forceRepair && currentVersion >= (migrationsList.at(-1)?.version ?? 0)) { + return false + } + + const pending = forceRepair ? migrationsList : migrationsList.filter((m) => m.version > currentVersion) + + if (pending.length === 0) return false + + db.transaction(() => { + for (const migration of pending) { + migration.up(db) + setSchemaVersion(db, migration.version) + } + }) + + return true +} diff --git a/packages/core/src/schema/tables.ts b/packages/core/src/schema/tables.ts new file mode 100644 index 000000000..50353e652 --- /dev/null +++ b/packages/core/src/schema/tables.ts @@ -0,0 +1,112 @@ +/** + * ChatLab 聊天会话数据库 Schema 定义 + * + * 所有 CREATE TABLE / INDEX 语句的单一事实来源。 + * 新建数据库时使用完整 Schema,现有数据库通过迁移脚本演进。 + * + * 当前 Schema 版本:7 + */ + +/** 当前 Schema 版本(最新迁移的版本号) */ +export const CURRENT_SCHEMA_VERSION = 8 + +/** + * Table DDL only (no indexes). Used by bulk-import workflows that defer + * index creation until after data is loaded for better write performance. + */ +export const CHAT_DB_TABLES = ` + CREATE TABLE IF NOT EXISTS meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + group_id TEXT, + group_avatar TEXT, + owner_id TEXT, + schema_version INTEGER DEFAULT ${CURRENT_SCHEMA_VERSION}, + session_gap_threshold INTEGER + ); + + CREATE TABLE IF NOT EXISTS member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT, + group_nickname TEXT, + aliases TEXT DEFAULT '[]', + avatar TEXT, + roles TEXT DEFAULT '[]' + ); + + CREATE TABLE IF NOT EXISTS member_name_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_id INTEGER NOT NULL, + name_type TEXT NOT NULL, + name TEXT NOT NULL, + start_ts INTEGER NOT NULL, + end_ts INTEGER, + FOREIGN KEY(member_id) REFERENCES member(id) + ); + + CREATE TABLE IF NOT EXISTS message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + sender_account_name TEXT, + sender_group_nickname TEXT, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT, + reply_to_message_id TEXT DEFAULT NULL, + platform_message_id TEXT DEFAULT NULL, + FOREIGN KEY(sender_id) REFERENCES member(id) + ); + + CREATE TABLE IF NOT EXISTS segment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + + CREATE TABLE IF NOT EXISTS message_context ( + message_id INTEGER PRIMARY KEY, + segment_id INTEGER NOT NULL, + topic_id INTEGER + ); +` + +/** + * Index DDL only. Applied after bulk import or as part of full schema init. + */ +export const CHAT_DB_INDEXES = ` + CREATE INDEX IF NOT EXISTS idx_message_ts ON message(ts); + CREATE INDEX IF NOT EXISTS idx_message_sender ON message(sender_id); + CREATE INDEX IF NOT EXISTS idx_message_sender_ts ON message(sender_id, ts); + CREATE INDEX IF NOT EXISTS idx_message_type_ts ON message(type, ts); + CREATE INDEX IF NOT EXISTS idx_message_reply_to ON message(reply_to_message_id); + CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id); + CREATE INDEX IF NOT EXISTS idx_member_name_history_member_id ON member_name_history(member_id); + CREATE INDEX IF NOT EXISTS idx_segment_time ON segment(start_ts, end_ts); + CREATE INDEX IF NOT EXISTS idx_context_segment ON message_context(segment_id); +` + +/** + * Combined tables + indexes DDL (Schema Version 4). + * For new database creation where deferred indexing is not needed. + */ +export const CHAT_DB_SCHEMA = CHAT_DB_TABLES + CHAT_DB_INDEXES + +/** + * FTS5 全文搜索虚拟表 DDL + * + * content='' 表示使用外部内容表模式(不存储原始内容,只存储索引)。 + * 填充需要在导入或迁移时手动执行。 + */ +export const FTS_TABLE_SCHEMA = ` + CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5( + content, + content='', + content_rowid=id + ); +` diff --git a/packages/core/src/version.test.ts b/packages/core/src/version.test.ts new file mode 100644 index 000000000..ecaa414d8 --- /dev/null +++ b/packages/core/src/version.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { isNewerStableVersion, isStableVersion } from './version' + +describe('stable version comparison', () => { + it('accepts only stable semver versions', () => { + assert.equal(isStableVersion('0.24.0'), true) + assert.equal(isStableVersion('v0.24.0'), true) + assert.equal(isStableVersion('0.24.0-beta.1'), false) + assert.equal(isStableVersion('0.24.0-rc.1'), false) + assert.equal(isStableVersion('latest'), false) + }) + + it('reports newer stable versions against stable and prerelease current versions', () => { + assert.equal(isNewerStableVersion('0.25.0', '0.24.0'), true) + assert.equal(isNewerStableVersion('0.24.1', '0.24.0'), true) + assert.equal(isNewerStableVersion('0.24.0', '0.24.0'), false) + assert.equal(isNewerStableVersion('0.23.9', '0.24.0'), false) + assert.equal(isNewerStableVersion('0.25.0-beta.1', '0.24.0'), false) + assert.equal(isNewerStableVersion('0.25.0', '0.25.0-beta.1'), true) + assert.equal(isNewerStableVersion('0.26.0', '0.25.0-beta.1'), true) + }) +}) diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts new file mode 100644 index 000000000..8cf767fe5 --- /dev/null +++ b/packages/core/src/version.ts @@ -0,0 +1,51 @@ +export interface ParsedStableVersion { + major: number + minor: number + patch: number +} + +interface ParsedComparableVersion extends ParsedStableVersion { + prerelease: boolean +} + +function parseStableVersion(version: string): ParsedStableVersion | null { + const normalized = version.trim().replace(/^v/i, '') + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(normalized) + if (!match) return null + + return buildParsedVersion(match) +} + +function parseComparableCurrentVersion(version: string): ParsedComparableVersion | null { + const normalized = version.trim().replace(/^v/i, '') + const match = /^(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?$/.exec(normalized) + if (!match) return null + + return { + ...buildParsedVersion(match), + prerelease: Boolean(match[4]), + } +} + +function buildParsedVersion(match: RegExpExecArray): ParsedStableVersion { + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + } +} + +export function isStableVersion(version: string): boolean { + return parseStableVersion(version) !== null +} + +export function isNewerStableVersion(latest: string, current: string): boolean { + const latestVersion = parseStableVersion(latest) + const currentVersion = parseComparableCurrentVersion(current) + if (!latestVersion || !currentVersion) return false + + if (latestVersion.major !== currentVersion.major) return latestVersion.major > currentVersion.major + if (latestVersion.minor !== currentVersion.minor) return latestVersion.minor > currentVersion.minor + if (latestVersion.patch !== currentVersion.patch) return latestVersion.patch > currentVersion.patch + return currentVersion.prerelease +} diff --git a/packages/http-routes/package.json b/packages/http-routes/package.json new file mode 100644 index 000000000..cd726b161 --- /dev/null +++ b/packages/http-routes/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openchatlab/http-routes", + "version": "0.0.0", + "private": true, + "description": "ChatLab shared HTTP API routes — consumed by CLI Server and Electron Internal Server", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "@openchatlab/config": "workspace:*", + "@openchatlab/core": "workspace:*", + "@openchatlab/node-runtime": "workspace:*", + "@openchatlab/shared-types": "workspace:*", + "@openchatlab/tools": "workspace:*", + "fastify": "^5.8.4" + } +} diff --git a/packages/http-routes/src/analytics-cache.test.ts b/packages/http-routes/src/analytics-cache.test.ts new file mode 100644 index 000000000..826ad7d1d --- /dev/null +++ b/packages/http-routes/src/analytics-cache.test.ts @@ -0,0 +1,117 @@ +/** + * buildAnalyticsCacheKey tests. + * + * 运行:node --import tsx --test packages/http-routes/src/analytics-cache.test.ts + * + * 键的确定性直接决定缓存命中率与正确性:等价请求必须同键,不同请求必须异键。 + */ + +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { buildAnalyticsCacheKey, withAnalyticsCache } from './analytics-cache' +import type { HttpRouteContext } from './context' + +describe('buildAnalyticsCacheKey', () => { + it('is independent of param key order', () => { + const a = buildAnalyticsCacheKey('wf', { startTs: 1, endTs: 2, memberId: 3 }) + const b = buildAnalyticsCacheKey('wf', { memberId: 3, endTs: 2, startTs: 1 }) + assert.equal(a, b) + }) + + it('ignores undefined params', () => { + const a = buildAnalyticsCacheKey('wf', { startTs: 1, memberId: undefined }) + const b = buildAnalyticsCacheKey('wf', { startTs: 1 }) + assert.equal(a, b) + }) + + it('separates by namespace', () => { + const a = buildAnalyticsCacheKey('catchphrase', { startTs: 1 }) + const b = buildAnalyticsCacheKey('mention', { startTs: 1 }) + assert.notEqual(a, b) + }) + + it('distinguishes different param values', () => { + const a = buildAnalyticsCacheKey('wf', { startTs: 1, endTs: 2 }) + const b = buildAnalyticsCacheKey('wf', { startTs: 1, endTs: 3 }) + assert.notEqual(a, b) + }) + + it('handles nested arrays/objects deterministically', () => { + const a = buildAnalyticsCacheKey('wf', { excludeWords: ['b', 'a'], pos: { mode: 'custom' } }) + const b = buildAnalyticsCacheKey('wf', { pos: { mode: 'custom' }, excludeWords: ['b', 'a'] }) + assert.equal(a, b) + const c = buildAnalyticsCacheKey('wf', { excludeWords: ['a', 'b'] }) + assert.notEqual(a, c, 'array order is significant') + }) +}) + +describe('withAnalyticsCache', () => { + let root: string + let dbPath: string + + function makeCtx(): HttpRouteContext { + return { + pathProvider: { getCacheDir: () => path.join(root, 'cache') }, + sessionAdapter: { getDbPath: () => dbPath }, + getVersion: () => '0.0.0-test', + } as unknown as HttpRouteContext + } + + beforeEach(() => { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + root = fs.mkdtempSync(path.join(baseDir, 'chatlab-analytics-cache-')) + dbPath = path.join(root, 'session.db') + fs.writeFileSync(dbPath, 'db-v1') + }) + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }) + }) + + it('computes once then serves the cached value on the second call', () => { + const ctx = makeCtx() + let calls = 0 + const compute = () => { + calls++ + return { value: calls } + } + const first = withAnalyticsCache(ctx, 's1', 'nlp.word-frequency', { startTs: 1 }, compute) + const second = withAnalyticsCache(ctx, 's1', 'nlp.word-frequency', { startTs: 1 }, compute) + assert.equal(calls, 1) + assert.deepEqual(first, { value: 1 }) + assert.deepEqual(second, { value: 1 }) + // 缓存确实落到了磁盘 cacheDir/query 下 + const cacheFile = path.join(root, 'cache', 'query', 's1.cache.json') + assert.ok(fs.existsSync(cacheFile)) + }) + + it('recomputes after the db file changes (size/mtime fingerprint)', () => { + const ctx = makeCtx() + let calls = 0 + const compute = () => { + calls++ + return { value: calls } + } + withAnalyticsCache(ctx, 's1', 'nlp.word-frequency', { startTs: 1 }, compute) + fs.writeFileSync(dbPath, 'db-v2-with-more-bytes') // 改变文件大小 => 版本变化 + const after = withAnalyticsCache(ctx, 's1', 'nlp.word-frequency', { startTs: 1 }, compute) + assert.equal(calls, 2) + assert.deepEqual(after, { value: 2 }) + }) + + it('isolates entries by params and by session', () => { + const ctx = makeCtx() + let calls = 0 + const compute = () => { + calls++ + return { value: calls } + } + withAnalyticsCache(ctx, 's1', 'nlp.word-frequency', { startTs: 1 }, compute) + withAnalyticsCache(ctx, 's1', 'nlp.word-frequency', { startTs: 2 }, compute) + withAnalyticsCache(ctx, 's2', 'nlp.word-frequency', { startTs: 1 }, compute) + assert.equal(calls, 3) + }) +}) diff --git a/packages/http-routes/src/analytics-cache.ts b/packages/http-routes/src/analytics-cache.ts new file mode 100644 index 000000000..4200774ed --- /dev/null +++ b/packages/http-routes/src/analytics-cache.ts @@ -0,0 +1,65 @@ +/** + * Shared analytics result caching for HTTP route handlers. + * + * Wraps expensive analytics / NLP computations with the platform-agnostic + * analytics cache (cacheDir/query/{sessionId}.cache.json). Used by both CLI Web + * (`chatlab start`) and the Electron internal server so the two share one cache + * implementation. Validity is keyed to the product version plus the session DB + * file fingerprint, so any release that changes query logic, and any import / + * incremental import / member edit, transparently invalidates entries. + */ + +import * as path from 'path' +import { getDbFileVersion, getOrComputeAnalysisCache } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from './context' + +/** Deterministic stringify: sorts object keys recursively and drops `undefined`. */ +function canonical(value: unknown): string { + if (value === undefined) return 'null' + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]` + const obj = value as Record + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort() + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(',')}}` +} + +/** + * Build a stable cache key from a namespace (endpoint id) and its params. + * Key order does not matter and `undefined` params are ignored, so equivalent + * requests map to the same key. + */ +export function buildAnalyticsCacheKey(namespace: string, params: Record): string { + return `${namespace}:${canonical(params)}` +} + +/** + * Cache-first wrapper for an analytics computation bound to a session. + * Returns the cached result when the DB file is unchanged; otherwise computes, + * persists (tagged with the current DB fingerprint) and returns it. + * + * Pass `options.dailyInvalidate: true` for endpoints whose results depend on + * the current date (e.g. daysSinceLastMessage, currentStreak). This appends + * today's date to the version string so the cache is refreshed each day even + * when the DB file has not changed. + * + * Pass `options.extraVersion` to append an arbitrary fingerprint to the version + * (e.g. an external file's mtime/size) so the cache is invalidated whenever + * that resource changes independently of the DB or app version. + */ +export function withAnalyticsCache( + ctx: HttpRouteContext, + sessionId: string, + namespace: string, + params: Record, + compute: () => T, + options?: { dailyInvalidate?: boolean; extraVersion?: string } +): T { + const queryCacheDir = path.join(ctx.pathProvider.getCacheDir(), 'query') + const dateStr = options?.dailyInvalidate ? `|date:${new Date().toISOString().split('T')[0]}` : '' + const extraStr = options?.extraVersion ? `|${options.extraVersion}` : '' + const version = `${ctx.getVersion()}|${getDbFileVersion(ctx.sessionAdapter.getDbPath(sessionId))}${dateStr}${extraStr}` + const key = buildAnalyticsCacheKey(namespace, params) + return getOrComputeAnalysisCache(sessionId, key, queryCacheDir, version, compute) +} diff --git a/packages/http-routes/src/auth.ts b/packages/http-routes/src/auth.ts new file mode 100644 index 000000000..4dfb95db9 --- /dev/null +++ b/packages/http-routes/src/auth.ts @@ -0,0 +1,71 @@ +/** + * ChatLab HTTP API — Bearer Token authentication hook + * + * Shared auth middleware for CLI Server and Electron Internal Server. + * URL classification: /api/* always requires token, /_web/* conditionally, + * static files and SPA fallback are public. + */ + +import type { FastifyRequest, FastifyReply } from 'fastify' +import { timingSafeEqual, createHmac, randomBytes } from 'crypto' +import { unauthorized, errorResponse } from './errors' + +let cachedToken: string | null = null +let requireAuthEnabled = false + +export function setAuthToken(token: string): void { + cachedToken = token +} + +/** + * When enabled, /_web/* routes also require Bearer token (same as /api/*). + * Used for server/headless deployments where same-origin assumption doesn't hold. + */ +export function setRequireAuth(enabled: boolean): void { + requireAuthEnabled = enabled +} + +// Compare via HMAC digests (fixed 32-byte length) to avoid leaking token length +const hmacKey = randomBytes(32) + +function safeTokenCompare(a: string, b: string): boolean { + const hashA = createHmac('sha256', hmacKey).update(a).digest() + const hashB = createHmac('sha256', hmacKey).update(b).digest() + return timingSafeEqual(hashA, hashB) +} + +export async function authHook(request: FastifyRequest, reply: FastifyReply): Promise { + if (!cachedToken) return + + const url = request.url + + if (url.startsWith('/api/')) { + return requireBearerToken(request, reply) + } + + if (url.startsWith('/_web/')) { + if (requireAuthEnabled) return requireBearerToken(request, reply) + return + } + + // Static files and SPA fallback are public +} + +function requireBearerToken(request: FastifyRequest, reply: FastifyReply): void { + if (!cachedToken) return + + const authHeader = request.headers.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + const err = unauthorized() + reply.code(err.statusCode).send(errorResponse(err)) + return + } + + const token = authHeader.slice(7) + + if (!safeTokenCompare(token, cachedToken)) { + const err = unauthorized() + reply.code(err.statusCode).send(errorResponse(err)) + return + } +} diff --git a/packages/http-routes/src/context.ts b/packages/http-routes/src/context.ts new file mode 100644 index 000000000..e9a51beb7 --- /dev/null +++ b/packages/http-routes/src/context.ts @@ -0,0 +1,146 @@ +/** + * HttpRouteContext — shared dependency injection interface for route handlers. + * + * CLI Server and Electron Internal Server each construct their own context + * and pass it to registerSharedRoutes(). Route handlers only depend on this + * interface, never on CLI or Electron specific modules. + */ + +import type { PathProvider } from '@openchatlab/core' +import type { ChartAutoMode } from '@openchatlab/shared-types' +import type { AuthProfile } from '@openchatlab/config' +import type { AnalyticsService } from '@openchatlab/node-runtime' +import type { + DatabaseManager, + DataDirSwitchResult, + PendingDataDirMigration, + SessionRuntimeAdapter, + PreferencesManager, + AIChatManager, + AssistantManager, + SkillManagerCore, + LLMConfigStore, + CustomProviderStore, + CustomModelStore, + MergeSessionCache, + AgentStreamChunk, + SemanticIndexRuntime, + ContactsService, + PeopleRelationshipsService, +} from '@openchatlab/node-runtime' +import type { RuntimeIdentity } from '@openchatlab/node-runtime' + +export interface HttpRouteContext { + dbManager: DatabaseManager + sessionAdapter: SessionRuntimeAdapter + pathProvider: PathProvider + + getVersion: () => string + runtimeIdentity?: RuntimeIdentity + + /** Native binding path for better-sqlite3 (CLI needs it, Electron does not) */ + nativeBinding?: string + contactsService?: ContactsService + peopleRelationshipsService?: PeopleRelationshipsService + + preferencesManager?: PreferencesManager + + /** Merge subsystem — optional, merge routes gracefully skip when absent */ + mergeSessionCache?: MergeSessionCache + /** + * Platform-specific import function for merge "andImport" flow. + * CLI and Electron each provide their own implementation. + */ + streamImport?: (dbManager: DatabaseManager, filePath: string) => Promise<{ sessionId: string }> + + /** AI subsystem — optional, routes gracefully skip when absent */ + aiDataDir?: string + aiChatManager?: AIChatManager + assistantManager?: AssistantManager + skillManagerCore?: SkillManagerCore + llmConfigStore?: LLMConfigStore + customProviderStore?: CustomProviderStore + customModelStore?: CustomModelStore + + /** 语义索引共享 service — 可选,路由在缺失时优雅跳过 */ + semanticIndexService?: SemanticIndexRuntime + + /** + * auth-profiles 读写注入 — 仅语义索引「向量库不可用」降级路径使用(其余路径走 service 内部注入)。 + * 缺省时 helper 回退到 @openchatlab/config 的真实读写(生产行为不变);测试可注入内存实现, + * 避免降级配置写入真实 ~/.chatlab。 + */ + resolveApiKey?: (provider: string, authProfile?: string) => string + writeAuthProfile?: (name: string, profile: AuthProfile) => void + + /** Analytics tracking service — optional, telemetry routes silently skip when absent */ + analyticsService?: AnalyticsService + + /** Cache/storage — platform-specific (optional) */ + openDirectory?: (dirPath: string) => Promise + showInFolder?: (filePath: string) => Promise + downloadsDir?: string + defaultUserDataDir?: string + isCustomDataDir?: boolean + canSetDataDir?: boolean + getPendingDataDirMigration?: () => PendingDataDirMigration | null + setDataDir?: (dirPath: string | null, migrate?: boolean) => Promise | DataDirSwitchResult + + /** Agent streaming — platform-specific execution (optional) */ + runAgentStream?: ( + params: AgentStreamRequest, + onEvent: (chunk: AgentStreamChunk) => void, + abortSignal: AbortSignal + ) => Promise + + /** AI tool debug execution - platform-specific so Electron can keep DB work in its worker. */ + executeAiTool?: (params: AiToolExecuteRequest) => Promise +} + +export interface AiToolExecuteRequest { + testId: string + toolName: string + params: Record + sessionId: string + abortSignal: AbortSignal +} + +export interface AiToolExecuteResult { + success: boolean + elapsed?: number + content?: Array<{ type: 'text'; text: string }> + details?: unknown + truncated?: boolean + error?: string +} + +export interface AgentStreamRequest { + userMessage: string + aiChatId: string + historyLeafMessageId?: string | null + sessionId: string + chatType?: 'group' | 'private' + locale?: string + assistantId?: string + skillId?: string | null + enableAutoSkill?: boolean + chartAutoMode?: ChartAutoMode + compressionConfig?: { + enabled: boolean + tokenThresholdPercent?: number + bufferSizePercent?: number + maxToolResultPercent?: number + } + ownerInfo?: { platformId: string; displayName: string } + mentionedMembers?: Array<{ + memberId: number + platformId: string + displayName: string + aliases: string[] + mentionText: string + }> + thinkingLevel?: string + timeFilter?: { startTs: number; endTs: number } + maxMessagesLimit?: number + preprocessConfig?: Record +} diff --git a/packages/http-routes/src/errors.test.ts b/packages/http-routes/src/errors.test.ts new file mode 100644 index 000000000..cc5dfd5d4 --- /dev/null +++ b/packages/http-routes/src/errors.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat' +import { ApiErrorCode, apiErrorFromUnknown } from './errors' + +test('apiErrorFromUnknown maps data directory compatibility errors to 409', () => { + const apiError = apiErrorFromUnknown( + new DataDirCompatibilityError( + 'DATA_DIR_REQUIRES_NEWER_RUNTIME', + 'ChatLab data directory requires runtime version 0.25.1 or newer; current version is 0.25.0.', + { + userDataDir: '/tmp/chatlab-data', + metaPath: '/tmp/chatlab-data/.chatlab-meta.json', + currentVersion: '0.25.0', + minRuntimeVersion: '0.25.1', + } + ) + ) + + assert.equal(apiError?.code, ApiErrorCode.DATA_DIR_INCOMPATIBLE) + assert.equal(apiError?.statusCode, 409) + assert.match(apiError?.message ?? '', /requires runtime version 0\.25\.1/) +}) diff --git a/packages/http-routes/src/errors.ts b/packages/http-routes/src/errors.ts new file mode 100644 index 000000000..ecce6e55d --- /dev/null +++ b/packages/http-routes/src/errors.ts @@ -0,0 +1,120 @@ +/** + * ChatLab HTTP API — Error codes and factory functions + * + * Platform-agnostic error handling shared by CLI Server and Electron Internal Server. + */ + +import { DataDirCompatibilityError } from '@openchatlab/node-runtime/src/data-dir-compat' + +export enum ApiErrorCode { + UNAUTHORIZED = 'UNAUTHORIZED', + SESSION_NOT_FOUND = 'SESSION_NOT_FOUND', + INVALID_FORMAT = 'INVALID_FORMAT', + INVALID_PAYLOAD = 'INVALID_PAYLOAD', + SQL_READONLY_VIOLATION = 'SQL_READONLY_VIOLATION', + SQL_EXECUTION_ERROR = 'SQL_EXECUTION_ERROR', + EXPORT_TOO_LARGE = 'EXPORT_TOO_LARGE', + BODY_TOO_LARGE = 'BODY_TOO_LARGE', + IMPORT_IN_PROGRESS = 'IMPORT_IN_PROGRESS', + IMPORT_FAILED = 'IMPORT_FAILED', + DATA_DIR_INCOMPATIBLE = 'DATA_DIR_INCOMPATIBLE', + SERVER_ERROR = 'SERVER_ERROR', +} + +const HTTP_STATUS: Record = { + [ApiErrorCode.UNAUTHORIZED]: 401, + [ApiErrorCode.SESSION_NOT_FOUND]: 404, + [ApiErrorCode.INVALID_FORMAT]: 400, + [ApiErrorCode.INVALID_PAYLOAD]: 400, + [ApiErrorCode.SQL_READONLY_VIOLATION]: 400, + [ApiErrorCode.SQL_EXECUTION_ERROR]: 400, + [ApiErrorCode.EXPORT_TOO_LARGE]: 400, + [ApiErrorCode.BODY_TOO_LARGE]: 413, + [ApiErrorCode.IMPORT_IN_PROGRESS]: 409, + [ApiErrorCode.IMPORT_FAILED]: 500, + [ApiErrorCode.DATA_DIR_INCOMPATIBLE]: 409, + [ApiErrorCode.SERVER_ERROR]: 500, +} + +export class ApiError extends Error { + code: ApiErrorCode + statusCode: number + + constructor(code: ApiErrorCode, message: string) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = HTTP_STATUS[code] + } +} + +export function unauthorized(message = 'Invalid or missing token'): ApiError { + return new ApiError(ApiErrorCode.UNAUTHORIZED, message) +} + +export function sessionNotFound(id: string): ApiError { + return new ApiError(ApiErrorCode.SESSION_NOT_FOUND, `Session not found: ${id}`) +} + +export function invalidPayload(message: string): ApiError { + return new ApiError(ApiErrorCode.INVALID_PAYLOAD, message) +} + +export function sqlReadonlyViolation(): ApiError { + return new ApiError(ApiErrorCode.SQL_READONLY_VIOLATION, 'Only SELECT queries are allowed') +} + +export function sqlExecutionError(message: string): ApiError { + return new ApiError(ApiErrorCode.SQL_EXECUTION_ERROR, message) +} + +export function exportTooLarge(count: number, limit: number): ApiError { + return new ApiError( + ApiErrorCode.EXPORT_TOO_LARGE, + `Message count ${count} exceeds export limit ${limit}. Use paginated /messages API instead.` + ) +} + +export function serverError(message = 'Internal server error'): ApiError { + return new ApiError(ApiErrorCode.SERVER_ERROR, message) +} + +export function importInProgress(): ApiError { + return new ApiError(ApiErrorCode.IMPORT_IN_PROGRESS, 'Another import is already in progress') +} + +export function importFailed(message: string): ApiError { + return new ApiError(ApiErrorCode.IMPORT_FAILED, message) +} + +export function dataDirIncompatible(message: string): ApiError { + return new ApiError(ApiErrorCode.DATA_DIR_INCOMPATIBLE, message) +} + +export function apiErrorFromUnknown(error: unknown): ApiError | null { + if (error instanceof ApiError) return error + if (error instanceof DataDirCompatibilityError) return dataDirIncompatible(error.message) + return null +} + +export function successResponse(data: T, meta?: Record) { + return { + success: true as const, + data, + meta: { + timestamp: Math.floor(Date.now() / 1000), + version: '0.0.2', + ...meta, + }, + } +} + +export function errorResponse(error: ApiError) { + return { + success: false as const, + error: { + code: error.code, + message: error.message, + }, + } +} diff --git a/packages/http-routes/src/helpers.ts b/packages/http-routes/src/helpers.ts new file mode 100644 index 000000000..44c2ffd80 --- /dev/null +++ b/packages/http-routes/src/helpers.ts @@ -0,0 +1,15 @@ +/** + * Shared helpers for route modules. + */ + +import type { TimeFilter } from '@openchatlab/shared-types' + +export function parseTimeFilter(query: Record): TimeFilter | undefined { + const { startTs, endTs, memberId } = query + if (!startTs && !endTs && !memberId) return undefined + const filter: TimeFilter = {} + if (startTs) filter.startTs = parseInt(startTs, 10) + if (endTs) filter.endTs = parseInt(endTs, 10) + if (memberId) filter.memberId = parseInt(memberId, 10) + return filter +} diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts new file mode 100644 index 000000000..bfcc06243 --- /dev/null +++ b/packages/http-routes/src/index.ts @@ -0,0 +1,42 @@ +export type { HttpRouteContext, AgentStreamRequest, AiToolExecuteRequest, AiToolExecuteResult } from './context' +export { registerSharedRoutes } from './register' +export type { SharedRouteOptions } from './register' +export { setAuthToken, setRequireAuth, authHook } from './auth' +export { + ApiError, + ApiErrorCode, + unauthorized, + sessionNotFound, + invalidPayload, + sqlReadonlyViolation, + sqlExecutionError, + exportTooLarge, + serverError, + dataDirIncompatible, + apiErrorFromUnknown, + successResponse, + errorResponse, +} from './errors' +export { parseTimeFilter } from './helpers' +export { buildAnalyticsCacheKey, withAnalyticsCache } from './analytics-cache' + +// Individual route registration for granular testing or selective registration +export { registerSystemRoutes } from './routes/system' +export { registerRestSessionRoutes } from './routes/sessions' +export { registerSessionRoutes } from './routes/web/sessions' +export { registerMemberRoutes } from './routes/web/members' +export { registerPreferencesRoutes } from './routes/web/preferences' +export { registerAnalyticsRoutes } from './routes/web/analytics' +export { registerSqlRoutes } from './routes/web/sql' +export { registerSessionIndexRoutes } from './routes/web/session-index' +export { registerExportRoutes } from './routes/web/export' +export { registerNlpRoutes } from './routes/web/nlp' +export { registerAiAssistantRoutes } from './routes/web/ai-assistants' +export { registerAiSkillRoutes } from './routes/web/ai-skills' +export { registerAiLlmRoutes } from './routes/web/ai-llm' +export { registerAiChatRoutes } from './routes/web/ai-chats' +export { registerAiSummaryRoutes } from './routes/web/ai-summaries' +export { registerAiLlmStreamRoutes } from './routes/web/ai-llm-stream' +export { registerAiAgentStreamRoutes } from './routes/web/ai-agent-stream' +export { registerAiToolRoutes } from './routes/web/ai-tools' +export { registerTelemetryRoutes } from './routes/web/telemetry' diff --git a/packages/http-routes/src/register.ts b/packages/http-routes/src/register.ts new file mode 100644 index 000000000..41dd63c78 --- /dev/null +++ b/packages/http-routes/src/register.ts @@ -0,0 +1,107 @@ +/** + * Aggregate route registration — one call to register all shared routes. + * + * CLI Server and Electron Internal Server call this instead of + * importing individual route modules. + */ + +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from './context' +import { PreferencesManager } from '@openchatlab/node-runtime' +import { registerSystemRoutes } from './routes/system' +import { registerRestSessionRoutes } from './routes/sessions' +import { registerImportRoutes } from './routes/imports' +import { registerSessionRoutes } from './routes/web/sessions' +import { registerMemberRoutes } from './routes/web/members' +import { registerContactsRoutes } from './routes/web/contacts' +import { registerPeopleRelationshipsRoutes } from './routes/web/people-relationships' +import { registerPreferencesRoutes } from './routes/web/preferences' +import { registerAnalyticsRoutes } from './routes/web/analytics' +import { registerSqlRoutes } from './routes/web/sql' +import { registerSessionIndexRoutes } from './routes/web/session-index' +import { registerExportRoutes } from './routes/web/export' +import { registerNlpRoutes } from './routes/web/nlp' +import { registerAiAssistantRoutes } from './routes/web/ai-assistants' +import { registerAiSkillRoutes } from './routes/web/ai-skills' +import { registerAiLlmRoutes } from './routes/web/ai-llm' +import { registerAiLlmStreamRoutes } from './routes/web/ai-llm-stream' +import { registerAiAgentStreamRoutes } from './routes/web/ai-agent-stream' +import { registerAiToolRoutes } from './routes/web/ai-tools' +import { registerAiChatRoutes } from './routes/web/ai-chats' +import { registerAiSummaryRoutes } from './routes/web/ai-summaries' +import { registerSemanticIndexRoutes } from './routes/web/ai-semantic-index' +import { registerMergeRoutes } from './routes/web/merge' +import { registerCacheRoutes } from './routes/web/cache' +import { registerTelemetryRoutes } from './routes/web/telemetry' +import { registerLogRoutes } from './routes/web/logs' + +export interface SharedRouteOptions { + /** When true, AI routes will throw on missing dependencies instead of silently skipping */ + requireAi?: boolean +} + +export function registerSharedRoutes( + server: FastifyInstance, + ctx: HttpRouteContext, + options?: SharedRouteOptions +): void { + // Ensure all routes share one PreferencesManager instance to avoid stale-cache + // overwrites between the session owner routes and the preferences routes. + const resolvedCtx: HttpRouteContext = ctx.preferencesManager + ? ctx + : { ...ctx, preferencesManager: new PreferencesManager(ctx.pathProvider.getSystemDir()) } + + // REST API (/api/v1/*) + registerSystemRoutes(server, resolvedCtx) + registerRestSessionRoutes(server, resolvedCtx) + registerImportRoutes(server, resolvedCtx) + + // Web UI API (/_web/*) + registerSessionRoutes(server, resolvedCtx) + registerMemberRoutes(server, resolvedCtx) + registerContactsRoutes(server, resolvedCtx) + registerPeopleRelationshipsRoutes(server, resolvedCtx) + registerPreferencesRoutes(server, resolvedCtx) + registerAnalyticsRoutes(server, resolvedCtx) + registerSqlRoutes(server, resolvedCtx) + registerSessionIndexRoutes(server, resolvedCtx) + registerExportRoutes(server, resolvedCtx) + registerNlpRoutes(server, resolvedCtx) + + if (options?.requireAi) { + const missing: string[] = [] + if (!resolvedCtx.aiDataDir) missing.push('aiDataDir') + if (!resolvedCtx.aiChatManager) missing.push('aiChatManager') + if (!resolvedCtx.assistantManager) missing.push('assistantManager') + if (!resolvedCtx.skillManagerCore) missing.push('skillManagerCore') + if (!resolvedCtx.llmConfigStore) missing.push('llmConfigStore') + if (!resolvedCtx.customProviderStore) missing.push('customProviderStore') + if (!resolvedCtx.customModelStore) missing.push('customModelStore') + if (!resolvedCtx.runAgentStream) missing.push('runAgentStream') + if (missing.length > 0) { + throw new Error(`[http-routes] requireAi is set but missing AI dependencies: ${missing.join(', ')}`) + } + } + + registerAiAssistantRoutes(server, resolvedCtx) + registerAiSkillRoutes(server, resolvedCtx) + registerAiLlmRoutes(server, resolvedCtx) + registerAiLlmStreamRoutes(server, resolvedCtx) + registerAiAgentStreamRoutes(server, resolvedCtx) + registerAiToolRoutes(server, resolvedCtx) + registerAiChatRoutes(server, resolvedCtx) + registerAiSummaryRoutes(server, resolvedCtx) + registerSemanticIndexRoutes(server, resolvedCtx) + + // Merge routes (graceful skip when mergeSessionCache is absent) + registerMergeRoutes(server, resolvedCtx) + + // Cache/storage routes + registerCacheRoutes(server, resolvedCtx) + + // Telemetry routes + registerTelemetryRoutes(server, resolvedCtx) + + // Front-end log report + open logs dir + registerLogRoutes(server, resolvedCtx) +} diff --git a/packages/http-routes/src/routes/imports.ts b/packages/http-routes/src/routes/imports.ts new file mode 100644 index 000000000..601cc8e3b --- /dev/null +++ b/packages/http-routes/src/routes/imports.ts @@ -0,0 +1,50 @@ +/** + * ChatLab HTTP API — Push Import route (/api/v1/imports/*) + * + * POST /api/v1/imports/:sessionId + * Creates a new session or appends messages to an existing one. + * See docs/cn/standard/chatlab-import.md for protocol spec. + */ + +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../context' +import { pushImport } from '@openchatlab/node-runtime' +import type { PushImportPayload } from '@openchatlab/node-runtime' +import { successResponse, errorResponse, invalidPayload, importInProgress, importFailed } from '../errors' + +export function registerImportRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + server.post<{ Params: { sessionId: string }; Body: PushImportPayload }>( + '/api/v1/imports/:sessionId', + async (request, reply) => { + const { sessionId } = request.params + const contentType = request.headers['content-type'] || '' + + if (!contentType.includes('application/json')) { + const err = invalidPayload('Content-Type must be application/json (JSONL is not yet supported)') + return reply.code(err.statusCode).send(errorResponse(err)) + } + + if (request.headers['x-dry-run'] === 'true') { + const err = invalidPayload('X-Dry-Run is not yet supported') + return reply.code(err.statusCode).send(errorResponse(err)) + } + + const outcome = await pushImport(ctx.dbManager, sessionId, request.body ?? {}) + + if (!outcome.ok) { + if (outcome.reason === 'import_in_progress') { + const err = importInProgress() + return reply.code(err.statusCode).send(errorResponse(err)) + } + if (outcome.reason === 'invalid_payload') { + const err = invalidPayload(outcome.message) + return reply.code(err.statusCode).send(errorResponse(err)) + } + const err = importFailed(outcome.message) + return reply.code(err.statusCode).send(errorResponse(err)) + } + + return successResponse(outcome.result) + } + ) +} diff --git a/packages/http-routes/src/routes/sessions.ts b/packages/http-routes/src/routes/sessions.ts new file mode 100644 index 000000000..5bd28a37a --- /dev/null +++ b/packages/http-routes/src/routes/sessions.ts @@ -0,0 +1,220 @@ +/** + * ChatLab HTTP API — REST Session routes (/api/v1/sessions/*) + * + * Public REST API for external tools, scripts, and integrations. + * Uses DatabaseManager + @openchatlab/core for data access. + */ + +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../context' +import { + getSessionInfo, + getSessionMeta, + getSessionOverview, + queryMessages, + getMembers, + getMembersDetailed, + getMemberActivity, + getMessageTypeStats, + executeReadonlySql, + getLastPlatformMessageId, +} from '@openchatlab/core' +import { successResponse, errorResponse, sessionNotFound, exportTooLarge, sqlExecutionError, ApiError } from '../errors' + +const EXPORT_MESSAGE_LIMIT = 100_000 + +function ensureDb(ctx: HttpRouteContext, sessionId: string) { + const db = ctx.dbManager.open(sessionId) + if (!db) throw sessionNotFound(sessionId) + return db +} + +export function registerRestSessionRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + server.get('/api/v1/sessions', async () => { + const sessionIds = ctx.dbManager.listSessionIds() + const sessions = sessionIds + .map((id) => { + const db = ctx.dbManager.open(id) + if (!db) return null + const info = getSessionInfo(db) + if (!info) return null + return { + id, + name: info.name, + platform: info.platform, + type: info.type, + groupId: info.groupId || undefined, + messageCount: info.messageCount, + memberCount: info.memberCount, + firstTimestamp: info.firstMessageTs, + lastTimestamp: info.lastMessageTs, + } + }) + .filter(Boolean) + return successResponse(sessions) + }) + + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id', async (request) => { + const db = ensureDb(ctx, request.params.id) + const info = getSessionInfo(db) + if (!info) throw sessionNotFound(request.params.id) + return successResponse({ + id: request.params.id, + name: info.name, + platform: info.platform, + type: info.type, + groupId: info.groupId || undefined, + messageCount: info.messageCount, + memberCount: info.memberCount, + firstTimestamp: info.firstMessageTs, + lastTimestamp: info.lastMessageTs, + lastPlatformMessageId: getLastPlatformMessageId(db), + importedAt: info.importedAt, + }) + }) + + server.get<{ + Params: { id: string } + Querystring: { + page?: string + limit?: string + startTime?: string + endTime?: string + keyword?: string + senderId?: string + } + }>('/api/v1/sessions/:id/messages', async (request) => { + const { id } = request.params + const db = ensureDb(ctx, id) + + const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1) + const limit = Math.min(1000, Math.max(1, parseInt(request.query.limit || '100', 10) || 100)) + const offset = (page - 1) * limit + + const { startTime, endTime, keyword, senderId } = request.query + + const result = queryMessages(db, { + keyword: keyword || undefined, + startTs: startTime ? parseInt(startTime, 10) : undefined, + endTs: endTime ? parseInt(endTime, 10) : undefined, + senderId: senderId ? parseInt(senderId, 10) : undefined, + limit, + offset, + }) + + return successResponse({ + messages: result.messages, + total: result.total, + page, + limit, + totalPages: result.totalPages, + }) + }) + + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/members', async (request) => { + const db = ensureDb(ctx, request.params.id) + const members = getMembers(db) + return successResponse(members) + }) + + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/stats/overview', async (request) => { + const { id } = request.params + const db = ensureDb(ctx, id) + + const overview = getSessionOverview(db) + const memberActivity = getMemberActivity(db) + const typeDistribution = getMessageTypeStats(db) + + const typeMap: Record = {} + for (const item of typeDistribution) { + typeMap[String(item.type)] = item.count + } + + const topMembers = memberActivity.slice(0, 10).map((m) => ({ + platformId: m.platformId, + name: m.name, + messageCount: m.messageCount, + percentage: m.percentage, + })) + + return successResponse({ + messageCount: overview.totalMessages, + memberCount: overview.totalMembers, + timeRange: { + start: overview.firstMessageTs ?? 0, + end: overview.lastMessageTs ?? 0, + }, + messageTypeDistribution: typeMap, + topMembers, + }) + }) + + server.post<{ Params: { id: string }; Body: { sql: string } }>('/api/v1/sessions/:id/sql', async (request, reply) => { + const { id } = request.params + const db = ensureDb(ctx, id) + + const { sql } = request.body || {} + if (!sql || typeof sql !== 'string') { + const err = sqlExecutionError('Missing sql parameter') + return reply.code(err.statusCode).send(errorResponse(err)) + } + + try { + const result = executeReadonlySql(db, sql) + return successResponse(result) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'SQL execution error' + if (message.includes('SELECT') || message.includes('只读') || message.includes('readonly')) { + const apiErr = new ApiError('SQL_READONLY_VIOLATION' as ApiError['code'], message) + apiErr.statusCode = 400 + return reply.code(400).send(errorResponse(apiErr)) + } + const apiErr = sqlExecutionError(message) + return reply.code(apiErr.statusCode).send(errorResponse(apiErr)) + } + }) + + server.get<{ Params: { id: string } }>('/api/v1/sessions/:id/export', async (request, reply) => { + const { id } = request.params + const db = ensureDb(ctx, id) + const meta = getSessionMeta(db) + if (!meta) throw sessionNotFound(id) + const overview = getSessionOverview(db) + + if (overview.totalMessages > EXPORT_MESSAGE_LIMIT) { + const err = exportTooLarge(overview.totalMessages, EXPORT_MESSAGE_LIMIT) + return reply.code(err.statusCode).send(errorResponse(err)) + } + + const members = getMembersDetailed(db) + const allMessages = queryMessages(db, { limit: EXPORT_MESSAGE_LIMIT, offset: 0 }) + + const chatLabFormat = { + chatlab: { + version: '0.0.2', + exportedAt: Math.floor(Date.now() / 1000), + generator: 'ChatLab API', + }, + meta: { + name: meta.name, + platform: meta.platform, + type: meta.type, + groupId: meta.groupId || undefined, + }, + members: members.map((m) => ({ + platformId: m.platformId, + accountName: m.accountName || m.platformId, + groupNickname: m.groupNickname || undefined, + })), + messages: allMessages.messages.map((msg) => ({ + sender: msg.senderPlatformId, + accountName: msg.senderName || undefined, + timestamp: msg.timestamp, + type: msg.type, + content: msg.content || null, + })), + } + + return successResponse(chatLabFormat) + }) +} diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts new file mode 100644 index 000000000..a750b60e8 --- /dev/null +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -0,0 +1,362 @@ +/** + * Smoke tests for registerSharedRoutes — verifies all route groups + * are registered and respond correctly with mock context. + * + * Uses a single Fastify instance to avoid repeated NLP dict init overhead. + */ + +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import Database from 'better-sqlite3' +import Fastify, { type FastifyInstance } from 'fastify' +import type { DatabaseAdapter, PathProvider, PreparedStatement, RunResult } from '@openchatlab/core' +import { PreferencesManager, type DatabaseManager, type SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../context' +import { registerSharedRoutes } from '../register' +import { registerRestSessionRoutes } from './sessions' +import { registerSessionRoutes } from './web/sessions' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +class SqlitePreparedStatement implements PreparedStatement { + readonly?: boolean + + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } + + get(...params: unknown[]): Record | undefined { + return this.stmt.get(...params) as Record | undefined + } + + all(...params: unknown[]): Record[] { + return this.stmt.all(...params) as Record[] + } + + run(...params: unknown[]): RunResult { + const result = this.stmt.run(...params) + return { changes: result.changes, lastInsertRowid: result.lastInsertRowid } + } +} + +class TestSqliteDb implements DatabaseAdapter { + constructor(private db: Database.Database) {} + + exec(sql: string): void { + this.db.exec(sql) + } + + prepare(sql: string): PreparedStatement { + return new SqlitePreparedStatement(this.db.prepare(sql)) + } + + transaction(fn: () => T): T { + return this.db.transaction(fn)() + } + + pragma(pragma: string): unknown { + return this.db.pragma(pragma) + } + + close(): void { + this.db.close() + } +} + +function createSessionDb(): TestSqliteDb { + const db = new TestSqliteDb(new Database(':memory:', { nativeBinding })) + db.exec(` + CREATE TABLE meta ( + name TEXT, + platform TEXT, + type TEXT, + imported_at INTEGER, + group_id TEXT, + group_avatar TEXT, + owner_id TEXT, + session_gap_threshold INTEGER + ); + CREATE TABLE member ( + id INTEGER PRIMARY KEY, + platform_id TEXT, + account_name TEXT, + group_nickname TEXT, + avatar TEXT + ); + CREATE TABLE message ( + id INTEGER PRIMARY KEY, + sender_id INTEGER, + ts INTEGER, + type INTEGER, + content TEXT, + platform_message_id TEXT + ); + CREATE TABLE segment ( + id INTEGER PRIMARY KEY, + start_ts INTEGER, + end_ts INTEGER, + message_count INTEGER, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + + INSERT INTO meta ( + name, platform, type, imported_at, group_id, group_avatar, owner_id, session_gap_threshold + ) VALUES ('Route Chat', 'wechat', 'group', 1700000000, 'group-1', NULL, 'alice', NULL); + INSERT INTO member (id, platform_id, account_name, group_nickname, avatar) VALUES + (1, 'alice', 'Alice', NULL, NULL), + (2, 'bob', 'Bob', NULL, NULL); + INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES + (1, 1, 100, 0, 'alpha first', 'm-1'), + (2, 2, 200, 0, 'alpha from bob', 'm-2'), + (3, 1, 300, 0, 'alpha later', 'm-3'); + `) + return db +} + +function createTestContext(dbs: Map = new Map()): HttpRouteContext { + const pathProvider: PathProvider = { + getSystemDir: () => '/tmp/chatlab-test', + getUserDataDir: () => '/tmp/chatlab-test/data', + getDatabaseDir: () => '/tmp/chatlab-test/databases', + getVectorDir: () => '/tmp/chatlab-test/vector', + getAiDataDir: () => '/tmp/chatlab-test/ai', + getSettingsDir: () => '/tmp/chatlab-test/settings', + getCacheDir: () => '/tmp/chatlab-test/cache', + getTempDir: () => '/tmp/chatlab-test/temp', + getLogsDir: () => '/tmp/chatlab-test/logs', + getDownloadsDir: () => '/tmp/chatlab-test/downloads', + } + + const dbManager = { + listSessionIds: () => Array.from(dbs.keys()), + open: (sessionId: string) => dbs.get(sessionId) ?? null, + openWritable: (sessionId: string) => dbs.get(sessionId) ?? null, + close: () => {}, + closeAll: () => {}, + getDbPath: (id: string) => `/tmp/${id}.db`, + } as unknown as DatabaseManager + + const sessionAdapter: SessionRuntimeAdapter = { + listSessionIds: () => Array.from(dbs.keys()), + openReadonly: (sessionId) => dbs.get(sessionId) ?? null, + openWritable: (sessionId) => dbs.get(sessionId) ?? null, + closeSession: () => {}, + getDbPath: (id: string) => `/tmp/${id}.db`, + deleteSessionFile: (sessionId) => dbs.delete(sessionId), + ensureReadonly: (sessionId) => { + const db = dbs.get(sessionId) + if (!db) throw Object.assign(new Error('Session not found'), { statusCode: 404 }) + return db + }, + ensureWritable: (sessionId) => { + const db = dbs.get(sessionId) + if (!db) throw Object.assign(new Error('Session not found'), { statusCode: 404 }) + return db + }, + } + + return { dbManager, sessionAdapter, pathProvider, getVersion: () => '0.0.0-test' } +} + +describe('registerSharedRoutes smoke tests', () => { + let app: FastifyInstance + + before(async () => { + app = Fastify() + registerSharedRoutes(app, createTestContext()) + await app.ready() + }) + + after(async () => { + await app.close() + }) + + it('GET /api/v1/status returns 200 with version', async () => { + const resp = await app.inject({ method: 'GET', url: '/api/v1/status' }) + assert.equal(resp.statusCode, 200) + const body = resp.json() + assert.equal(body.data.version, '0.0.0-test') + assert.equal(body.data.name, 'ChatLab API') + }) + + it('GET /api/v1/schema returns schema definition', async () => { + const resp = await app.inject({ method: 'GET', url: '/api/v1/schema' }) + assert.equal(resp.statusCode, 200) + const body = resp.json() + assert.equal(body.data.format, 'ChatLab Format') + }) + + it('GET /api/v1/sessions returns empty list', async () => { + const resp = await app.inject({ method: 'GET', url: '/api/v1/sessions' }) + assert.equal(resp.statusCode, 200) + assert.deepEqual(resp.json().data, []) + }) + + it('GET /_web/sessions returns empty list', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/sessions' }) + assert.equal(resp.statusCode, 200) + assert.ok(Array.isArray(resp.json())) + }) + + it('GET /_web/sessions/:id returns 404 for missing session', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/sessions/nonexistent' }) + assert.equal(resp.statusCode, 404) + }) + + it('GET /_web/nlp/pos-tags returns 200', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/nlp/pos-tags' }) + assert.equal(resp.statusCode, 200) + }) + + it('GET /_web/preferences returns 200 or 500', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/preferences' }) + assert.ok([200, 500].includes(resp.statusCode), `Expected 200 or 500, got ${resp.statusCode}`) + }) + + it('GET /api/v1/sessions/:id/messages applies query filters and pagination', async () => { + const db = createSessionDb() + const routeApp = Fastify() + registerRestSessionRoutes(routeApp, createTestContext(new Map([['chat-1', db]]))) + await routeApp.ready() + + const resp = await routeApp.inject({ + method: 'GET', + url: '/api/v1/sessions/chat-1/messages?keyword=alpha&senderId=1&startTime=100&endTime=250&limit=10', + }) + + await routeApp.close() + db.close() + + assert.equal(resp.statusCode, 200) + const body = resp.json() + assert.equal(body.success, true) + assert.equal(body.data.total, 1) + assert.equal(body.data.messages.length, 1) + assert.equal(body.data.messages[0].id, 1) + assert.equal(body.data.messages[0].senderName, 'Alice') + assert.equal(body.data.limit, 10) + }) + + it('POST /api/v1/sessions/:id/sql rejects write statements and keeps data unchanged', async () => { + const db = createSessionDb() + const routeApp = Fastify() + registerRestSessionRoutes(routeApp, createTestContext(new Map([['chat-1', db]]))) + await routeApp.ready() + + const resp = await routeApp.inject({ + method: 'POST', + url: '/api/v1/sessions/chat-1/sql', + payload: { sql: 'DELETE FROM message' }, + }) + const countRow = db.prepare('SELECT COUNT(*) AS count FROM message').get() as { count: number } + + await routeApp.close() + db.close() + + assert.equal(resp.statusCode, 400) + assert.equal(resp.json().error.code, 'SQL_READONLY_VIOLATION') + assert.equal(countRow.count, 3) + }) + + it('PATCH /_web/sessions/:id/name updates the shared session metadata', async () => { + const db = createSessionDb() + const routeApp = Fastify() + registerSessionRoutes(routeApp, createTestContext(new Map([['chat-1', db]]))) + await routeApp.ready() + + const resp = await routeApp.inject({ + method: 'PATCH', + url: '/_web/sessions/chat-1/name', + payload: { name: 'Renamed Chat' }, + }) + const meta = db.prepare('SELECT name FROM meta LIMIT 1').get() as { name: string } + + await routeApp.close() + db.close() + + assert.equal(resp.statusCode, 200) + assert.deepEqual(resp.json(), { success: true }) + assert.equal(meta.name, 'Renamed Chat') + }) + + it('DELETE /_web/sessions/:id delegates to the session adapter', async () => { + const db = createSessionDb() + const dbs = new Map([['chat-1', db]]) + const routeApp = Fastify() + registerSessionRoutes(routeApp, createTestContext(dbs)) + await routeApp.ready() + + const resp = await routeApp.inject({ method: 'DELETE', url: '/_web/sessions/chat-1' }) + + await routeApp.close() + db.close() + + assert.equal(resp.statusCode, 200) + assert.deepEqual(resp.json(), { success: true }) + assert.equal(dbs.has('chat-1'), false) + }) + + it('owner profile routes select, apply and dismiss across sessions', async () => { + const prefDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-owner-routes-')) + const current = createSessionDb() + current.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'`) + const other = createSessionDb() + other.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'`) + const dismissedOnly = createSessionDb() + dismissedOnly.exec(`UPDATE meta SET owner_id = NULL, platform = 'whatsapp'; DELETE FROM member`) + + const dbs = new Map([ + ['chat-1', current], + ['chat-2', other], + ['chat-3', dismissedOnly], + ]) + const ctx = createTestContext(dbs) + ctx.preferencesManager = new PreferencesManager(prefDir) + const routeApp = Fastify() + registerSessionRoutes(routeApp, ctx) + await routeApp.ready() + + try { + // Dismiss the prompt for chat-3 (no matching member there) + const dismissResp = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-3/owner/dismiss-prompt' }) + assert.equal(dismissResp.statusCode, 200) + assert.deepEqual(dismissResp.json(), { success: true }) + + // apply-profile before any profile exists reports no_profile and the dismissed flag + const earlyApply = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-3/owner/apply-profile' }) + assert.equal(earlyApply.statusCode, 200) + assert.deepEqual(earlyApply.json(), { applied: false, reason: 'no_profile', dismissed: true }) + + // Manual selection writes owner, saves profile and batch-applies to chat-2 + const selectResp = await routeApp.inject({ + method: 'POST', + url: '/_web/sessions/chat-1/owner/select', + payload: { ownerPlatformId: 'alice' }, + }) + assert.equal(selectResp.statusCode, 200) + const selectBody = selectResp.json() + assert.equal(selectBody.ownerId, 'alice') + assert.equal(selectBody.platform, 'whatsapp') + assert.deepEqual(selectBody.updatedSessionIds, ['chat-2']) + const currentMeta = current.prepare('SELECT owner_id FROM meta LIMIT 1').get() as { owner_id: string } + const otherMeta = other.prepare('SELECT owner_id FROM meta LIMIT 1').get() as { owner_id: string } + assert.equal(currentMeta.owner_id, 'alice') + assert.equal(otherMeta.owner_id, 'alice') + + // apply-profile on an already-owned session reports already_set + const applyResp = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-2/owner/apply-profile' }) + assert.equal(applyResp.statusCode, 200) + assert.deepEqual(applyResp.json(), { applied: false, ownerId: 'alice', reason: 'already_set', dismissed: false }) + } finally { + await routeApp.close() + current.close() + other.close() + dismissedOnly.close() + fs.rmSync(prefDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/http-routes/src/routes/system.ts b/packages/http-routes/src/routes/system.ts new file mode 100644 index 000000000..6a17b0163 --- /dev/null +++ b/packages/http-routes/src/routes/system.ts @@ -0,0 +1,89 @@ +/** + * ChatLab HTTP API — System routes + * + * GET /api/v1/status Service status + * GET /api/v1/schema ChatLab Format JSON Schema + */ + +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../context' +import { successResponse } from '../errors' + +export function registerSystemRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + server.get('/api/v1/status', async () => { + let sessionCount = 0 + try { + sessionCount = ctx.dbManager.listSessionIds().length + } catch { + // ignore + } + + return successResponse({ + name: 'ChatLab API', + version: ctx.getVersion(), + uptime: Math.floor(process.uptime()), + sessionCount, + }) + }) + + server.get('/api/v1/schema', async () => { + return successResponse({ + format: 'ChatLab Format', + version: '0.0.2', + spec: { + chatlab: { + type: 'object', + required: ['version'], + properties: { + version: { type: 'string' }, + exportedAt: { type: 'number' }, + generator: { type: 'string' }, + }, + }, + meta: { + type: 'object', + required: ['name', 'platform', 'type'], + properties: { + name: { type: 'string' }, + platform: { + type: 'string', + enum: ['qq', 'wechat', 'telegram', 'discord', 'line', 'whatsapp', 'instagram', 'unknown'], + }, + type: { type: 'string', enum: ['group', 'private'] }, + groupId: { type: 'string' }, + }, + }, + members: { + type: 'array', + items: { + type: 'object', + required: ['platformId', 'accountName'], + properties: { + platformId: { type: 'string' }, + accountName: { type: 'string' }, + groupNickname: { type: 'string' }, + avatar: { type: 'string' }, + }, + }, + }, + messages: { + type: 'array', + items: { + type: 'object', + required: ['sender', 'timestamp', 'type'], + properties: { + platformMessageId: { type: 'string' }, + sender: { type: 'string' }, + accountName: { type: 'string' }, + groupNickname: { type: 'string' }, + timestamp: { type: 'number' }, + type: { type: 'number' }, + content: { type: ['string', 'null'] }, + replyToMessageId: { type: 'string' }, + }, + }, + }, + }, + }) + }) +} diff --git a/packages/http-routes/src/routes/web/ai-agent-stream.ts b/packages/http-routes/src/routes/web/ai-agent-stream.ts new file mode 100644 index 000000000..a9dd01e39 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-agent-stream.ts @@ -0,0 +1,75 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext, AgentStreamRequest } from '../../context' + +const activeAgentAborts = new Map() + +export function registerAiAgentStreamRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + if (!ctx.runAgentStream) return + + const runAgentStream = ctx.runAgentStream + + server.post<{ Body: AgentStreamRequest }>('/_web/ai/agent/stream', async (request, reply) => { + const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + const abortController = new AbortController() + activeAgentAborts.set(requestId, abortController) + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Request-Id': requestId, + }) + + let emittedDone = false + + const safeSendSSE = (event: string, data: unknown) => { + if (reply.raw.writableEnded || reply.raw.destroyed) return + reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + } + + const safeEnd = () => { + if (reply.raw.writableEnded || reply.raw.destroyed) return + reply.raw.end() + } + + safeSendSSE('meta', { requestId }) + + reply.raw.on('close', () => { + if (!abortController.signal.aborted) { + abortController.abort() + } + activeAgentAborts.delete(requestId) + }) + + try { + await runAgentStream( + request.body, + (chunk) => { + if (chunk.type === 'done') emittedDone = true + safeSendSSE(chunk.type, chunk) + }, + abortController.signal + ) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + safeSendSSE('error', { type: 'error', error: { name: 'ServerError', message: msg } }) + } finally { + if (!emittedDone) safeSendSSE('done', { type: 'done', isFinished: true }) + activeAgentAborts.delete(requestId) + safeEnd() + } + }) + + server.post<{ + Body: { requestId: string } + }>('/_web/ai/agent/abort', async (request) => { + const { requestId } = request.body + const controller = activeAgentAborts.get(requestId) + if (controller) { + controller.abort() + activeAgentAborts.delete(requestId) + return { success: true } + } + return { success: false } + }) +} diff --git a/packages/http-routes/src/routes/web/ai-assistants.ts b/packages/http-routes/src/routes/web/ai-assistants.ts new file mode 100644 index 000000000..b10098aa4 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-assistants.ts @@ -0,0 +1,67 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import type { AssistantConfig } from '@openchatlab/node-runtime' +import { BUILTIN_TOOL_CATALOG } from '@openchatlab/core' + +export function registerAiAssistantRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + // Tool catalog is static data — always available regardless of AI context + server.get('/_web/ai/tools/catalog', async () => { + return BUILTIN_TOOL_CATALOG + }) + + const mgr = ctx.assistantManager + if (!mgr) return + + server.get('/_web/ai/assistants', async () => { + return mgr.getAllAssistants() + }) + + server.get<{ Params: { id: string } }>('/_web/ai/assistants/:id', async (request, reply) => { + const config = mgr.getAssistantConfig(request.params.id) + if (!config) return reply.code(404).send({ error: 'Not found' }) + return config + }) + + server.post<{ + Body: Omit + }>('/_web/ai/assistants', async (request) => { + return mgr.createAssistant(request.body) + }) + + server.put<{ + Params: { id: string } + Body: Partial + }>('/_web/ai/assistants/:id', async (request, reply) => { + const result = mgr.updateAssistant(request.params.id, request.body) + if (!result.success) return reply.code(404).send(result) + return result + }) + + server.delete<{ Params: { id: string } }>('/_web/ai/assistants/:id', async (request, reply) => { + const result = mgr.deleteAssistant(request.params.id) + if (!result.success) return reply.code(400).send(result) + return result + }) + + server.post<{ Params: { id: string } }>('/_web/ai/assistants/:id/reset', async (request, reply) => { + const result = mgr.resetAssistant(request.params.id) + if (!result.success) return reply.code(400).send(result) + return result + }) + + server.post<{ Body: { rawMd: string } }>('/_web/ai/assistants/import', async (request) => { + return mgr.importAssistantFromMd(request.body.rawMd) + }) + + server.post<{ Body: { builtinId: string } }>('/_web/ai/assistants/import-builtin', async (request, reply) => { + const result = mgr.importAssistant(request.body.builtinId) + if (!result.success) return reply.code(400).send(result) + return result + }) + + server.post<{ Params: { id: string } }>('/_web/ai/assistants/:id/reimport', async (request, reply) => { + const result = mgr.reimportAssistant(request.params.id) + if (!result.success) return reply.code(400).send(result) + return result + }) +} diff --git a/packages/http-routes/src/routes/web/ai-chats.ts b/packages/http-routes/src/routes/web/ai-chats.ts new file mode 100644 index 000000000..4c8ffccea --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-chats.ts @@ -0,0 +1,183 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { countMessagesTokens } from '@openchatlab/node-runtime' + +export function registerAiChatRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const cm = ctx.aiChatManager + if (!cm) return + + // ==================== AI Chat CRUD ==================== + + server.post<{ + Body: { sessionId: string; title?: string; assistantId: string } + }>('/_web/ai/chats', async (request) => { + const { sessionId, title, assistantId } = request.body + return cm.createAIChat(sessionId, title, assistantId) + }) + + server.get<{ + Querystring: { sessionId: string } + }>('/_web/ai/chats', async (request) => { + const { sessionId } = request.query + if (!sessionId) return [] + return cm.getAIChats(sessionId) + }) + + server.get<{ Params: { id: string } }>('/_web/ai/chats/:id', async (request, reply) => { + const conv = cm.getAIChat(request.params.id) + if (!conv) return reply.code(404).send({ error: 'AI chat not found' }) + return conv + }) + + server.put<{ + Params: { id: string } + Body: { title: string } + }>('/_web/ai/chats/:id/title', async (request) => { + return cm.updateAIChatTitle(request.params.id, request.body.title) + }) + + server.delete<{ Params: { id: string } }>('/_web/ai/chats/:id', async (request) => { + return cm.deleteAIChat(request.params.id) + }) + + // ==================== Message CRUD ==================== + + server.post<{ + Params: { id: string } + Body: { + role: 'user' | 'assistant' | 'summary' + content: string + dataKeywords?: string[] + dataMessageCount?: number + contentBlocks?: unknown[] + tokenUsage?: { + promptTokens: number + completionTokens: number + totalTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + } + } + }>('/_web/ai/chats/:id/messages', async (request) => { + const { role, content, dataKeywords, dataMessageCount, contentBlocks, tokenUsage } = request.body + return cm.addMessage( + request.params.id, + role, + content, + dataKeywords, + dataMessageCount, + contentBlocks as any, + tokenUsage + ) + }) + + server.get<{ Params: { id: string } }>('/_web/ai/chats/:id/messages', async (request) => { + return cm.getMessages(request.params.id) + }) + + server.post<{ + Params: { id: string; messageId: string } + }>('/_web/ai/chats/:id/messages/:messageId/delete-from', async (request, reply) => { + const { id, messageId } = request.params + if (!id || typeof id !== 'string' || !messageId || typeof messageId !== 'string') { + return reply.code(400).send({ error: 'aiChatId and messageId are required' }) + } + cm.deleteMessagesFrom(id, messageId) + return { success: true } + }) + + server.post<{ + Params: { id: string } + Body: { upToMessageId: string; title?: string } + }>('/_web/ai/chats/:id/fork', async (request, reply) => { + const { upToMessageId, title } = request.body + if (!upToMessageId || typeof upToMessageId !== 'string') { + return reply.code(400).send({ error: 'upToMessageId is required' }) + } + return cm.forkAIChat(request.params.id, upToMessageId, title) + }) + + server.put<{ + Params: { messageId: string } + Body: { content: string } + }>('/_web/ai/messages/:messageId/content', async (request, reply) => { + const { content } = request.body + if (!content || typeof content !== 'string') { + return reply.code(400).send({ error: 'content is required' }) + } + cm.updateMessageContent(request.params.messageId, content) + return { success: true } + }) + + server.post<{ + Params: { id: string; messageId: string } + }>('/_web/ai/chats/:id/messages/:messageId/delete-relink', async (request, reply) => { + const { id, messageId } = request.params + if (!id || !messageId) { + return reply.code(400).send({ error: 'aiChatId and messageId are required' }) + } + cm.deleteAndRelinkMessage(id, messageId) + return { success: true } + }) + + server.post<{ + Params: { id: string } + Body: { + afterMessageId: string + role: 'user' | 'assistant' | 'summary' + content: string + contentBlocks?: unknown[] + tokenUsage?: { + promptTokens: number + completionTokens: number + totalTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + } + } + }>('/_web/ai/chats/:id/messages/insert-after', async (request, reply) => { + const { afterMessageId, role, content, contentBlocks, tokenUsage } = request.body + if (!afterMessageId || typeof afterMessageId !== 'string') { + return reply.code(400).send({ error: 'afterMessageId is required' }) + } + return cm.insertMessageAfter(request.params.id, afterMessageId, role, content, contentBlocks as any, tokenUsage) + }) + + server.get<{ Params: { id: string } }>('/_web/ai/chats/:id/token-usage', async (request) => { + return cm.getAIChatTokenUsage(request.params.id) + }) + + // ==================== Debug ==================== + + server.get('/_web/ai/debug/schema', async () => { + return cm.getAiSchema() + }) + + server.post<{ + Body: { sql: string } + }>('/_web/ai/debug/execute-sql', async (request, reply) => { + const { sql } = request.body + if (!sql || typeof sql !== 'string') { + return reply.code(400).send({ error: 'sql is required' }) + } + try { + return cm.executeAiSQL(sql) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return reply.code(400).send({ error: msg }) + } + }) + + server.post('/_web/ai/debug/clear-debug-context', async () => { + const cleared = cm.clearAllDebugContext() + return { success: true, cleared } + }) + + server.get<{ + Params: { id: string } + }>('/_web/ai/chats/:id/estimate-tokens', async (request) => { + const history = cm.getHistoryForAgent(request.params.id) + const tokens = countMessagesTokens(history.map((m) => ({ role: m.role, content: m.content }))) + return { success: true, tokens, messageCount: history.length } + }) +} diff --git a/packages/http-routes/src/routes/web/ai-llm-stream.ts b/packages/http-routes/src/routes/web/ai-llm-stream.ts new file mode 100644 index 000000000..924143abd --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-llm-stream.ts @@ -0,0 +1,58 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { buildPiModel, runSimpleLlmStream } from '@openchatlab/node-runtime' + +export function registerAiLlmStreamRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const store = ctx.llmConfigStore + if (!store) return + + server.post<{ + Body: { + messages: Array<{ role: string; content: string }> + options?: { temperature?: number; maxTokens?: number } + } + }>('/_web/ai/llm/chat-stream', async (request, reply) => { + const { messages, options } = request.body + + const llmConfig = store.getDefaultAssistantConfig() + if (!llmConfig) { + return reply.code(400).send({ success: false, error: 'LLM service not configured' }) + } + + const piModel = buildPiModel(llmConfig) + const abortController = new AbortController() + + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + reply.raw.on('close', () => { + if (!abortController.signal.aborted) abortController.abort() + }) + + const sendChunk = (data: unknown) => { + if (reply.raw.writableEnded || reply.raw.destroyed) return + reply.raw.write(`event: chunk\ndata: ${JSON.stringify(data)}\n\n`) + } + + try { + await runSimpleLlmStream({ + messages, + apiKey: llmConfig.apiKey, + piModel, + temperature: options?.temperature, + maxTokens: options?.maxTokens, + onChunk: sendChunk, + abortSignal: abortController.signal, + }) + } catch (error) { + if (abortController.signal.aborted) return + const msg = error instanceof Error ? error.message : String(error) + sendChunk({ content: '', isFinished: true, finishReason: 'error', error: msg }) + } finally { + if (!reply.raw.writableEnded && !reply.raw.destroyed) reply.raw.end() + } + }) +} diff --git a/packages/http-routes/src/routes/web/ai-llm.ts b/packages/http-routes/src/routes/web/ai-llm.ts new file mode 100644 index 000000000..ae56b5817 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-llm.ts @@ -0,0 +1,236 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { BUILTIN_PROVIDERS, BUILTIN_MODELS, getBuiltinModelsByProvider } from '@openchatlab/core' +import { + validateApiKey, + fetchRemoteModels, + getDefaultRulesForLocale, + mergeRulesForLocale, +} from '@openchatlab/node-runtime' + +// ==================== API key display masking ==================== + +function hasRealApiKey(apiKey: string): boolean { + return !!apiKey && apiKey !== 'sk-no-key-required' +} + +function toConfigDisplay(config: Record): Record { + const { apiKey: _raw, ...rest } = config + return { ...rest, apiKey: '', apiKeySet: hasRealApiKey(String(_raw || '')) } +} + +// ==================== Route registration ==================== + +export function registerAiLlmRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const store = ctx.llmConfigStore + const aiDataDir = ctx.aiDataDir + if (!store || !aiDataDir) return + + // ---------- Config CRUD ---------- + + server.get('/_web/ai/llm/has-config', async () => { + return store.hasActiveConfig() + }) + + server.get('/_web/ai/llm/configs', async () => { + const data = store.loadStore() + return { + configs: data.configs.map((c) => toConfigDisplay(c as unknown as Record)), + defaultAssistant: data.defaultAssistant, + fastModel: data.fastModel, + } + }) + + server.post<{ + Body: { + name: string + provider: string + apiKey: string + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string + customModels?: Array<{ id: string; name: string }> + } + }>('/_web/ai/llm/configs', async (request) => { + const result = store.addConfig(request.body) + if (result.success && result.config) { + return { ...result, config: toConfigDisplay(result.config as unknown as Record) } + } + return result + }) + + server.put<{ + Params: { id: string } + Body: { + name?: string + provider?: string + apiKey?: string + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string + customModels?: Array<{ id: string; name: string }> + } + }>('/_web/ai/llm/configs/:id', async (request) => { + return store.updateConfig(request.params.id, request.body) + }) + + server.delete<{ Params: { id: string } }>('/_web/ai/llm/configs/:id', async (request) => { + return store.deleteConfig(request.params.id) + }) + + server.get('/_web/ai/llm/default-assistant-slot', async () => { + return store.getDefaultAssistantSlot() + }) + + server.put<{ + Body: { configId: string; modelId: string } + }>('/_web/ai/llm/default-assistant-slot', async (request) => { + return store.setDefaultAssistantModel(request.body.configId, request.body.modelId) + }) + + server.get('/_web/ai/llm/fast-model-slot', async () => { + return store.getFastModelSlot() + }) + + server.put<{ + Body: { configId: string; modelId: string } | null + }>('/_web/ai/llm/fast-model-slot', async (request) => { + return store.setFastModel(request.body) + }) + + // ---------- Provider Registry & Model Catalog ---------- + + server.get('/_web/ai/llm/providers', async () => { + return BUILTIN_PROVIDERS.map((p) => { + const models = getBuiltinModelsByProvider(p.id) + return { + id: p.id, + name: p.name, + defaultBaseUrl: p.defaultBaseUrl, + models: models + .filter((m) => !m.capabilities.includes('embedding') && !m.capabilities.includes('ranking')) + .map((m) => ({ id: m.id, name: m.name, description: m.description })), + } + }) + }) + + server.get('/_web/ai/llm/provider-registry', async () => { + const custom = ctx.customProviderStore?.getAll() ?? [] + return [...BUILTIN_PROVIDERS, ...custom] + }) + + server.get('/_web/ai/llm/model-catalog', async () => { + const custom = ctx.customModelStore?.getAll() ?? [] + return [...BUILTIN_MODELS, ...custom] + }) + + // ---------- Custom Provider CRUD ---------- + + server.post<{ + Body: { + name: string + kind: string + defaultBaseUrl: string + supportsCustomModels?: boolean + modelIds?: string[] + website?: string + consoleUrl?: string + } + }>('/_web/ai/llm/custom-providers', async (request, reply) => { + if (!ctx.customProviderStore) + return reply.code(501).send({ success: false, error: 'Custom providers not available' }) + return ctx.customProviderStore.add(request.body) + }) + + server.put<{ + Params: { id: string } + Body: Record + }>('/_web/ai/llm/custom-providers/:id', async (request, reply) => { + if (!ctx.customProviderStore) + return reply.code(501).send({ success: false, error: 'Custom providers not available' }) + const result = ctx.customProviderStore.update(request.params.id, request.body as any) + if (!result.success) return reply.code(404).send(result) + return result + }) + + server.delete<{ Params: { id: string } }>('/_web/ai/llm/custom-providers/:id', async (request, reply) => { + if (!ctx.customProviderStore) + return reply.code(501).send({ success: false, error: 'Custom providers not available' }) + const result = ctx.customProviderStore.delete(request.params.id) + if (!result.success) return reply.code(404).send(result) + return result + }) + + // ---------- Custom Model CRUD ---------- + + server.post<{ + Body: { + id: string + providerId: string + name: string + description?: string + contextWindow?: number + capabilities?: string[] + recommendedFor?: string[] + status?: string + } + }>('/_web/ai/llm/custom-models', async (request, reply) => { + if (!ctx.customModelStore) return reply.code(501).send({ success: false, error: 'Custom models not available' }) + const result = ctx.customModelStore.add(request.body) + if (!result.success) return reply.code(409).send(result) + return result + }) + + server.put<{ + Params: { providerId: string; modelId: string } + Body: Record + }>('/_web/ai/llm/custom-models/:providerId/:modelId', async (request, reply) => { + if (!ctx.customModelStore) return reply.code(501).send({ success: false, error: 'Custom models not available' }) + const result = ctx.customModelStore.update(request.params.providerId, request.params.modelId, request.body as any) + if (!result.success) return reply.code(404).send(result) + return result + }) + + server.delete<{ + Params: { providerId: string; modelId: string } + }>('/_web/ai/llm/custom-models/:providerId/:modelId', async (request, reply) => { + if (!ctx.customModelStore) return reply.code(501).send({ success: false, error: 'Custom models not available' }) + const result = ctx.customModelStore.delete(request.params.providerId, request.params.modelId) + if (!result.success) return reply.code(404).send(result) + return result + }) + + // ---------- Remote API ---------- + + server.post<{ + Body: { provider: string; apiKey: string; baseUrl?: string; model?: string; apiFormat?: string; configId?: string } + }>('/_web/ai/llm/validate-key', async (request) => { + const { provider, apiKey, baseUrl, model, apiFormat, configId } = request.body + const resolvedKey = apiKey?.trim() ? apiKey : configId ? store.getConfigById(configId)?.apiKey || '' : '' + return validateApiKey(provider, resolvedKey, baseUrl, model, apiFormat) + }) + + server.post<{ + Body: { provider: string; apiKey: string; baseUrl?: string; apiFormat?: string; configId?: string } + }>('/_web/ai/llm/remote-models', async (request) => { + const { provider, apiKey, baseUrl, apiFormat, configId } = request.body + const resolvedKey = apiKey?.trim() ? apiKey : configId ? store.getConfigById(configId)?.apiKey || '' : '' + return fetchRemoteModels(provider, resolvedKey, baseUrl, apiFormat) + }) + + // ---------- Desensitize Rules ---------- + + server.get<{ + Querystring: { locale?: string } + }>('/_web/ai/desensitize-rules/defaults', async (request) => { + return getDefaultRulesForLocale(request.query.locale ?? 'zh-CN') + }) + + server.post<{ + Body: { existingRules: unknown[]; locale: string; overrides?: Record } + }>('/_web/ai/desensitize-rules/merge', async (request) => { + return mergeRulesForLocale(request.body.existingRules as any[], request.body.locale, request.body.overrides ?? {}) + }) +} diff --git a/packages/http-routes/src/routes/web/ai-semantic-index.test.ts b/packages/http-routes/src/routes/web/ai-semantic-index.test.ts new file mode 100644 index 000000000..003cdc984 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-semantic-index.test.ts @@ -0,0 +1,205 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import Fastify, { type FastifyInstance } from 'fastify' +import type { SemanticIndexService } from '@openchatlab/node-runtime' +import { SEMANTIC_INDEX_CONFIG_FILE } from '@openchatlab/node-runtime' +import type { AuthProfile } from '@openchatlab/config' +import type { HttpRouteContext } from '../../context' +import { registerSemanticIndexRoutes } from './ai-semantic-index' + +type Call = [string, ...unknown[]] + +function makeFakeService(calls: Call[]): SemanticIndexService { + const status = (sessionId: string) => ({ + sessionId, + enabled: true, + indexStatus: 'completed' as const, + needsRebuild: false, + totalMessages: 10, + indexedMessages: 10, + chunkCount: 3, + coverage: 1, + queued: false, + running: false, + partial: false, + error: null, + modelId: 'm', + }) + return { + getConfig: () => ({ version: 1, mode: 'local', local: { modelId: 'm' }, api: null }), + hasApiKey: () => false, + isConfigured: () => true, + getModelStatus: () => 'ready', + setConfig: (c: unknown, opts?: unknown) => { + calls.push(['setConfig', c, opts]) + return c + }, + listEnabledStatuses: () => [status('s1')], + status, + statusForSessions: (ids: string[]) => { + calls.push(['statusForSessions', ids]) + return ids.map(status) + }, + enable: (id: string) => calls.push(['enable', id]), + disable: (id: string) => calls.push(['disable', id]), + build: (id: string) => calls.push(['build', id]), + pause: (id: string) => calls.push(['pause', id]), + cancel: (id: string) => calls.push(['cancel', id]), + rebuild: (id: string) => calls.push(['rebuild', id]), + buildAllPending: () => calls.push(['buildAllPending']), + cleanupUnused: () => ({ cleaned: 2 }), + search: async (id: string, query: string) => { + calls.push(['search', id, query]) + return { available: true, blocks: [], coverage: 1, partial: false } + }, + } as unknown as SemanticIndexService +} + +describe('semantic-index routes', () => { + let app: FastifyInstance + const calls: Call[] = [] + + before(async () => { + app = Fastify() + const ctx = { semanticIndexService: makeFakeService(calls) } as unknown as HttpRouteContext + registerSemanticIndexRoutes(app, ctx) + await app.ready() + }) + + after(async () => { + await app.close() + }) + + it('GET config returns current config', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/ai/semantic-index/config' }) + assert.equal(resp.statusCode, 200) + assert.equal(resp.json().config.mode, 'local') + assert.equal(resp.json().modelStatus, 'ready') + }) + + it('PUT config forwards body to setConfig', async () => { + const config = { version: 1, mode: 'api', local: { modelId: 'm' }, api: { baseUrl: 'b', model: 'x' } } + const resp = await app.inject({ method: 'PUT', url: '/_web/ai/semantic-index/config', payload: { config } }) + assert.equal(resp.statusCode, 200) + assert.ok(calls.some((c) => c[0] === 'setConfig')) + }) + + it('POST enable forwards sessionId and returns status', async () => { + const resp = await app.inject({ + method: 'POST', + url: '/_web/ai/semantic-index/enable', + payload: { sessionId: 'sess-9' }, + }) + assert.equal(resp.statusCode, 200) + assert.equal(resp.json().status.sessionId, 'sess-9') + assert.ok(calls.some((c) => c[0] === 'enable' && c[1] === 'sess-9')) + }) + + it('POST enable without sessionId returns 400', async () => { + const resp = await app.inject({ method: 'POST', url: '/_web/ai/semantic-index/enable', payload: {} }) + assert.equal(resp.statusCode, 400) + }) + + it('POST status (batch) forwards sessionIds', async () => { + const resp = await app.inject({ + method: 'POST', + url: '/_web/ai/semantic-index/status', + payload: { sessionIds: ['a', 'b'] }, + }) + assert.equal(resp.statusCode, 200) + assert.equal(resp.json().sessions.length, 2) + }) + + it('POST cleanup returns cleaned count', async () => { + const resp = await app.inject({ method: 'POST', url: '/_web/ai/semantic-index/cleanup' }) + assert.equal(resp.statusCode, 200) + assert.equal(resp.json().cleaned, 2) + }) + + it('POST search forwards query and returns availability', async () => { + const resp = await app.inject({ + method: 'POST', + url: '/_web/ai/semantic-index/search', + payload: { sessionId: 's1', query: '排期' }, + }) + assert.equal(resp.statusCode, 200) + assert.equal(resp.json().available, true) + assert.ok(calls.some((c) => c[0] === 'search' && c[2] === '排期')) + }) + + it('routes are skipped when service is absent', async () => { + const bare = Fastify() + registerSemanticIndexRoutes(bare, {} as unknown as HttpRouteContext) + await bare.ready() + const resp = await bare.inject({ method: 'GET', url: '/_web/ai/semantic-index/config' }) + assert.equal(resp.statusCode, 404) + await bare.close() + }) +}) + +// 向量库不可用(service 缺失)但 aiDataDir 存在时,仍注册「仅配置」降级路由。 +// 用注入的内存 auth-profile 读写验证 API Key 落引用且不写真实 ~/.chatlab。 +describe('semantic-index routes (degraded: service absent, aiDataDir present)', () => { + const SECRET = 'sk-degraded-secret' + const AUTH_PROFILE = 'semantic-index-embedding' + const authProfiles = new Map() + let app: FastifyInstance + let aiDataDir: string + + before(async () => { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + aiDataDir = fs.mkdtempSync(path.join(baseDir, 'chatlab-si-route-')) + app = Fastify() + const ctx = { + // semanticIndexService intentionally omitted -> degraded config-only routes + aiDataDir, + resolveApiKey: (_provider: string, authProfile?: string) => + authProfile ? (authProfiles.get(authProfile)?.key ?? '') : '', + writeAuthProfile: (name: string, profile: AuthProfile) => authProfiles.set(name, profile), + } as unknown as HttpRouteContext + registerSemanticIndexRoutes(app, ctx) + await app.ready() + }) + + after(async () => { + await app.close() + fs.rmSync(aiDataDir, { recursive: true, force: true }) + }) + + it('PUT API config + apiKey saves key by reference and reports apiKeySet', async () => { + const config = { + version: 1, + mode: 'api', + local: { modelId: '' }, + api: { baseUrl: 'https://emb.example', model: 'text-embedding-3-small' }, + } + const resp = await app.inject({ + method: 'PUT', + url: '/_web/ai/semantic-index/config', + payload: { config, apiKey: SECRET }, + }) + assert.equal(resp.statusCode, 200) + const body = resp.json() + assert.equal(body.config.api.authProfile, AUTH_PROFILE) + assert.equal(body.apiKeySet, true) + assert.equal(authProfiles.get(AUTH_PROFILE)?.key, SECRET) + assert.ok(!JSON.stringify(body.config).includes(SECRET)) + }) + + it('GET config reads back the same authProfile and apiKeySet', async () => { + const resp = await app.inject({ method: 'GET', url: '/_web/ai/semantic-index/config' }) + assert.equal(resp.statusCode, 200) + const body = resp.json() + assert.equal(body.config.api.authProfile, AUTH_PROFILE) + assert.equal(body.apiKeySet, true) + }) + + it('persisted config file never contains the plaintext apiKey', () => { + const raw = fs.readFileSync(path.join(aiDataDir, SEMANTIC_INDEX_CONFIG_FILE), 'utf-8') + assert.ok(!raw.includes(SECRET)) + assert.equal(JSON.parse(raw).api.authProfile, AUTH_PROFILE) + }) +}) diff --git a/packages/http-routes/src/routes/web/ai-semantic-index.ts b/packages/http-routes/src/routes/web/ai-semantic-index.ts new file mode 100644 index 000000000..5e8ff730e --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-semantic-index.ts @@ -0,0 +1,123 @@ +/** + * 语义索引共享 Web 路由(Electron Internal API 与 CLI Web 复用) + * + * 入参以 sessionId 暴露,不暴露 db_path_hash。service 缺失时整组路由优雅跳过。 + * AI pipeline 直接调用 SemanticIndexService,不经过这些 HTTP 路由。 + */ + +import path from 'node:path' +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import type { SemanticIndexConfig } from '@openchatlab/node-runtime' +import { + SemanticIndexConfigStore, + SEMANTIC_INDEX_CONFIG_FILE, + isSemanticIndexConfigured, + persistSemanticIndexConfig, + resolveSemanticIndexApiKeySet, +} from '@openchatlab/node-runtime' + +export function registerSemanticIndexRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const service = ctx.semanticIndexService + + // When the vector service is unavailable (e.g. sqlite-vec failed to load), register config + // routes backed by the config-only store so the settings UI can still read/write configuration. + if (!service) { + if (ctx.aiDataDir) { + const configStore = new SemanticIndexConfigStore(path.join(ctx.aiDataDir, SEMANTIC_INDEX_CONFIG_FILE)) + const configResponse = (config: SemanticIndexConfig) => ({ + config, + apiKeySet: resolveSemanticIndexApiKeySet(config, ctx.resolveApiKey), + configured: isSemanticIndexConfigured(config), + }) + server.get('/_web/ai/semantic-index/config', async () => ({ + ...configResponse(configStore.get()), + modelStatus: 'idle' as const, + })) + // 向量库不可用时仍允许写配置与 API Key(key 落 auth-profiles,不依赖向量库), + // 否则 API 模式在降级期无法保存 key,恢复后会出现"已配置但无法检索"。 + server.put<{ Body: { config: SemanticIndexConfig; apiKey?: string } }>( + '/_web/ai/semantic-index/config', + async (request) => { + const config = persistSemanticIndexConfig(configStore, request.body.config, { + apiKey: request.body.apiKey, + writeAuthProfile: ctx.writeAuthProfile, + }) + return { ...configResponse(config), modelStatus: 'idle' as const } + } + ) + } + server.get('/_web/ai/semantic-index/enabled', async () => ({ sessions: [] })) + server.get('/_web/ai/semantic-index/status', async () => ({ status: null })) + server.post('/_web/ai/semantic-index/status', async () => ({ sessions: [] })) + return + } + + server.get('/_web/ai/semantic-index/config', async () => { + return { + config: await service.getConfig(), + apiKeySet: await service.hasApiKey(), + configured: await service.isConfigured(), + modelStatus: await service.getModelStatus(), + } + }) + + server.put<{ Body: { config: SemanticIndexConfig; apiKey?: string } }>( + '/_web/ai/semantic-index/config', + async (request) => { + const config = await service.setConfig(request.body.config, { apiKey: request.body.apiKey }) + return { + config, + apiKeySet: await service.hasApiKey(), + configured: await service.isConfigured(), + modelStatus: await service.getModelStatus(), + } + } + ) + + server.get('/_web/ai/semantic-index/enabled', async () => { + return { sessions: await service.listEnabledStatuses() } + }) + + server.get<{ Querystring: { sessionId: string } }>('/_web/ai/semantic-index/status', async (request) => { + return { status: await service.status(request.query.sessionId) } + }) + + server.post<{ Body: { sessionIds: string[] } }>('/_web/ai/semantic-index/status', async (request) => { + return { sessions: await service.statusForSessions(request.body.sessionIds ?? []) } + }) + + const sessionAction = (path: string, action: (sessionId: string) => void | Promise): void => { + server.post<{ Body: { sessionId: string } }>(path, async (request, reply) => { + const { sessionId } = request.body + if (!sessionId) return reply.code(400).send({ error: 'sessionId is required' }) + await action(sessionId) + return { status: await service.status(sessionId) } + }) + } + + sessionAction('/_web/ai/semantic-index/enable', (id) => service.enable(id)) + sessionAction('/_web/ai/semantic-index/remove', (id) => service.remove(id)) + sessionAction('/_web/ai/semantic-index/build', (id) => service.build(id)) + sessionAction('/_web/ai/semantic-index/pause', (id) => service.pause(id)) + sessionAction('/_web/ai/semantic-index/cancel', (id) => service.cancel(id)) + sessionAction('/_web/ai/semantic-index/rebuild', (id) => service.rebuild(id)) + + server.post('/_web/ai/semantic-index/build-pending', async () => { + await service.buildAllPending() + return { sessions: await service.listEnabledStatuses() } + }) + + server.post('/_web/ai/semantic-index/cleanup', async () => { + return await service.cleanupUnused() + }) + + server.post<{ Body: { sessionId: string; query: string; finalTopK?: number } }>( + '/_web/ai/semantic-index/search', + async (request, reply) => { + const { sessionId, query, finalTopK } = request.body + if (!sessionId || !query) return reply.code(400).send({ error: 'sessionId and query are required' }) + return service.search(sessionId, query, { finalTopK }) + } + ) +} diff --git a/packages/http-routes/src/routes/web/ai-skills.ts b/packages/http-routes/src/routes/web/ai-skills.ts new file mode 100644 index 000000000..4118d3694 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-skills.ts @@ -0,0 +1,56 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' + +export function registerAiSkillRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const mgr = ctx.skillManagerCore + if (!mgr) return + + server.get('/_web/ai/skills', async () => { + return mgr.getAllSkills() + }) + + server.get<{ Params: { id: string } }>('/_web/ai/skills/:id', async (request, reply) => { + const config = mgr.getSkillConfig(request.params.id) + if (!config) return reply.code(404).send({ error: 'Not found' }) + return config + }) + + server.post<{ Body: { rawMd: string } }>('/_web/ai/skills', async (request) => { + return mgr.createSkill(request.body.rawMd) + }) + + server.put<{ + Params: { id: string } + Body: { rawMd: string } + }>('/_web/ai/skills/:id', async (request, reply) => { + const result = mgr.updateSkill(request.params.id, request.body.rawMd) + if (!result.success) return reply.code(404).send(result) + return result + }) + + server.delete<{ Params: { id: string } }>('/_web/ai/skills/:id', async (request, reply) => { + const result = mgr.deleteSkill(request.params.id) + if (!result.success) return reply.code(400).send(result) + return result + }) + + server.post<{ Body: { rawMd: string } }>('/_web/ai/skills/import', async (request) => { + return mgr.importSkillFromMd(request.body.rawMd) + }) + + server.get('/_web/ai/skills/builtin-catalog', async () => { + return mgr.getBuiltinCatalog() + }) + + server.post<{ Body: { builtinId: string } }>('/_web/ai/skills/import-builtin', async (request, reply) => { + const result = mgr.importSkill(request.body.builtinId) + if (!result.success) return reply.code(400).send(result) + return result + }) + + server.post<{ Params: { id: string } }>('/_web/ai/skills/:id/reimport', async (request, reply) => { + const result = mgr.reimportSkill(request.params.id) + if (!result.success) return reply.code(400).send(result) + return result + }) +} diff --git a/packages/http-routes/src/routes/web/ai-summaries.ts b/packages/http-routes/src/routes/web/ai-summaries.ts new file mode 100644 index 000000000..ead7cb80f --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-summaries.ts @@ -0,0 +1,65 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { summaryService, buildPiModel } from '@openchatlab/node-runtime' +import type { SummaryServiceDeps, LlmConfig, PiModelConfig } from '@openchatlab/node-runtime' + +function createSummaryDeps(ctx: HttpRouteContext): SummaryServiceDeps | null { + const store = ctx.llmConfigStore + if (!store) return null + return { + getLlmConfig(): LlmConfig | null { + const config = store.getDefaultAssistantConfig() + if (!config) return null + return config + }, + buildPiModel(config: LlmConfig) { + return buildPiModel(config as unknown as PiModelConfig) + }, + } +} + +export function registerAiSummaryRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const deps = createSummaryDeps(ctx) + if (!deps) return + + const { sessionAdapter: adapter } = ctx + + server.post<{ + Params: { id: string } + Body: { segmentId: number; locale?: string; forceRegenerate?: boolean; strategy?: 'brief' | 'standard' } + }>('/_web/sessions/:id/summaries/generate', async (request, reply) => { + const { segmentId, locale, forceRegenerate, strategy } = request.body + const result = await summaryService.generateSummary(adapter, request.params.id, segmentId, deps, { + locale, + forceRegenerate, + strategy, + }) + if ('error' in result && !result.success) { + return reply.code(400).send({ error: result.error }) + } + return result + }) + + server.post<{ + Params: { id: string } + Body: { locale?: string; forceRegenerate?: boolean } + }>('/_web/sessions/:id/summaries/generate-all', async (request, reply) => { + const { locale, forceRegenerate } = request.body + const result = await summaryService.generateAllSummaries(adapter, request.params.id, deps, { + locale, + forceRegenerate, + }) + if (result.error) { + return reply.code(400).send({ error: result.error }) + } + return result + }) + + server.post<{ + Params: { id: string } + Body: { segmentIds: number[] } + }>('/_web/sessions/:id/summaries/check-can-generate', async (request) => { + const { segmentIds } = request.body + return summaryService.checkCanGenerate(adapter, request.params.id, segmentIds) + }) +} diff --git a/packages/http-routes/src/routes/web/ai-tools.test.ts b/packages/http-routes/src/routes/web/ai-tools.test.ts new file mode 100644 index 000000000..937e78d68 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-tools.test.ts @@ -0,0 +1,132 @@ +import { describe, it, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import Fastify, { type FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { registerAiToolRoutes } from './ai-tools' + +function createContext(overrides: Partial = {}): HttpRouteContext { + return { + dbManager: { + open: () => null, + }, + ...overrides, + } as unknown as HttpRouteContext +} + +describe('ai tool debug routes', () => { + let app: FastifyInstance | null = null + + afterEach(async () => { + if (app) { + await app.close() + app = null + } + }) + + it('delegates tool execution to the platform hook when provided', async () => { + const calls: Array<{ toolName: string; sessionId: string; params: Record }> = [] + app = Fastify() + registerAiToolRoutes( + app, + createContext({ + executeAiTool: async ({ toolName, sessionId, params }) => { + calls.push({ toolName, sessionId, params }) + return { + success: true, + elapsed: 12, + content: [{ type: 'text', text: 'ok' }], + details: { delegated: true }, + truncated: false, + } + }, + }) + ) + await app.ready() + + const resp = await app.inject({ + method: 'POST', + url: '/_web/ai/tools/execute', + payload: { + testId: 'test-1', + toolName: 'get_chat_overview', + sessionId: 'chat-1', + params: { top_n: 3 }, + }, + }) + + assert.equal(resp.statusCode, 200) + assert.deepEqual(resp.json(), { + success: true, + elapsed: 12, + content: [{ type: 'text', text: 'ok' }], + details: { delegated: true }, + truncated: false, + }) + assert.deepEqual(calls, [{ toolName: 'get_chat_overview', sessionId: 'chat-1', params: { top_n: 3 } }]) + }) + + it('aborts an active platform tool execution when cancelled', async () => { + let startedResolve: (() => void) | undefined + const started = new Promise((resolve) => { + startedResolve = resolve + }) + + app = Fastify() + registerAiToolRoutes( + app, + createContext({ + executeAiTool: async ({ abortSignal }) => { + startedResolve?.() + return new Promise((resolve) => { + abortSignal.addEventListener('abort', () => resolve({ success: false, error: 'cancelled' }), { once: true }) + }) + }, + }) + ) + await app.ready() + + const executePromise = app.inject({ + method: 'POST', + url: '/_web/ai/tools/execute', + payload: { + testId: 'test-cancel', + toolName: 'get_chat_overview', + sessionId: 'chat-1', + params: {}, + }, + }) + await started + + const cancelResp = await app.inject({ + method: 'POST', + url: '/_web/ai/tools/cancel', + payload: { testId: 'test-cancel' }, + }) + const executeResp = await executePromise + + assert.equal(cancelResp.statusCode, 200) + assert.deepEqual(cancelResp.json(), { success: true }) + assert.equal(executeResp.statusCode, 200) + assert.deepEqual(executeResp.json(), { success: false, error: 'cancelled' }) + }) + + it('keeps the CoreDataProvider fallback when no platform hook is provided', async () => { + app = Fastify() + registerAiToolRoutes(app, createContext()) + await app.ready() + + const resp = await app.inject({ + method: 'POST', + url: '/_web/ai/tools/execute', + payload: { + testId: 'test-fallback', + toolName: 'get_chat_overview', + sessionId: 'missing-session', + params: {}, + }, + }) + + assert.equal(resp.statusCode, 404) + assert.deepEqual(resp.json(), { success: false, error: 'Session not found: missing-session' }) + }) +}) diff --git a/packages/http-routes/src/routes/web/ai-tools.ts b/packages/http-routes/src/routes/web/ai-tools.ts new file mode 100644 index 000000000..156a676f4 --- /dev/null +++ b/packages/http-routes/src/routes/web/ai-tools.ts @@ -0,0 +1,103 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { AGENT_TOOL_REGISTRY, CoreDataProvider } from '@openchatlab/tools' +import type { ToolExecutionContext } from '@openchatlab/tools' +import { stripAvatarFields } from '@openchatlab/core' + +const MAX_RESULT_CHARS = 500_000 +const activeToolTests = new Map() + +export function registerAiToolRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + server.get('/_web/ai/tools/full-catalog', async () => { + return AGENT_TOOL_REGISTRY.map((tool) => ({ + name: tool.name, + category: tool.category ?? 'core', + description: tool.description, + parameters: tool.inputSchema ?? {}, + })) + }) + + server.post<{ + Body: { + testId: string + toolName: string + params: Record + sessionId: string + } + }>('/_web/ai/tools/execute', async (request, reply) => { + const { testId, toolName, params, sessionId } = request.body + + const entry = AGENT_TOOL_REGISTRY.find((t) => t.name === toolName) + if (!entry) { + return reply.code(404).send({ success: false, error: `Tool not found: ${toolName}` }) + } + + const abortController = new AbortController() + activeToolTests.set(testId, abortController) + + try { + if (ctx.executeAiTool) { + return await ctx.executeAiTool({ testId, toolName, params, sessionId, abortSignal: abortController.signal }) + } + + const db = ctx.dbManager.open(sessionId) + if (!db) { + return reply.code(404).send({ success: false, error: `Session not found: ${sessionId}` }) + } + + const execCtx: ToolExecutionContext = { + sessionId, + db, + dataProvider: new CoreDataProvider(db), + abortSignal: abortController.signal, + } + + const startTime = Date.now() + const result = await entry.handler(params, execCtx) + const elapsed = Date.now() - startTime + + if (abortController.signal.aborted) { + return { success: false, error: 'cancelled' } + } + + let details = (result.data as Record | undefined) ?? undefined + let truncated = false + + if (details) { + stripAvatarFields(details) + const raw = JSON.stringify(details) + if (raw.length > MAX_RESULT_CHARS) { + truncated = true + details = { _truncated: true, _originalSize: raw.length, _preview: raw.slice(0, MAX_RESULT_CHARS) } + } + } + + return { + success: true, + elapsed, + content: [{ type: 'text', text: result.content }], + details, + truncated, + } + } catch (error) { + if (abortController.signal.aborted) { + return { success: false, error: 'cancelled' } + } + console.error(`Failed to execute tool ${toolName}:`, error) + return { success: false, error: String(error) } + } finally { + activeToolTests.delete(testId) + } + }) + + server.post<{ Body: { testId: string } }>('/_web/ai/tools/cancel', async (request) => { + const { testId } = request.body + const controller = activeToolTests.get(testId) + if (controller) { + controller.abort() + activeToolTests.delete(testId) + return { success: true } + } + return { success: false } + }) +} diff --git a/packages/http-routes/src/routes/web/analytics.test.ts b/packages/http-routes/src/routes/web/analytics.test.ts new file mode 100644 index 000000000..b21710444 --- /dev/null +++ b/packages/http-routes/src/routes/web/analytics.test.ts @@ -0,0 +1,175 @@ +/** + * analytics 路由缓存接入集成测试。 + * + * 运行:node --import tsx --test packages/http-routes/src/routes/web/analytics.test.ts + * + * 验证目标(接入契约,不重复 core 的算法矩阵): + * 1. 同一查询二次请求命中磁盘缓存、不再访问数据库(命中后关闭 db 仍能成功返回)。 + * 2. DB 文件状态变化(mtime/size)使缓存失效,触发重新计算。 + */ + +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import Database from 'better-sqlite3' +import Fastify, { type FastifyInstance } from 'fastify' +import type { DatabaseAdapter, PathProvider, PreparedStatement, RunResult } from '@openchatlab/core' +import type { SessionRuntimeAdapter, DatabaseManager } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' +import { registerAnalyticsRoutes } from './analytics' + +class Stmt implements PreparedStatement { + readonly?: boolean + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } + get(...p: unknown[]) { + return this.stmt.get(...p) as Record | undefined + } + all(...p: unknown[]) { + return this.stmt.all(...p) as Record[] + } + run(...p: unknown[]): RunResult { + const r = this.stmt.run(...p) + return { changes: r.changes, lastInsertRowid: r.lastInsertRowid } + } +} + +class Adapter implements DatabaseAdapter { + constructor(private db: Database.Database) {} + exec(sql: string) { + this.db.exec(sql) + } + prepare(sql: string) { + return new Stmt(this.db.prepare(sql)) + } + transaction(fn: () => T): T { + return this.db.transaction(fn)() + } + pragma(p: string) { + return this.db.pragma(p) + } + close() { + this.db.close() + } +} + +const SESSION_ID = 'chat-1' +const MEMBER_ACTIVITY_URL = `/_web/sessions/${SESSION_ID}/stats/member-activity` +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +describe('analytics routes caching', () => { + let root: string + let dbFile: string + let raw: Database.Database + let app: FastifyInstance + + beforeEach(async () => { + const base = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + root = fs.mkdtempSync(path.join(base, 'chatlab-analytics-routes-')) + dbFile = path.join(root, `${SESSION_ID}.db`) + raw = new Database(dbFile, { nativeBinding }) + raw.exec(` + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT, account_name TEXT, group_nickname TEXT, avatar TEXT); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT, platform_message_id TEXT); + INSERT INTO member (id, platform_id, account_name) VALUES (1, 'alice', 'Alice'), (2, 'bob', 'Bob'); + INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) VALUES + (1, 1, 100, 0, 'a', 'm-1'), (2, 2, 200, 0, 'b', 'm-2'), (3, 1, 300, 0, 'c', 'm-3'); + `) + const adapter = new Adapter(raw) + + const pathProvider = { + getCacheDir: () => path.join(root, 'cache'), + } as unknown as PathProvider + const sessionAdapter = { + ensureReadonly: () => adapter, + getDbPath: () => dbFile, + } as unknown as SessionRuntimeAdapter + const ctx = { + sessionAdapter, + pathProvider, + dbManager: {} as unknown as DatabaseManager, + getVersion: () => 'test', + } as HttpRouteContext + + app = Fastify() + registerAnalyticsRoutes(app, ctx) + await app.ready() + }) + + afterEach(async () => { + await app.close() + try { + raw.close() + } catch { + /* already closed by a test */ + } + fs.rmSync(root, { recursive: true, force: true }) + }) + + it('serves the second identical request from cache without touching the db', async () => { + const first = await app.inject({ method: 'GET', url: MEMBER_ACTIVITY_URL }) + assert.equal(first.statusCode, 200) + const firstBody = first.json() + assert.ok(Array.isArray(firstBody) && firstBody.length === 2) + + // 缓存文件已写入 cacheDir/query + assert.ok(fs.existsSync(path.join(root, 'cache', 'query', `${SESSION_ID}.cache.json`))) + + // 关闭底层数据库:若二次请求重新计算必然抛错,命中缓存才能成功 + raw.close() + + const second = await app.inject({ method: 'GET', url: MEMBER_ACTIVITY_URL }) + assert.equal(second.statusCode, 200) + assert.deepEqual(second.json(), firstBody) + }) + + it('recomputes after the db file changes (cache invalidation)', async () => { + const first = await app.inject({ method: 'GET', url: MEMBER_ACTIVITY_URL }) + assert.equal(first.statusCode, 200) + + // 改变 DB 文件大小 => 版本指纹变化 => 缓存失效。配合关闭连接,重算会抛错。 + raw.close() + fs.appendFileSync(dbFile, Buffer.from('xx')) + + const second = await app.inject({ method: 'GET', url: MEMBER_ACTIVITY_URL }) + assert.equal(second.statusCode, 500) + }) + + it('keys long-message-count cache by minLength', async () => { + const base = `/_web/sessions/${SESSION_ID}/analytics/long-message-count` + const first = await app.inject({ method: 'GET', url: `${base}?minLength=1` }) + assert.equal(first.statusCode, 200) + + raw.close() + + // 相同 minLength => 命中缓存 + const same = await app.inject({ method: 'GET', url: `${base}?minLength=1` }) + assert.equal(same.statusCode, 200) + assert.deepEqual(same.json(), first.json()) + + // 不同 minLength => 缓存未命中、重算因 db 关闭而失败,证明 minLength 进入缓存键 + const diff = await app.inject({ method: 'GET', url: `${base}?minLength=999` }) + assert.equal(diff.statusCode, 500) + }) + + it('keys cluster cache by options so different params recompute', async () => { + const base = `/_web/sessions/${SESSION_ID}/analytics/cluster` + const first = await app.inject({ method: 'GET', url: `${base}?lookAhead=3&decaySeconds=120&topEdges=150` }) + assert.equal(first.statusCode, 200) + + // 关闭底层数据库:命中缓存才能成功,重算必然抛错。 + raw.close() + + // 相同参数 => 命中缓存 + const same = await app.inject({ method: 'GET', url: `${base}?lookAhead=3&decaySeconds=120&topEdges=150` }) + assert.equal(same.statusCode, 200) + assert.deepEqual(same.json(), first.json()) + + // 不同参数 => 缓存未命中、重算因 db 关闭而失败,证明 lookAhead 已进入缓存键 + const diff = await app.inject({ method: 'GET', url: `${base}?lookAhead=10&decaySeconds=120&topEdges=150` }) + assert.equal(diff.statusCode, 500) + }) +}) diff --git a/packages/http-routes/src/routes/web/analytics.ts b/packages/http-routes/src/routes/web/analytics.ts new file mode 100644 index 000000000..349c1ee45 --- /dev/null +++ b/packages/http-routes/src/routes/web/analytics.ts @@ -0,0 +1,328 @@ +import * as fs from 'fs' +import * as path from 'path' +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { createJiebaNlpProvider } from '@openchatlab/node-runtime' +import { + getTimeRange, + getAvailableYears, + getMemberActivity, + getHourlyActivity, + getDailyActivity, + getWeekdayActivity, + getMessageTypeStats, + getMonthlyActivity, + getYearlyActivity, + getMessageLengthDistribution, + getTextStats, + getLongMessageCount, + getMemberMonthlyTrend, + getTextLengthPercentiles, + getRelationshipStats, + getCatchphraseAnalysis, + getMentionAnalysis, + getMentionGraph, + getLaughAnalysis, + getClusterGraph, + getLanguagePreferenceAnalysis, + getDragonKingAnalysis, + getDivingAnalysis, + getCheckInAnalysis, + getMemeBattleAnalysis, + getNightOwlAnalysis, + getRepeatAnalysis, +} from '@openchatlab/core' +import type { ClusterGraphOptions } from '@openchatlab/core' +import { parseTimeFilter } from '../../helpers' +import { withAnalyticsCache } from '../../analytics-cache' + +type FilteredQuery = { startTs?: string; endTs?: string; memberId?: string } + +export function registerAnalyticsRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx + + /** Cache-first wrapper bound to this context; `params` must capture all inputs that affect the result. */ + const cached = ( + name: string, + sessionId: string, + params: Record, + compute: () => T, + options?: { dailyInvalidate?: boolean; extraVersion?: string } + ): T => withAnalyticsCache(ctx, sessionId, `analytics.${name}`, params, compute, options) + + server.get<{ Params: { id: string } }>('/_web/sessions/:id/years', async (request) => { + const db = adapter.ensureReadonly(request.params.id) + return getAvailableYears(db) + }) + + server.get<{ Params: { id: string } }>('/_web/sessions/:id/time-range', async (request) => { + const db = adapter.ensureReadonly(request.params.id) + return getTimeRange(db) + }) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/stats/member-activity', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('member-activity', id, { ...filter }, () => getMemberActivity(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/stats/hourly', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('hourly', id, { ...filter }, () => getHourlyActivity(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/stats/daily', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('daily', id, { ...filter }, () => getDailyActivity(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/stats/weekday', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('weekday', id, { ...filter }, () => getWeekdayActivity(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/stats/message-types', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('message-types', id, { ...filter }, () => getMessageTypeStats(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/relationship', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('relationship', id, { ...filter }, () => getRelationshipStats(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/catchphrase', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('catchphrase', id, { ...filter }, () => getCatchphraseAnalysis(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/mention', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('mention', id, { ...filter }, () => getMentionAnalysis(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/mention-graph', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('mention-graph', id, { ...filter }, () => getMentionGraph(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/laugh', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('laugh', id, { ...filter }, () => getLaughAnalysis(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ + Params: { id: string } + Querystring: FilteredQuery & { topEdges?: string; lookAhead?: string; decaySeconds?: string } + }>('/_web/sessions/:id/analytics/cluster', async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + // 仅收集显式传入的参数,避免 undefined 覆盖核心算法默认值 + const options: ClusterGraphOptions = {} + if (request.query.topEdges) options.topEdges = parseInt(request.query.topEdges, 10) + if (request.query.lookAhead) options.lookAhead = parseInt(request.query.lookAhead, 10) + if (request.query.decaySeconds) options.decaySeconds = parseInt(request.query.decaySeconds, 10) + const hasOptions = Object.keys(options).length > 0 + return cached('cluster', id, { ...filter, ...options }, () => + getClusterGraph(adapter.ensureReadonly(id), filter, hasOptions ? options : undefined) + ) + }) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery & { locale?: string } }>( + '/_web/sessions/:id/analytics/language-preference', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + const locale = request.query.locale || 'zh-CN' + const zhDictPath = path.join(ctx.pathProvider.getSystemDir(), 'nlp', 'zh-CN.dict') + let zhDictVersion: string + try { + const st = fs.statSync(zhDictPath) + zhDictVersion = `${Math.floor(st.mtimeMs)}:${st.size}` + } catch { + zhDictVersion = '-' + } + return cached( + 'language-preference', + id, + { ...filter, locale }, + () => + getLanguagePreferenceAnalysis(adapter.ensureReadonly(id), { + locale, + timeFilter: filter, + nlpProvider: createJiebaNlpProvider(), + }), + { extraVersion: `dict:${zhDictVersion}` } + ) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/monthly-activity', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('monthly-activity', id, { ...filter }, () => getMonthlyActivity(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/yearly-activity', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('yearly-activity', id, { ...filter }, () => getYearlyActivity(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/message-length-distribution', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('message-length-distribution', id, { ...filter }, () => + getMessageLengthDistribution(adapter.ensureReadonly(id), filter) + ) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/text-stats', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('text-stats', id, { ...filter }, () => getTextStats(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery & { minLength?: string } }>( + '/_web/sessions/:id/analytics/long-message-count', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + const minLength = request.query.minLength ? parseInt(request.query.minLength, 10) : undefined + return cached('long-message-count', id, { ...filter, minLength }, () => + getLongMessageCount(adapter.ensureReadonly(id), filter, minLength) + ) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/member-monthly-trend', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('member-monthly-trend', id, { ...filter }, () => + getMemberMonthlyTrend(adapter.ensureReadonly(id), filter) + ) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/text-length-percentiles', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('text-length-percentiles', id, { ...filter }, () => + getTextLengthPercentiles(adapter.ensureReadonly(id), filter) + ) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/dragon-king', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('dragon-king', id, { ...filter }, () => getDragonKingAnalysis(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/diving', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('diving', id, { ...filter }, () => getDivingAnalysis(adapter.ensureReadonly(id), filter), { + dailyInvalidate: true, + }) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/check-in', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('check-in', id, { ...filter }, () => getCheckInAnalysis(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/meme-battle', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('meme-battle', id, { ...filter }, () => getMemeBattleAnalysis(adapter.ensureReadonly(id), filter)) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/night-owl', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('night-owl', id, { ...filter }, () => getNightOwlAnalysis(adapter.ensureReadonly(id), filter), { + dailyInvalidate: true, + }) + } + ) + + server.get<{ Params: { id: string }; Querystring: FilteredQuery }>( + '/_web/sessions/:id/analytics/repeat', + async (request) => { + const id = request.params.id + const filter = parseTimeFilter(request.query) + return cached('repeat', id, { ...filter }, () => getRepeatAnalysis(adapter.ensureReadonly(id), filter)) + } + ) +} diff --git a/packages/http-routes/src/routes/web/cache.test.ts b/packages/http-routes/src/routes/web/cache.test.ts new file mode 100644 index 000000000..48a3cfc0d --- /dev/null +++ b/packages/http-routes/src/routes/web/cache.test.ts @@ -0,0 +1,161 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import Fastify from 'fastify' +import type { PathProvider } from '@openchatlab/core' +import type { HttpRouteContext } from '../../context' +import { registerCacheRoutes } from './cache' + +function createPathProvider(overrides: Partial = {}): PathProvider { + return { + getSystemDir: () => '/tmp/chatlab-test', + getUserDataDir: () => '/tmp/chatlab-test/data', + getDatabaseDir: () => '/tmp/chatlab-test/databases', + getVectorDir: () => '/tmp/chatlab-test/vector', + getAiDataDir: () => '/tmp/chatlab-test/ai', + getSettingsDir: () => '/tmp/chatlab-test/settings', + getCacheDir: () => '/tmp/chatlab-test/cache', + getTempDir: () => '/tmp/chatlab-test/temp', + getLogsDir: () => '/tmp/chatlab-test/logs', + getDownloadsDir: () => '/tmp/chatlab-test/downloads', + ...overrides, + } +} + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-cache-routes-')) +} + +describe('registerCacheRoutes data directory routes', () => { + it('returns data directory capability and pending migration', async () => { + const app = Fastify() + const ctx = { + pathProvider: createPathProvider(), + defaultUserDataDir: '/tmp/chatlab-test/default-data', + isCustomDataDir: true, + canSetDataDir: true, + getPendingDataDirMigration: () => ({ + from: '/tmp/chatlab-test/data', + to: '/tmp/chatlab-test/new-data', + migrate: true, + createdAt: '2026-06-02T00:00:00.000Z', + }), + } as unknown as HttpRouteContext + + registerCacheRoutes(app, ctx) + await app.ready() + + const response = await app.inject({ method: 'GET', url: '/_web/cache/data-dir' }) + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { + path: '/tmp/chatlab-test/data', + defaultPath: '/tmp/chatlab-test/default-data', + isCustom: true, + canSetDataDir: true, + hasLegacyDataAtDefaultDir: false, + pendingMigration: { + from: '/tmp/chatlab-test/data', + to: '/tmp/chatlab-test/new-data', + createdAt: '2026-06-02T00:00:00.000Z', + }, + }) + + await app.close() + }) + + it('reports legacy data at default directory only when default databases contain db files', async () => { + const root = makeTempDir() + const currentDir = path.join(root, 'custom-data') + const defaultDir = path.join(root, 'default-data') + fs.mkdirSync(path.join(defaultDir, 'databases'), { recursive: true }) + fs.writeFileSync(path.join(defaultDir, '.chatlab'), 'ChatLab Data Directory') + + const appWithoutDb = Fastify() + registerCacheRoutes(appWithoutDb, { + pathProvider: createPathProvider({ getUserDataDir: () => currentDir }), + defaultUserDataDir: defaultDir, + isCustomDataDir: true, + } as unknown as HttpRouteContext) + await appWithoutDb.ready() + + const emptyResponse = await appWithoutDb.inject({ method: 'GET', url: '/_web/cache/data-dir' }) + assert.equal(emptyResponse.statusCode, 200) + assert.equal(emptyResponse.json().hasLegacyDataAtDefaultDir, false) + await appWithoutDb.close() + + fs.writeFileSync(path.join(defaultDir, 'databases', 'legacy.db'), 'sqlite') + + const appWithDb = Fastify() + registerCacheRoutes(appWithDb, { + pathProvider: createPathProvider({ getUserDataDir: () => currentDir }), + defaultUserDataDir: defaultDir, + isCustomDataDir: true, + } as unknown as HttpRouteContext) + await appWithDb.ready() + + const response = await appWithDb.inject({ method: 'GET', url: '/_web/cache/data-dir' }) + assert.equal(response.statusCode, 200) + assert.equal(response.json().hasLegacyDataAtDefaultDir, true) + await appWithDb.close() + }) + + it('delegates data directory changes to context callback', async () => { + const app = Fastify() + const calls: Array<{ path: string | null; migrate?: boolean }> = [] + const ctx = { + pathProvider: createPathProvider(), + setDataDir: (dirPath: string | null, migrate?: boolean) => { + calls.push({ path: dirPath, migrate }) + return { + success: true, + from: '/tmp/chatlab-test/data', + to: dirPath ?? '/tmp/chatlab-test/default-data', + requiresRelaunch: true, + } + }, + } as unknown as HttpRouteContext + + registerCacheRoutes(app, ctx) + await app.ready() + + const response = await app.inject({ + method: 'POST', + url: '/_web/cache/data-dir', + payload: { path: '/tmp/chatlab-test/new-data', migrate: true }, + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(calls, [{ path: '/tmp/chatlab-test/new-data', migrate: true }]) + assert.deepEqual(response.json(), { + success: true, + from: '/tmp/chatlab-test/data', + to: '/tmp/chatlab-test/new-data', + requiresRelaunch: true, + }) + + await app.close() + }) + + it('returns 501 when data directory changes are unsupported', async () => { + const app = Fastify() + registerCacheRoutes(app, { pathProvider: createPathProvider() } as unknown as HttpRouteContext) + await app.ready() + + const response = await app.inject({ + method: 'POST', + url: '/_web/cache/data-dir', + payload: { path: '/tmp/chatlab-test/new-data', migrate: true }, + }) + + assert.equal(response.statusCode, 501) + assert.deepEqual(response.json(), { + success: false, + error: 'Data directory changes are not supported', + }) + + await app.close() + }) +}) diff --git a/packages/http-routes/src/routes/web/cache.ts b/packages/http-routes/src/routes/web/cache.ts new file mode 100644 index 000000000..8d2443f0f --- /dev/null +++ b/packages/http-routes/src/routes/web/cache.ts @@ -0,0 +1,260 @@ +import * as fs from 'fs' +import * as fsp from 'fs/promises' +import * as path from 'path' +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' + +async function getDirSize(dirPath: string): Promise { + let totalSize = 0 + try { + if (!fs.existsSync(dirPath)) return 0 + const files = await fsp.readdir(dirPath, { withFileTypes: true }) + for (const file of files) { + const filePath = path.join(dirPath, file.name) + if (file.isDirectory()) { + totalSize += await getDirSize(filePath) + } else { + const stat = await fsp.stat(filePath) + totalSize += stat.size + } + } + } catch { + /* directory inaccessible */ + } + return totalSize +} + +async function getFileCount(dirPath: string): Promise { + let count = 0 + try { + if (!fs.existsSync(dirPath)) return 0 + const files = await fsp.readdir(dirPath, { withFileTypes: true }) + for (const file of files) { + const filePath = path.join(dirPath, file.name) + if (file.isDirectory()) { + count += await getFileCount(filePath) + } else { + count++ + } + } + } catch { + /* directory inaccessible */ + } + return count +} + +function hasChatLabDatabases(dirPath: string): boolean { + try { + const markerPath = path.join(dirPath, '.chatlab') + const dbDir = path.join(dirPath, 'databases') + if (!fs.existsSync(markerPath) || !fs.existsSync(dbDir)) return false + return fs.readdirSync(dbDir).some((file) => file.endsWith('.db')) + } catch { + return false + } +} + +export function registerCacheRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const pp = ctx.pathProvider + const downloadsDir = ctx.downloadsDir ?? pp.getDownloadsDir() + + server.get('/_web/cache/info', async () => { + const directories = [ + { + id: 'databases', + name: 'settings.storage.cache.databases.name', + description: 'settings.storage.cache.databases.description', + path: pp.getDatabaseDir(), + icon: 'i-heroicons-circle-stack', + canClear: false, + }, + { + id: 'ai', + name: 'settings.storage.cache.ai.name', + description: 'settings.storage.cache.ai.description', + path: pp.getAiDataDir(), + icon: 'i-heroicons-sparkles', + canClear: false, + }, + { + id: 'cache', + name: 'settings.storage.cache.statsCache.name', + description: 'settings.storage.cache.statsCache.description', + path: pp.getCacheDir(), + icon: 'i-heroicons-bolt', + canClear: true, + }, + { + id: 'logs', + name: 'settings.storage.cache.logs.name', + description: 'settings.storage.cache.logs.description', + path: pp.getLogsDir(), + icon: 'i-heroicons-document-text', + canClear: true, + }, + ] + + const results = await Promise.all( + directories.map(async (dir) => { + const [size, fileCount] = await Promise.all([getDirSize(dir.path), getFileCount(dir.path)]) + return { ...dir, size, fileCount, exists: fs.existsSync(dir.path) } + }) + ) + + return { + baseDir: pp.getUserDataDir(), + directories: results, + totalSize: results.reduce((sum, d) => sum + d.size, 0), + } + }) + + server.post<{ Body: { cacheId: string } }>('/_web/cache/clear', async (request) => { + const { cacheId } = request.body + const allowedDirs: Record = { + cache: pp.getCacheDir(), + logs: pp.getLogsDir(), + } + const dirPath = allowedDirs[cacheId] + if (!dirPath) return { success: false, error: 'Not allowed to clear this directory' } + + if (!fs.existsSync(dirPath)) return { success: true } + + const files = await fsp.readdir(dirPath) + for (const file of files) { + const filePath = path.join(dirPath, file) + const stat = await fsp.stat(filePath) + if (stat.isDirectory()) { + await fsp.rm(filePath, { recursive: true }) + } else { + await fsp.unlink(filePath) + } + } + return { success: true } + }) + + server.get('/_web/cache/data-dir', async () => { + const pending = ctx.getPendingDataDirMigration?.() + return { + path: pp.getUserDataDir(), + defaultPath: ctx.defaultUserDataDir, + isCustom: ctx.isCustomDataDir ?? false, + canSetDataDir: ctx.canSetDataDir ?? Boolean(ctx.setDataDir), + hasLegacyDataAtDefaultDir: + Boolean(ctx.defaultUserDataDir) && + path.resolve(pp.getUserDataDir()) !== path.resolve(ctx.defaultUserDataDir ?? '') && + hasChatLabDatabases(ctx.defaultUserDataDir ?? ''), + pendingMigration: pending + ? { + from: pending.from, + to: pending.to, + createdAt: pending.createdAt, + } + : undefined, + } + }) + + server.post<{ Body: { path?: string | null; migrate?: boolean } }>('/_web/cache/data-dir', async (request, reply) => { + if (!ctx.setDataDir) { + return reply.code(501).send({ success: false, error: 'Data directory changes are not supported' }) + } + + const targetPath = typeof request.body?.path === 'string' ? request.body.path : null + const migrate = request.body?.migrate !== false + return ctx.setDataDir(targetPath, migrate) + }) + + server.get('/_web/cache/latest-import-log', async () => { + const importLogDir = path.join(pp.getLogsDir(), 'import') + if (!fs.existsSync(importLogDir)) { + return { success: false, error: 'Log directory not found' } + } + + const files = await fsp.readdir(importLogDir) + const logFiles = files.filter((f) => f.startsWith('import_') && f.endsWith('.log')) + if (logFiles.length === 0) { + return { success: false, error: 'No import logs found' } + } + + const fileStats = await Promise.all( + logFiles.map(async (f) => { + const filePath = path.join(importLogDir, f) + const stat = await fsp.stat(filePath) + return { name: f, path: filePath, mtime: stat.mtime.getTime() } + }) + ) + fileStats.sort((a, b) => b.mtime - a.mtime) + + return { success: true, path: fileStats[0].path, name: fileStats[0].name } + }) + + server.post<{ Body: { filename: string; dataUrl: string } }>('/_web/cache/save-to-downloads', async (request) => { + const { filename, dataUrl } = request.body + if (!filename || !dataUrl) { + return { success: false, error: 'filename and dataUrl are required' } + } + + let buffer: Buffer + if (dataUrl.includes(';base64,')) { + const base64Data = dataUrl.split(';base64,')[1] + buffer = Buffer.from(base64Data, 'base64') + } else if (dataUrl.includes('charset=utf-8,')) { + const textData = dataUrl.split('charset=utf-8,')[1] + buffer = Buffer.from(decodeURIComponent(textData), 'utf-8') + } else { + const base64Data = dataUrl.replace(/^data:[^,]+,/, '') + buffer = Buffer.from(base64Data, 'base64') + } + + if (!fs.existsSync(downloadsDir)) { + fs.mkdirSync(downloadsDir, { recursive: true }) + } + + const filePath = path.join(downloadsDir, filename) + fs.writeFileSync(filePath, buffer) + + return { success: true, filePath } + }) + + // Shell operations — require platform-specific callbacks + + server.post<{ Body: { cacheId: string } }>('/_web/cache/open-dir', async (request, reply) => { + if (!ctx.openDirectory) return reply.code(501).send({ success: false, error: 'Not supported' }) + + const { cacheId } = request.body + const dirPaths: Record = { + base: pp.getUserDataDir(), + databases: pp.getDatabaseDir(), + cache: pp.getCacheDir(), + ai: pp.getAiDataDir(), + logs: pp.getLogsDir(), + downloads: downloadsDir, + } + const dirPath = dirPaths[cacheId] + if (!dirPath) return { success: false, error: 'Unknown directory' } + + if (!fs.existsSync(dirPath)) { + await fsp.mkdir(dirPath, { recursive: true }) + } + + try { + await ctx.openDirectory(dirPath) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + return { success: true } + }) + + server.post<{ Body: { filePath: string } }>('/_web/cache/show-in-folder', async (request, reply) => { + if (!ctx.showInFolder) return reply.code(501).send({ success: false, error: 'Not supported' }) + + const { filePath } = request.body + if (!filePath) return { success: false, error: 'filePath is required' } + + try { + await ctx.showInFolder(filePath) + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + return { success: true } + }) +} diff --git a/packages/http-routes/src/routes/web/contacts.test.ts b/packages/http-routes/src/routes/web/contacts.test.ts new file mode 100644 index 000000000..61d75cc78 --- /dev/null +++ b/packages/http-routes/src/routes/web/contacts.test.ts @@ -0,0 +1,327 @@ +/** + * Contract tests for shared contacts routes. + * + * Run: pnpm test -- packages/http-routes/src/routes/web/contacts.test.ts + */ + +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' +import Fastify from 'fastify' +import type { PathProvider } from '@openchatlab/core' +import type { ContactsResponse } from '@openchatlab/shared-types' +import type { ContactsService, DatabaseManager, SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' +import { registerContactsRoutes } from './contacts' + +function emptyContactsResponse(status: ContactsResponse['cache']['status'] = 'missing'): ContactsResponse { + return { + contacts: [], + diagnostics: { + privateSessionCount: 0, + activePrivateSessionCount: 0, + contactsEnabled: false, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedInvalidPlatformIdMembers: 0, + skippedFailedSessions: 0, + warnings: [], + }, + cache: { + status, + computedAt: null, + }, + timeRange: { + preset: '1y', + anchorTs: null, + startTs: null, + }, + algorithmVersion: 'contacts-v1', + pagination: { + page: 1, + pageSize: 100, + total: 0, + hasMore: false, + }, + stats: { + friendsTotal: 0, + nonFriendsTotal: 0, + }, + task: { + id: 'task-1', + status: 'running', + startedAt: 1000, + finishedAt: null, + processedSessions: 0, + totalSessions: 1, + timeRangePreset: '1y', + }, + } +} + +class FakeContactsService implements ContactsService { + getCalls: Array<{ acceptStale?: boolean; timeRangePreset?: string }> = [] + recomputeCalls: Array<{ timeRangePreset?: string }> = [] + pageCalls: Array<{ + acceptStale?: boolean + timeRangePreset?: string + pool?: string + page?: number + pageSize?: number + query?: string + }> = [] + detailCalls: Array<{ key: string; acceptStale?: boolean; timeRangePreset?: string }> = [] + markFriendCalls: Array<{ key: string; timeRangePreset?: string }> = [] + unmarkFriendCalls: Array<{ key: string; timeRangePreset?: string }> = [] + closeCalls = 0 + + getContacts(options?: { acceptStale?: boolean; timeRangePreset?: string }): ContactsResponse { + this.getCalls.push({ acceptStale: options?.acceptStale, timeRangePreset: options?.timeRangePreset }) + return emptyContactsResponse('missing') + } + + getContactsPage(options?: { + acceptStale?: boolean + timeRangePreset?: string + pool?: string + page?: number + pageSize?: number + query?: string + }): ContactsResponse { + this.pageCalls.push({ + acceptStale: options?.acceptStale, + timeRangePreset: options?.timeRangePreset, + pool: options?.pool, + page: options?.page, + pageSize: options?.pageSize, + query: options?.query, + }) + return { + ...emptyContactsResponse('missing'), + pagination: { page: options?.page ?? 1, pageSize: options?.pageSize ?? 100, total: 0, hasMore: false }, + stats: { friendsTotal: 0, nonFriendsTotal: 0 }, + } as any + } + + getContactDetail(key: string, options?: { acceptStale?: boolean; timeRangePreset?: string }) { + this.detailCalls.push({ key, acceptStale: options?.acceptStale, timeRangePreset: options?.timeRangePreset }) + return { + contact: null, + cache: emptyContactsResponse('missing').cache, + timeRange: emptyContactsResponse('missing').timeRange, + algorithmVersion: 'contacts-v1', + task: emptyContactsResponse('missing').task, + } + } + + startRecompute(options?: { timeRangePreset?: string }): ContactsResponse { + this.recomputeCalls.push({ timeRangePreset: options?.timeRangePreset }) + return emptyContactsResponse('stale') + } + + markContactAsFriend(key: string, options?: { timeRangePreset?: string }): { success: boolean } { + this.markFriendCalls.push({ key, timeRangePreset: options?.timeRangePreset }) + return { success: true } + } + + unmarkContactAsFriend(key: string, options?: { timeRangePreset?: string }): { success: boolean } { + this.unmarkFriendCalls.push({ key, timeRangePreset: options?.timeRangePreset }) + return { success: true } + } + + invalidateContactsCache(): void { + throw new Error('not used in route contract tests') + } + + async close(): Promise { + this.closeCalls++ + } +} + +function createMockContext(contactsService: ContactsService): HttpRouteContext { + const pathProvider: PathProvider = { + getSystemDir: () => path.join('/tmp', 'chatlab-contacts-route-test'), + getUserDataDir: () => path.join('/tmp', 'chatlab-contacts-route-test', 'data'), + getDatabaseDir: () => path.join('/tmp', 'chatlab-contacts-route-test', 'data', 'databases'), + getVectorDir: () => path.join('/tmp', 'chatlab-contacts-route-test', 'vector'), + getAiDataDir: () => path.join('/tmp', 'chatlab-contacts-route-test', 'ai'), + getSettingsDir: () => path.join('/tmp', 'chatlab-contacts-route-test', 'settings'), + getCacheDir: () => path.join('/tmp', 'chatlab-contacts-route-test', 'cache'), + getTempDir: () => path.join('/tmp', 'chatlab-contacts-route-test', 'temp'), + getLogsDir: () => path.join('/tmp', 'chatlab-contacts-route-test', 'logs'), + getDownloadsDir: () => path.join('/tmp', 'chatlab-contacts-route-test', 'downloads'), + } + const sessionAdapter = { + listSessionIds: () => [], + } as unknown as SessionRuntimeAdapter + + return { + sessionAdapter, + pathProvider, + contactsService, + dbManager: {} as DatabaseManager, + getVersion: () => 'test', + } as HttpRouteContext +} + +test('GET /_web/contacts returns contacts response with task state', async (t) => { + const service = new FakeContactsService() + const app = Fastify() + t.after(async () => app.close()) + registerContactsRoutes(app, createMockContext(service)) + await app.ready() + + const response = await app.inject({ method: 'GET', url: '/_web/contacts?acceptStale=1' }) + + assert.equal(response.statusCode, 200) + const body = response.json() + assert.equal(body.cache.status, 'missing') + assert.equal(body.task?.status, 'running') + assert.deepEqual(service.pageCalls, [ + { + acceptStale: true, + timeRangePreset: '1y', + pool: undefined, + page: undefined, + pageSize: undefined, + query: undefined, + }, + ]) +}) + +test('GET /_web/contacts forwards explicit time range preset', async (t) => { + const service = new FakeContactsService() + const app = Fastify() + t.after(async () => app.close()) + registerContactsRoutes(app, createMockContext(service)) + await app.ready() + + const response = await app.inject({ method: 'GET', url: '/_web/contacts?acceptStale=1&timeRange=2y' }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(service.pageCalls, [ + { + acceptStale: true, + timeRangePreset: '2y', + pool: undefined, + page: undefined, + pageSize: undefined, + query: undefined, + }, + ]) +}) + +test('GET /_web/contacts forwards pagination, pool, search, and time range query', async (t) => { + const service = new FakeContactsService() + const app = Fastify() + t.after(async () => app.close()) + registerContactsRoutes(app, createMockContext(service)) + await app.ready() + + const response = await app.inject({ + method: 'GET', + url: '/_web/contacts?acceptStale=1&timeRange=2y&pool=non_friend&page=2&pageSize=50&q=Alice', + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(service.pageCalls, [ + { + acceptStale: true, + timeRangePreset: '2y', + pool: 'non_friend', + page: 2, + pageSize: 50, + query: 'Alice', + }, + ]) + assert.deepEqual(service.getCalls, []) +}) + +test('GET /_web/contacts/:key/detail forwards decoded contact key and stale preference', async (t) => { + const service = new FakeContactsService() + const app = Fastify() + t.after(async () => app.close()) + registerContactsRoutes(app, createMockContext(service)) + await app.ready() + + const response = await app.inject({ + method: 'GET', + url: `/_web/contacts/${encodeURIComponent('weixin:alice')}/detail?acceptStale=1&timeRange=2y`, + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(service.detailCalls, [{ key: 'weixin:alice', acceptStale: true, timeRangePreset: '2y' }]) +}) + +test('POST /_web/contacts/recompute starts or reuses background recompute without waiting for completion', async (t) => { + const service = new FakeContactsService() + const app = Fastify() + t.after(async () => app.close()) + registerContactsRoutes(app, createMockContext(service)) + await app.ready() + + const response = await app.inject({ method: 'POST', url: '/_web/contacts/recompute' }) + + assert.equal(response.statusCode, 200) + const body = response.json() + assert.equal(body.cache.status, 'stale') + assert.equal(body.task?.status, 'running') + assert.deepEqual(service.recomputeCalls, [{ timeRangePreset: '1y' }]) +}) + +test('override routes are not registered', async (t) => { + const app = Fastify() + t.after(async () => app.close()) + registerContactsRoutes(app, createMockContext(new FakeContactsService())) + await app.ready() + + const patched = await app.inject({ + method: 'PATCH', + url: '/_web/contacts/weixin:alice/override', + payload: { isPinned: true }, + }) + assert.equal(patched.statusCode, 404) + + const deleted = await app.inject({ + method: 'DELETE', + url: '/_web/contacts/weixin:alice/override', + }) + assert.equal(deleted.statusCode, 404) +}) + +test('PUT and DELETE /_web/contacts/:key/mark-friend forward decoded key and time range', async (t) => { + const service = new FakeContactsService() + const app = Fastify() + t.after(async () => app.close()) + registerContactsRoutes(app, createMockContext(service)) + await app.ready() + + const putResponse = await app.inject({ + method: 'PUT', + url: `/_web/contacts/${encodeURIComponent('weixin:alice')}/mark-friend?timeRange=2y`, + }) + assert.equal(putResponse.statusCode, 200) + assert.deepEqual(putResponse.json(), { success: true }) + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/_web/contacts/${encodeURIComponent('weixin:alice')}/mark-friend?timeRange=2y`, + }) + assert.equal(deleteResponse.statusCode, 200) + assert.deepEqual(deleteResponse.json(), { success: true }) + + assert.deepEqual(service.markFriendCalls, [{ key: 'weixin:alice', timeRangePreset: '2y' }]) + assert.deepEqual(service.unmarkFriendCalls, [{ key: 'weixin:alice', timeRangePreset: '2y' }]) +}) + +test('closes contacts service when Fastify app closes', async () => { + const service = new FakeContactsService() + const app = Fastify() + registerContactsRoutes(app, createMockContext(service)) + await app.ready() + + await app.close() + + assert.equal(service.closeCalls, 1) +}) diff --git a/packages/http-routes/src/routes/web/contacts.ts b/packages/http-routes/src/routes/web/contacts.ts new file mode 100644 index 000000000..b4c37bba9 --- /dev/null +++ b/packages/http-routes/src/routes/web/contacts.ts @@ -0,0 +1,94 @@ +import type { FastifyInstance } from 'fastify' +import { createContactsService } from '@openchatlab/node-runtime' +import { CONTACTS_TIME_RANGE_PRESETS, type ContactPool, type ContactsTimeRangePreset } from '@openchatlab/shared-types' +import type { HttpRouteContext } from '../../context' + +type ContactsQuery = { + acceptStale?: string + timeRange?: string + pool?: string + page?: string + pageSize?: string + q?: string +} + +export function registerContactsRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const service = + ctx.contactsService ?? + createContactsService({ + adapter: ctx.sessionAdapter, + pathProvider: ctx.pathProvider, + runtimeIdentity: ctx.runtimeIdentity, + nativeBinding: ctx.nativeBinding, + }) + server.addHook('onClose', async () => { + await service.close() + }) + + server.get<{ Querystring: ContactsQuery }>('/_web/contacts', async (request) => { + return service.getContactsPage({ + acceptStale: isTruthy(request.query.acceptStale), + timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange), + pool: parseContactPool(request.query.pool), + page: parsePositiveInteger(request.query.page), + pageSize: parsePositiveInteger(request.query.pageSize), + query: request.query.q, + }) + }) + + server.post<{ Querystring: ContactsQuery }>('/_web/contacts/recompute', async (request) => { + return service.startRecompute({ + timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange), + pool: parseContactPool(request.query.pool), + page: parsePositiveInteger(request.query.page), + pageSize: parsePositiveInteger(request.query.pageSize), + query: request.query.q, + }) + }) + + server.get<{ Params: { key: string }; Querystring: ContactsQuery }>('/_web/contacts/:key/detail', async (request) => { + return service.getContactDetail(request.params.key, { + acceptStale: isTruthy(request.query.acceptStale), + timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange), + }) + }) + + server.put<{ Params: { key: string }; Querystring: ContactsQuery }>( + '/_web/contacts/:key/mark-friend', + async (request) => { + return service.markContactAsFriend(request.params.key, { + timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange), + }) + } + ) + + server.delete<{ Params: { key: string }; Querystring: ContactsQuery }>( + '/_web/contacts/:key/mark-friend', + async (request) => { + return service.unmarkContactAsFriend(request.params.key, { + timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange), + }) + } + ) +} + +function isTruthy(value: string | undefined): boolean { + return value === '1' || value === 'true' || value === 'yes' +} + +function parseContactsTimeRangePreset(value: string | undefined): ContactsTimeRangePreset { + return CONTACTS_TIME_RANGE_PRESETS.includes(value as ContactsTimeRangePreset) + ? (value as ContactsTimeRangePreset) + : '1y' +} + +function parseContactPool(value: string | undefined): ContactPool | undefined { + return value === 'friend' || value === 'non_friend' ? value : undefined +} + +function parsePositiveInteger(value: string | undefined): number | undefined { + if (!value) return undefined + const parsed = Number(value) + if (!Number.isFinite(parsed)) return undefined + return Math.max(1, Math.trunc(parsed)) +} diff --git a/packages/http-routes/src/routes/web/export.ts b/packages/http-routes/src/routes/web/export.ts new file mode 100644 index 000000000..5df39255c --- /dev/null +++ b/packages/http-routes/src/routes/web/export.ts @@ -0,0 +1,37 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { exportService } from '@openchatlab/node-runtime' +import type { ExportFormat } from '@openchatlab/node-runtime' + +export function registerExportRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx + + server.post<{ + Params: { id: string } + Body: { + sessionName: string + format?: ExportFormat + timeFilter?: { startTs: number; endTs: number } + } + }>('/_web/sessions/:id/export', async (request, reply) => { + const { id } = request.params + const body = request.body as any + const sessionName = body?.sessionName || id + const format: ExportFormat = body?.format || 'txt' + + const result = exportService.exportFormatted(adapter, { + sessionId: id, + sessionName, + format, + timeFilter: body.timeFilter, + }) + + if (!result.success) { + return reply.code(result.totalMessages === 0 ? 404 : 500).send({ error: result.error }) + } + + reply.header('Content-Type', result.mimeType) + reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(result.filename)}"`) + return reply.send(result.content) + }) +} diff --git a/packages/http-routes/src/routes/web/logs.test.ts b/packages/http-routes/src/routes/web/logs.test.ts new file mode 100644 index 000000000..0e5a3e933 --- /dev/null +++ b/packages/http-routes/src/routes/web/logs.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import Fastify from 'fastify' +import { initAppLogger } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' +import { registerLogRoutes } from './logs' + +function ctxWith(logsDir: string): HttpRouteContext { + return { pathProvider: { getLogsDir: () => logsDir } } as unknown as HttpRouteContext +} + +describe('logs routes', () => { + it('appends front-end error report to app.log', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'logsroute-')) + initAppLogger(dir) + const app = Fastify() + registerLogRoutes(app, ctxWith(dir)) + await app.ready() + try { + const res = await app.inject({ + method: 'POST', + url: '/_web/logs/report', + payload: { level: 'error', message: 'boom', stack: 'at x', url: 'http://app/page' }, + }) + assert.equal(res.statusCode, 200) + const content = fs.readFileSync(path.join(dir, 'app.log'), 'utf-8') + assert.match(content, /\[ERROR\] \[web\] boom/) + assert.match(content, /url=http:\/\/app\/page/) + } finally { + await app.close() + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects report without message', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'logsroute-')) + initAppLogger(dir) + const app = Fastify() + registerLogRoutes(app, ctxWith(dir)) + await app.ready() + try { + const res = await app.inject({ method: 'POST', url: '/_web/logs/report', payload: {} }) + assert.equal(res.statusCode, 400) + } finally { + await app.close() + fs.rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/http-routes/src/routes/web/logs.ts b/packages/http-routes/src/routes/web/logs.ts new file mode 100644 index 000000000..7662a577c --- /dev/null +++ b/packages/http-routes/src/routes/web/logs.ts @@ -0,0 +1,26 @@ +import type { FastifyInstance } from 'fastify' +import { appLogger } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' + +interface LogReportBody { + level?: 'error' | 'warn' + message: string + stack?: string + url?: string +} + +/** + * Front-end error report sink. The browser can't write files, so uncaught + * front-end errors are POSTed here and appended to logs/app.log (scope 'web'). + */ +export function registerLogRoutes(server: FastifyInstance, _ctx: HttpRouteContext): void { + server.post<{ Body: LogReportBody }>('/_web/logs/report', async (req, reply) => { + const { level, message, stack, url } = req.body ?? ({} as LogReportBody) + if (!message) return reply.code(400).send({ error: 'message required' }) + + const detail = [url ? `url=${url}` : null, stack ? `\n${stack}` : null].filter(Boolean).join(' ') + if (level === 'warn') appLogger.warn('web', message, detail || undefined) + else appLogger.error('web', message, detail || undefined) + return { ok: true } + }) +} diff --git a/packages/http-routes/src/routes/web/members.ts b/packages/http-routes/src/routes/web/members.ts new file mode 100644 index 000000000..1e06554de --- /dev/null +++ b/packages/http-routes/src/routes/web/members.ts @@ -0,0 +1,58 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { memberService } from '@openchatlab/node-runtime' + +export function registerMemberRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx + + server.get<{ Params: { id: string } }>('/_web/sessions/:id/members', async (request) => { + return memberService.getMembers(adapter, request.params.id) + }) + + server.get<{ + Params: { id: string } + Querystring: { page?: string; pageSize?: string; search?: string; sortOrder?: string } + }>('/_web/sessions/:id/members/paginated', async (request) => { + return memberService.getMembersPaginated(adapter, request.params.id, { + page: parseInt(request.query.page || '1', 10), + pageSize: parseInt(request.query.pageSize || '20', 10), + search: request.query.search, + sortOrder: request.query.sortOrder === 'asc' ? 'asc' : 'desc', + }) + }) + + server.patch<{ Params: { id: string; memberId: string }; Body: { aliases: string[] } }>( + '/_web/sessions/:id/members/:memberId/aliases', + async (request) => { + const memberId = parseInt(request.params.memberId, 10) + memberService.updateMemberAliases(adapter, request.params.id, memberId, request.body.aliases) + return { success: true } + } + ) + + server.delete<{ Params: { id: string; memberId: string } }>( + '/_web/sessions/:id/members/:memberId', + async (request) => { + const memberId = parseInt(request.params.memberId, 10) + memberService.deleteMember(adapter, request.params.id, memberId) + return { success: true } + } + ) + + server.post<{ Params: { id: string }; Body: { memberId1: number; memberId2: number } }>( + '/_web/sessions/:id/members/merge', + async (request) => { + const { memberId1, memberId2 } = request.body + memberService.mergeMembers(adapter, request.params.id, memberId1, memberId2) + return { success: true } + } + ) + + server.get<{ Params: { id: string; memberId: string } }>( + '/_web/sessions/:id/members/:memberId/history', + async (request) => { + const memberId = parseInt(request.params.memberId, 10) + return memberService.getMemberNameHistory(adapter, request.params.id, memberId) + } + ) +} diff --git a/packages/http-routes/src/routes/web/merge.ts b/packages/http-routes/src/routes/web/merge.ts new file mode 100644 index 000000000..b9b5bedf1 --- /dev/null +++ b/packages/http-routes/src/routes/web/merge.ts @@ -0,0 +1,261 @@ +/** + * Merge routes — shared across CLI, Electron, and web-serve. + * + * Parse supports dual-mode: + * - multipart/form-data → file upload (web/CLI) + * - application/json { filePath } → local disk path (Electron) + */ + +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { + streamParseFileInfo, + checkConflictsFromSources, + buildMergedOutput, + serializeChatLabToJsonl, + exportSessionToJson, + TempDbReader, + TempDbWriter, +} from '@openchatlab/node-runtime' +import type { MergerDataSource } from '@openchatlab/node-runtime' +import { sessionNotFound } from '../../errors' + +function ensureDb(ctx: HttpRouteContext, sessionId: string) { + const db = ctx.dbManager.open(sessionId) + if (!db) throw sessionNotFound(sessionId) + return db +} + +export function registerMergeRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { dbManager, mergeSessionCache: mergeCache, streamImport } = ctx + if (!mergeCache) return + + // ── parse (dual-mode) ────────────────────────────────────────────── + + server.post('/_web/merge/parse', async (request, reply) => { + const contentType = request.headers['content-type'] || '' + let filePath: string + let cleanupFile = false + let cleanupDir: string | undefined + + if (contentType.includes('multipart/form-data')) { + const data = await (request as any).file() + if (!data) return reply.code(400).send({ error: 'No file uploaded' }) + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-merge-')) + const tmpPath = path.join(tmpDir, data.filename || 'upload') + const chunks: Buffer[] = [] + for await (const chunk of data.file) { + chunks.push(chunk) + } + fs.writeFileSync(tmpPath, Buffer.concat(chunks)) + filePath = tmpPath + cleanupFile = true + cleanupDir = tmpDir + } else { + const body = request.body as { filePath?: string } + if (!body?.filePath) return reply.code(400).send({ error: 'Missing filePath in body' }) + if (!fs.existsSync(body.filePath)) return reply.code(400).send({ error: 'File not found' }) + filePath = body.filePath + } + + try { + const result = await streamParseFileInfo(filePath, { + createTempDatabase(sourceFilePath: string) { + return mergeCache.createTempDatabase(path.basename(sourceFilePath)) + }, + onProgress() { + /* HTTP parse has no progress stream */ + }, + }) + + const filename = path.basename(filePath) + const handle = mergeCache.store(filename, result.tempDbPath) + + return { + handle, + name: result.name, + format: result.format, + platform: result.platform, + messageCount: result.messageCount, + memberCount: result.memberCount, + fileSize: result.fileSize, + } + } finally { + if (cleanupFile) { + try { + fs.unlinkSync(filePath) + } catch { + /* ignore */ + } + } + if (cleanupDir) { + try { + fs.rmdirSync(cleanupDir) + } catch { + /* ignore */ + } + } + } + }) + + // ── conflicts ────────────────────────────────────────────────────── + + server.post<{ Body: { handles: string[] } }>('/_web/merge/conflicts', async (request, reply) => { + const { handles } = request.body as { handles?: string[] } + if (!handles || !Array.isArray(handles) || handles.length === 0) { + return reply.code(400).send({ error: 'Missing or empty handles array' }) + } + + const readers: TempDbReader[] = [] + try { + const dataSources: Array<{ source: MergerDataSource; filename: string }> = [] + for (const handle of handles) { + const entry = mergeCache.openReader(handle) + if (!entry) return reply.code(404).send({ error: `Handle not found: ${handle}` }) + readers.push(entry.reader) + dataSources.push({ source: entry.reader.toDataSource(), filename: entry.filename }) + } + return checkConflictsFromSources(dataSources) + } finally { + for (const r of readers) r.close() + } + }) + + // ── execute ──────────────────────────────────────────────────────── + + server.post<{ + Body: { handles: string[]; outputName: string; format?: 'json' | 'jsonl'; andImport?: boolean } + }>('/_web/merge/execute', async (request, reply) => { + const { handles, outputName, format = 'json', andImport } = request.body as any + if (!handles || !Array.isArray(handles) || handles.length === 0) { + return reply.code(400).send({ error: 'Missing handles' }) + } + if (!outputName) { + return reply.code(400).send({ error: 'Missing outputName' }) + } + + const readers: TempDbReader[] = [] + try { + const dataSources: Array<{ source: MergerDataSource; filename: string }> = [] + for (const handle of handles) { + const entry = mergeCache.openReader(handle) + if (!entry) return reply.code(404).send({ error: `Handle not found: ${handle}` }) + readers.push(entry.reader) + dataSources.push({ source: entry.reader.toDataSource(), filename: entry.filename }) + } + + const merged = buildMergedOutput(dataSources, outputName) + + let sessionId: string | undefined + if (andImport) { + if (!streamImport) { + return reply.code(501).send({ error: 'Import not available on this server' }) + } + const jsonData = JSON.stringify(merged.chatLabData) + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-merged-')) + const safeName = path.basename(outputName).replace(/[/\\?%*:|"<>]/g, '_') || 'merged' + const tmpPath = path.join(tmpDir, `${safeName}.json`) + fs.writeFileSync(tmpPath, jsonData, 'utf-8') + + try { + const importResult = await streamImport(dbManager, tmpPath) + sessionId = importResult.sessionId + } finally { + try { + fs.unlinkSync(tmpPath) + } catch { + /* ignore */ + } + try { + fs.rmdirSync(tmpDir) + } catch { + /* ignore */ + } + } + } + + for (const handle of handles) { + mergeCache.delete(handle) + } + + if (format === 'jsonl') { + const lines: string[] = [] + for (const line of serializeChatLabToJsonl(merged.chatLabData)) { + lines.push(line) + } + return { success: true, sessionId, data: lines.join('\n') } + } + + return { success: true, sessionId, data: merged.chatLabData } + } finally { + for (const r of readers) r.close() + } + }) + + // ── clear ────────────────────────────────────────────────────────── + + server.post<{ Body: { handle?: string } }>('/_web/merge/clear', async (request) => { + const { handle } = (request.body as any) || {} + if (handle) { + mergeCache.delete(handle) + } else { + mergeCache.clear() + } + return { success: true } + }) + + // ── export sessions for merge ────────────────────────────────────── + + server.post<{ + Body: { sessionIds: string[] } + }>('/_web/sessions/export-for-merge', async (request, reply) => { + const { sessionIds } = request.body as { sessionIds?: string[] } + if (!sessionIds || !Array.isArray(sessionIds) || sessionIds.length === 0) { + return reply.code(400).send({ error: 'Missing sessionIds' }) + } + + const handles: Array<{ sessionId: string; handle: string }> = [] + for (const sid of sessionIds) { + const db = ensureDb(ctx, sid) + const exported = exportSessionToJson(db) + const { db: tempDb, tempDbPath } = mergeCache.createTempDatabase(exported.meta.name) + + const writer = new TempDbWriter(tempDb) + writer.writeMeta({ + name: exported.meta.name, + platform: exported.meta.platform, + type: exported.meta.type, + groupId: exported.meta.groupId, + groupAvatar: exported.meta.groupAvatar, + }) + writer.writeMembers( + exported.members.map((m) => ({ + platformId: m.platformId, + accountName: m.accountName, + groupNickname: m.groupNickname, + avatar: m.avatar, + })) + ) + writer.writeMessages( + exported.messages.map((msg) => ({ + senderPlatformId: msg.sender, + senderAccountName: msg.accountName, + senderGroupNickname: msg.groupNickname, + timestamp: msg.timestamp, + type: msg.type, + content: msg.content, + })) + ) + writer.finish() + + const handle = mergeCache.store(exported.meta.name, tempDbPath) + handles.push({ sessionId: sid, handle }) + } + + return { success: true, handles } + }) +} diff --git a/packages/http-routes/src/routes/web/nlp.ts b/packages/http-routes/src/routes/web/nlp.ts new file mode 100644 index 000000000..0be0b87f9 --- /dev/null +++ b/packages/http-routes/src/routes/web/nlp.ts @@ -0,0 +1,79 @@ +/** + * NLP Web API — /_web/nlp/ routes + * + * Word frequency, POS tags, dictionary management. + * Business logic from @openchatlab/core and @openchatlab/node-runtime. + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { withAnalyticsCache } from '../../analytics-cache' +import type { WordFrequencyParams, SupportedLocale } from '@openchatlab/core' +import { POS_TAG_DEFINITIONS } from '@openchatlab/core' +import { + initNlpDir, + computeWordFrequency, + segmentText, + getDictList, + isDictDownloaded, + downloadDict, + deleteDict, + ensureDefaultDict, +} from '@openchatlab/node-runtime' + +export function registerNlpRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const nlpDir = path.join(ctx.pathProvider.getSystemDir(), 'nlp') + initNlpDir(nlpDir) + ensureDefaultDict(nlpDir).catch((err) => console.warn('[NLP] Auto-download zh-CN dict failed:', err)) + + server.get('/_web/nlp/pos-tags', async () => { + return POS_TAG_DEFINITIONS + }) + + server.get('/_web/nlp/dicts', async () => { + return getDictList(nlpDir) + }) + + server.get<{ Params: { id: string } }>('/_web/nlp/dicts/:id/status', async (request) => { + return isDictDownloaded(nlpDir, request.params.id) + }) + + server.post<{ Params: { id: string } }>('/_web/nlp/dicts/:id/download', async (request) => { + return downloadDict(nlpDir, request.params.id) + }) + + server.delete<{ Params: { id: string } }>('/_web/nlp/dicts/:id', async (request) => { + return deleteDict(nlpDir, request.params.id) + }) + + server.post<{ Body: WordFrequencyParams }>('/_web/nlp/word-frequency', async (request) => { + const params = request.body + const db = ctx.dbManager.open(params.sessionId) + if (!db) { + throw Object.assign(new Error(`Session not found: ${params.sessionId}`), { statusCode: 404 }) + } + const { sessionId, ...keyParams } = params + const effectiveDictId = !params.dictType || params.dictType === 'default' ? 'zh-CN' : String(params.dictType) + const dictFilePath = path.join(nlpDir, `${effectiveDictId}.dict`) + let dictVersion: string + try { + const st = fs.statSync(dictFilePath) + dictVersion = `${Math.floor(st.mtimeMs)}:${st.size}` + } catch { + dictVersion = '-' + } + return withAnalyticsCache(ctx, sessionId, 'nlp.word-frequency', keyParams, () => computeWordFrequency(db, params), { + extraVersion: `dict:${dictVersion}`, + }) + }) + + server.post<{ Body: { text: string; locale: SupportedLocale; minLength?: number } }>( + '/_web/nlp/segment', + async (request) => { + const { text, locale, minLength } = request.body + return segmentText(text, locale, minLength) + } + ) +} diff --git a/packages/http-routes/src/routes/web/people-relationships.test.ts b/packages/http-routes/src/routes/web/people-relationships.test.ts new file mode 100644 index 000000000..e74f96834 --- /dev/null +++ b/packages/http-routes/src/routes/web/people-relationships.test.ts @@ -0,0 +1,228 @@ +/** + * Contract tests for shared People relationships routes. + * + * Run: pnpm test -- packages/http-routes/src/routes/web/people-relationships.test.ts + */ + +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' +import Fastify from 'fastify' +import type { PathProvider } from '@openchatlab/core' +import type { + PeopleRelationshipsGraphResponse, + PeopleRelationshipsNeighborhoodResponse, +} from '@openchatlab/shared-types' +import type { DatabaseManager, PeopleRelationshipsService, SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' +import { registerPeopleRelationshipsRoutes } from './people-relationships' + +function emptyGraphResponse( + status: PeopleRelationshipsGraphResponse['cache']['status'] = 'missing' +): PeopleRelationshipsGraphResponse { + return { + graph: { nodes: [], edges: [], communities: [] }, + searchResults: [], + diagnostics: { + processedPrivateSessions: 0, + processedGroupSessions: 0, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedFailedSessions: 0, + totalNodes: 0, + totalEdges: 0, + panoramaIncludedGroupSessions: 0, + panoramaExcludedLowValueGroupSessions: 0, + panoramaIncludedGroupMembers: 0, + panoramaExcludedGroupMembers: 0, + panoramaCandidateNodes: 0, + panoramaGroupInclusionReasons: {}, + coreNodeCount: 0, + coreEdgeCount: 0, + warnings: [], + }, + cache: { status, computedAt: null }, + timeRange: { preset: '1y', anchorTs: null, startTs: null }, + algorithmVersion: 'people-relationships-v1', + task: { + id: 'task-1', + status: 'running', + startedAt: 1000, + finishedAt: null, + processedSessions: 0, + totalSessions: 1, + timeRangePreset: '1y', + }, + } +} + +function emptyNeighborhoodResponse( + status: PeopleRelationshipsGraphResponse['cache']['status'] = 'missing' +): PeopleRelationshipsNeighborhoodResponse { + return { + contact: null, + graph: { nodes: [], edges: [], communities: [] }, + diagnostics: emptyGraphResponse(status).diagnostics, + cache: { status, computedAt: null }, + timeRange: { preset: '1y', anchorTs: null, startTs: null }, + algorithmVersion: 'people-relationships-v1', + task: emptyGraphResponse(status).task, + } +} + +class FakePeopleRelationshipsService implements PeopleRelationshipsService { + graphCalls: Array<{ acceptStale?: boolean; timeRangePreset?: string; query?: string; graphScope?: string }> = [] + recomputeCalls: Array<{ timeRangePreset?: string; query?: string; graphScope?: string }> = [] + neighborhoodCalls: Array<{ key: string; acceptStale?: boolean; timeRangePreset?: string }> = [] + closeCalls = 0 + + getGraph(options?: { + acceptStale?: boolean + timeRangePreset?: string + query?: string + graphScope?: string + }): PeopleRelationshipsGraphResponse { + this.graphCalls.push({ + acceptStale: options?.acceptStale, + timeRangePreset: options?.timeRangePreset, + query: options?.query, + graphScope: options?.graphScope, + }) + return emptyGraphResponse('missing') + } + + getNeighborhood( + key: string, + options?: { acceptStale?: boolean; timeRangePreset?: string } + ): PeopleRelationshipsNeighborhoodResponse { + this.neighborhoodCalls.push({ key, acceptStale: options?.acceptStale, timeRangePreset: options?.timeRangePreset }) + return emptyNeighborhoodResponse('missing') + } + + startRecompute(options?: { + timeRangePreset?: string + query?: string + graphScope?: string + }): PeopleRelationshipsGraphResponse { + this.recomputeCalls.push({ + timeRangePreset: options?.timeRangePreset, + query: options?.query, + graphScope: options?.graphScope, + }) + return emptyGraphResponse('stale') + } + + invalidateRelationshipsCache(): void { + throw new Error('not used in route contract tests') + } + + async close(): Promise { + this.closeCalls++ + } +} + +function createMockContext(relationshipsService: PeopleRelationshipsService): HttpRouteContext { + const pathProvider: PathProvider = { + getSystemDir: () => path.join('/tmp', 'chatlab-relationships-route-test'), + getUserDataDir: () => path.join('/tmp', 'chatlab-relationships-route-test', 'data'), + getDatabaseDir: () => path.join('/tmp', 'chatlab-relationships-route-test', 'data', 'databases'), + getVectorDir: () => path.join('/tmp', 'chatlab-relationships-route-test', 'vector'), + getAiDataDir: () => path.join('/tmp', 'chatlab-relationships-route-test', 'ai'), + getSettingsDir: () => path.join('/tmp', 'chatlab-relationships-route-test', 'settings'), + getCacheDir: () => path.join('/tmp', 'chatlab-relationships-route-test', 'cache'), + getTempDir: () => path.join('/tmp', 'chatlab-relationships-route-test', 'temp'), + getLogsDir: () => path.join('/tmp', 'chatlab-relationships-route-test', 'logs'), + getDownloadsDir: () => path.join('/tmp', 'chatlab-relationships-route-test', 'downloads'), + } + const sessionAdapter = { + listSessionIds: () => [], + } as unknown as SessionRuntimeAdapter + + return { + sessionAdapter, + pathProvider, + peopleRelationshipsService: relationshipsService, + dbManager: {} as DatabaseManager, + getVersion: () => 'test', + } as HttpRouteContext +} + +test('GET /_web/people/relationships forwards stale, time range, search query, and graph scope', async (t) => { + const service = new FakePeopleRelationshipsService() + const app = Fastify() + t.after(async () => app.close()) + registerPeopleRelationshipsRoutes(app, createMockContext(service)) + await app.ready() + + const response = await app.inject({ + method: 'GET', + url: '/_web/people/relationships?acceptStale=1&timeRange=2y&q=Alice&scope=close', + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(service.graphCalls, [ + { acceptStale: true, timeRangePreset: '2y', query: 'Alice', graphScope: 'close' }, + ]) +}) + +test('GET /_web/people/relationships forwards friends graph scope', async (t) => { + const service = new FakePeopleRelationshipsService() + const app = Fastify() + t.after(async () => app.close()) + registerPeopleRelationshipsRoutes(app, createMockContext(service)) + await app.ready() + + const response = await app.inject({ + method: 'GET', + url: '/_web/people/relationships?scope=friends', + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(service.graphCalls, [ + { acceptStale: false, timeRangePreset: '1y', query: undefined, graphScope: 'friends' }, + ]) +}) + +test('POST /_web/people/relationships/recompute forwards time range, search query, and graph scope', async (t) => { + const service = new FakePeopleRelationshipsService() + const app = Fastify() + t.after(async () => app.close()) + registerPeopleRelationshipsRoutes(app, createMockContext(service)) + await app.ready() + + const response = await app.inject({ + method: 'POST', + url: '/_web/people/relationships/recompute?timeRange=3y&q=Bob&scope=close', + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(service.recomputeCalls, [{ timeRangePreset: '3y', query: 'Bob', graphScope: 'close' }]) +}) + +test('GET /_web/people/relationships/:key/neighborhood forwards decoded contact key', async (t) => { + const service = new FakePeopleRelationshipsService() + const app = Fastify() + t.after(async () => app.close()) + registerPeopleRelationshipsRoutes(app, createMockContext(service)) + await app.ready() + + const response = await app.inject({ + method: 'GET', + url: `/_web/people/relationships/${encodeURIComponent('weixin:alice')}/neighborhood?acceptStale=1&timeRange=5y`, + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(service.neighborhoodCalls, [{ key: 'weixin:alice', acceptStale: true, timeRangePreset: '5y' }]) +}) + +test('closes people relationships service when Fastify app closes', async () => { + const service = new FakePeopleRelationshipsService() + const app = Fastify() + registerPeopleRelationshipsRoutes(app, createMockContext(service)) + await app.ready() + + await app.close() + + assert.equal(service.closeCalls, 1) +}) diff --git a/packages/http-routes/src/routes/web/people-relationships.ts b/packages/http-routes/src/routes/web/people-relationships.ts new file mode 100644 index 000000000..22f1e2374 --- /dev/null +++ b/packages/http-routes/src/routes/web/people-relationships.ts @@ -0,0 +1,70 @@ +import type { FastifyInstance } from 'fastify' +import { + CONTACTS_TIME_RANGE_PRESETS, + type ContactsTimeRangePreset, + type PeopleRelationshipsGraphScope, +} from '@openchatlab/shared-types' +import { createPeopleRelationshipsService } from '@openchatlab/node-runtime' +import type { HttpRouteContext } from '../../context' + +type PeopleRelationshipsQuery = { + acceptStale?: string + timeRange?: string + scope?: string + q?: string +} + +export function registerPeopleRelationshipsRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const service = + ctx.peopleRelationshipsService ?? + createPeopleRelationshipsService({ + adapter: ctx.sessionAdapter, + pathProvider: ctx.pathProvider, + runtimeIdentity: ctx.runtimeIdentity, + nativeBinding: ctx.nativeBinding, + }) + server.addHook('onClose', async () => { + await service.close() + }) + + server.get<{ Querystring: PeopleRelationshipsQuery }>('/_web/people/relationships', async (request) => { + return service.getGraph({ + acceptStale: isTruthy(request.query.acceptStale), + timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange), + graphScope: parsePeopleRelationshipsGraphScope(request.query.scope), + query: request.query.q, + }) + }) + + server.post<{ Querystring: PeopleRelationshipsQuery }>('/_web/people/relationships/recompute', async (request) => { + return service.startRecompute({ + timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange), + graphScope: parsePeopleRelationshipsGraphScope(request.query.scope), + query: request.query.q, + }) + }) + + server.get<{ Params: { key: string }; Querystring: PeopleRelationshipsQuery }>( + '/_web/people/relationships/:key/neighborhood', + async (request) => { + return service.getNeighborhood(request.params.key, { + acceptStale: isTruthy(request.query.acceptStale), + timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange), + }) + } + ) +} + +function isTruthy(value: string | undefined): boolean { + return value === '1' || value === 'true' || value === 'yes' +} + +function parseContactsTimeRangePreset(value: string | undefined): ContactsTimeRangePreset { + return CONTACTS_TIME_RANGE_PRESETS.includes(value as ContactsTimeRangePreset) + ? (value as ContactsTimeRangePreset) + : '1y' +} + +function parsePeopleRelationshipsGraphScope(value: string | undefined): PeopleRelationshipsGraphScope { + return value === 'close' || value === 'friends' ? value : 'panorama' +} diff --git a/packages/http-routes/src/routes/web/preferences.ts b/packages/http-routes/src/routes/web/preferences.ts new file mode 100644 index 000000000..f1dc4a952 --- /dev/null +++ b/packages/http-routes/src/routes/web/preferences.ts @@ -0,0 +1,54 @@ +/** + * Preferences HTTP routes — /_web/preferences/* + * + * Read/write preferences.json and config.toml [ui] section for Web UI. + */ + +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { PreferencesManager, type Preferences } from '@openchatlab/node-runtime' +import { loadConfig, writeConfigField, type UiConfig } from '@openchatlab/config' + +export function registerPreferencesRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const prefManager = ctx.preferencesManager ?? new PreferencesManager(ctx.pathProvider.getSystemDir()) + + server.get('/_web/preferences', async () => { + return prefManager.load() + }) + + server.patch<{ Body: Partial }>('/_web/preferences', async (request) => { + return prefManager.save(request.body) + }) + + server.get('/_web/preferences/ui-config', async () => { + const config = loadConfig() + return config.ui + }) + + server.patch<{ Body: Partial }>('/_web/preferences/ui-config', async (request) => { + try { + for (const [key, value] of Object.entries(request.body)) { + if (value !== undefined) { + writeConfigField('ui', key, value as string | number) + } + } + return { success: true } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } + } + }) + + server.get('/_web/preferences/locale', async () => { + const config = loadConfig() + return { lang: config.locale.lang } + }) + + server.patch<{ Body: { lang: string } }>('/_web/preferences/locale', async (request) => { + try { + writeConfigField('locale', 'lang', request.body.lang) + return { success: true } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } + } + }) +} diff --git a/packages/http-routes/src/routes/web/session-index.ts b/packages/http-routes/src/routes/web/session-index.ts new file mode 100644 index 000000000..baed849dc --- /dev/null +++ b/packages/http-routes/src/routes/web/session-index.ts @@ -0,0 +1,57 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { sessionIndexService } from '@openchatlab/node-runtime' + +export function registerSessionIndexRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx + + server.get('/_web/sessions/index-stats', async () => { + return sessionIndexService.getAllIndexStats(adapter) + }) + + server.post<{ + Params: { id: string } + Body: { gapThreshold?: number } + }>('/_web/sessions/:id/generate-index', async (request) => { + const gapThreshold = (request.body as any)?.gapThreshold ?? 1800 + const sessionCount = sessionIndexService.generateIndex(adapter, request.params.id, gapThreshold) + return { sessionCount } + }) + + server.post<{ + Params: { id: string } + Body: { gapThreshold?: number } + }>('/_web/sessions/:id/generate-incremental-index', async (request) => { + const gapThreshold = (request.body as any)?.gapThreshold ?? 1800 + const sessionCount = sessionIndexService.generateIncrementalIndex(adapter, request.params.id, gapThreshold) + return { sessionCount } + }) + + server.post<{ Params: { id: string } }>('/_web/sessions/:id/clear-index', async (request) => { + sessionIndexService.clearIndex(adapter, request.params.id) + return { success: true } + }) + + server.get<{ + Params: { id: string } + Querystring: { keywords: string; limit?: string; offset?: string } + }>('/_web/sessions/:id/search/fts', async (request, reply) => { + if (!sessionIndexService.getFtsStatus(adapter, request.params.id)) { + return reply.code(400).send({ error: 'FTS index not built for this session' }) + } + const keywords = request.query.keywords.split(/\s+/).filter(Boolean) + if (keywords.length === 0) return { rowids: [], total: 0 } + const limit = parseInt(request.query.limit || '100', 10) + const offset = parseInt(request.query.offset || '0', 10) + return sessionIndexService.searchFts(adapter, request.params.id, keywords, limit, offset) + }) + + server.get<{ Params: { id: string } }>('/_web/sessions/:id/fts/status', async (request) => { + return { hasFtsIndex: sessionIndexService.getFtsStatus(adapter, request.params.id) } + }) + + server.post<{ Params: { id: string } }>('/_web/sessions/:id/fts/rebuild', async (request) => { + const result = sessionIndexService.rebuildFts(adapter, request.params.id) + return { success: true, indexed: result.indexed } + }) +} diff --git a/packages/http-routes/src/routes/web/sessions.ts b/packages/http-routes/src/routes/web/sessions.ts new file mode 100644 index 000000000..b622f93d4 --- /dev/null +++ b/packages/http-routes/src/routes/web/sessions.ts @@ -0,0 +1,85 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { sessionService, ownerProfileService, PreferencesManager } from '@openchatlab/node-runtime' + +export function registerSessionRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx + + // Lazy: only owner-profile routes need preferences.json access + let preferencesInstance: PreferencesManager | null = null + const preferences = () => { + preferencesInstance ??= ctx.preferencesManager ?? new PreferencesManager(ctx.pathProvider.getSystemDir()) + return preferencesInstance + } + + server.get('/_web/sessions', async () => { + const aiChatCounts = ctx.aiChatManager?.getAIChatCountsBySession() + return sessionService.listAnalysisSessions(adapter, { + enrichSession: aiChatCounts?.size + ? (dto) => ({ ...dto, aiConversationCount: aiChatCounts.get(dto.id) ?? 0 }) + : undefined, + }) + }) + + server.get<{ Params: { id: string } }>('/_web/sessions/:id', async (request) => { + const session = sessionService.getAnalysisSession(adapter, request.params.id) + if (!session) { + throw Object.assign(new Error(`Session not found: ${request.params.id}`), { statusCode: 404 }) + } + if (ctx.aiChatManager) { + session.aiConversationCount = ctx.aiChatManager.getAIChats(request.params.id).length + } + return session + }) + + server.delete<{ Params: { id: string } }>('/_web/sessions/:id', async (request, reply) => { + const { id } = request.params + try { + const deleted = sessionService.deleteSession(adapter, id) + if (!deleted) { + return reply.code(404).send({ success: false, error: 'File not found' }) + } + return { success: true } + } catch (err) { + return reply.code(500).send({ success: false, error: String(err) }) + } + }) + + server.patch<{ Params: { id: string }; Body: { name: string } }>('/_web/sessions/:id/name', async (request) => { + sessionService.renameSession(adapter, request.params.id, request.body.name) + return { success: true } + }) + + server.patch<{ Params: { id: string }; Body: { ownerId: string | null } }>( + '/_web/sessions/:id/owner', + async (request) => { + sessionService.updateSessionOwnerId(adapter, request.params.id, request.body.ownerId ?? null) + return { success: true } + } + ) + + // Try to auto-apply the stored platform owner profile to this session. + server.post<{ Params: { id: string } }>('/_web/sessions/:id/owner/apply-profile', async (request) => { + return ownerProfileService.tryApplyOwnerProfile(adapter, preferences(), request.params.id) + }) + + // Manually select owner: writes meta.owner_id, updates the platform profile, + // and batch-applies it to other unowned same-platform sessions. + server.post<{ Params: { id: string }; Body: { ownerPlatformId: string } }>( + '/_web/sessions/:id/owner/select', + async (request) => { + return ownerProfileService.setOwnerAndApplyProfile( + adapter, + preferences(), + request.params.id, + request.body.ownerPlatformId + ) + } + ) + + // Suppress the owner prompt for this session (UI-only). + server.post<{ Params: { id: string } }>('/_web/sessions/:id/owner/dismiss-prompt', async (request) => { + ownerProfileService.dismissOwnerPrompt(preferences(), request.params.id) + return { success: true } + }) +} diff --git a/packages/http-routes/src/routes/web/sql.ts b/packages/http-routes/src/routes/web/sql.ts new file mode 100644 index 000000000..679cf5a71 --- /dev/null +++ b/packages/http-routes/src/routes/web/sql.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' +import { executeSql, getSchemaDetailed } from '@openchatlab/core' + +export function registerSqlRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + const { sessionAdapter: adapter } = ctx + + server.post<{ Params: { id: string }; Body: { sql: string } }>('/_web/sessions/:id/sql', async (request, reply) => { + const db = adapter.ensureReadonly(request.params.id) + const { sql } = request.body || {} + if (!sql || typeof sql !== 'string') { + return reply.code(400).send({ error: 'Missing sql parameter' }) + } + try { + return executeSql(db, sql, { timing: true }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'SQL execution error' + return reply.code(400).send({ error: message }) + } + }) + + server.get<{ Params: { id: string } }>('/_web/sessions/:id/schema', async (request) => { + const db = adapter.ensureReadonly(request.params.id) + return getSchemaDetailed(db) + }) + + server.post<{ + Params: { id: string } + Body: { sql: string; params?: unknown[] | Record } + }>('/_web/sessions/:id/query', async (request) => { + const db = adapter.ensureReadonly(request.params.id) + const { sql, params = [] } = request.body as { sql: string; params?: unknown[] | Record } + + if (!sql || typeof sql !== 'string') { + throw Object.assign(new Error('Missing or invalid "sql" field'), { statusCode: 400 }) + } + + const stmt = db.prepare(sql.trim()) + + if (!stmt.readonly) { + throw Object.assign(new Error('Only READ-ONLY statements are allowed'), { statusCode: 403 }) + } + + if (Array.isArray(params)) { + return stmt.all(...params) + } + return stmt.all(params) + }) +} diff --git a/packages/http-routes/src/routes/web/telemetry.test.ts b/packages/http-routes/src/routes/web/telemetry.test.ts new file mode 100644 index 000000000..ad7f04ee3 --- /dev/null +++ b/packages/http-routes/src/routes/web/telemetry.test.ts @@ -0,0 +1,41 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import Fastify from 'fastify' +import type { HttpRouteContext } from '../../context' +import { registerTelemetryRoutes } from './telemetry' + +describe('telemetry routes', () => { + it('reads and writes analytics enabled state through the shared service', async () => { + let enabled = true + const app = Fastify() + registerTelemetryRoutes(app, { + analyticsService: { + getEnabled: () => enabled, + setEnabled: (next: boolean) => { + enabled = next + }, + }, + } as unknown as HttpRouteContext) + + await app.ready() + try { + const before = await app.inject({ method: 'GET', url: '/_web/telemetry/enabled' }) + assert.equal(before.statusCode, 200) + assert.deepEqual(before.json(), { enabled: true }) + + const update = await app.inject({ + method: 'POST', + url: '/_web/telemetry/enabled', + payload: { enabled: false }, + }) + assert.equal(update.statusCode, 200) + assert.deepEqual(update.json(), { success: true }) + + const after = await app.inject({ method: 'GET', url: '/_web/telemetry/enabled' }) + assert.equal(after.statusCode, 200) + assert.deepEqual(after.json(), { enabled: false }) + } finally { + await app.close() + } + }) +}) diff --git a/packages/http-routes/src/routes/web/telemetry.ts b/packages/http-routes/src/routes/web/telemetry.ts new file mode 100644 index 000000000..45821e2a0 --- /dev/null +++ b/packages/http-routes/src/routes/web/telemetry.ts @@ -0,0 +1,29 @@ +import type { FastifyInstance } from 'fastify' +import type { HttpRouteContext } from '../../context' + +export function registerTelemetryRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { + server.get('/_web/telemetry/enabled', async () => { + return { enabled: ctx.analyticsService?.getEnabled() ?? false } + }) + + server.post<{ Body: { enabled?: boolean } }>('/_web/telemetry/enabled', async (req) => { + if (!ctx.analyticsService || typeof req.body?.enabled !== 'boolean') return { success: false } + ctx.analyticsService.setEnabled(req.body.enabled) + return { success: true } + }) + + server.post<{ Body: { eventName: string; properties?: Record } }>( + '/_web/telemetry/track', + async (req, reply) => { + if (!ctx.analyticsService) return { ok: true } + const { eventName, properties } = req.body ?? {} + if (!eventName) return reply.code(400).send({ error: 'eventName required' }) + if (eventName === 'app_active') { + await ctx.analyticsService.trackDailyActive(properties) + } else { + await ctx.analyticsService.track(eventName, properties) + } + return { ok: true } + } + ) +} diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md new file mode 100644 index 000000000..ad0307ce4 --- /dev/null +++ b/packages/mcp-server/README.md @@ -0,0 +1,91 @@ +# chatlab-mcp + +ChatLab MCP Server exposes local ChatLab conversation data to MCP clients such as ClaudeCode, Cursor, Codex, and OpenClaw. + +It runs over stdio and provides read-only access to imported ChatLab sessions, including session discovery, keyword search, member statistics, time analysis, SQL queries, and conversation context tools. + +## Quick Start + +Use it directly with `npx`: + +```bash +npx -y chatlab-mcp +``` + +The command starts an MCP stdio server and waits for an MCP client. It does not print a help screen because stdout is reserved for MCP protocol messages. + +## MCP Client Configuration + +Add this server to your MCP client configuration: + +```json +{ + "mcpServers": { + "chatlab": { + "command": "npx", + "args": ["-y", "chatlab-mcp"] + } + } +} +``` + +If you install the package globally, you can also use: + +```json +{ + "mcpServers": { + "chatlab": { + "command": "chatlab-mcp" + } + } +} +``` + +## Data Directory + +`chatlab-mcp` reads the same local data directory as ChatLab: + +```text +~/.chatlab/ +``` + +If you configured a custom ChatLab data directory in `~/.chatlab/config.toml`, the MCP server will use that configuration automatically. + +The server is read-only. It opens existing ChatLab session databases and does not modify chat data. + +## Available Capabilities + +The server registers ChatLab tools for: + +- Listing imported chat sessions +- Reading session metadata and database schema +- Searching messages and keywords +- Loading recent messages and message context +- Listing members and member activity +- Analyzing active hours, response time, interaction pairs, and daily active users +- Running read-only SQL queries +- Producing text or JSON output for tool results + +## Requirements + +- Node.js 20 or later +- Existing ChatLab data under `~/.chatlab/` + +`better-sqlite3` is installed as a runtime dependency. On platforms without a matching prebuilt binary, npm may need local build tools for native modules. + +`@node-rs/jieba` is optional and improves Chinese word segmentation for keyword-frequency tools. If it is unavailable, core MCP functionality still works. + +## Package API + +Advanced callers can import the shared server core: + +```js +import { startMcpServer } from 'chatlab-mcp' +``` + +Most users should prefer the `npx -y chatlab-mcp` command. + +## Links + +- ChatLab: https://github.com/ChatLab/ChatLab +- Model Context Protocol: https://modelcontextprotocol.io/ diff --git a/packages/mcp-server/bin/chatlab-mcp.js b/packages/mcp-server/bin/chatlab-mcp.js new file mode 100755 index 000000000..40579bc4d --- /dev/null +++ b/packages/mcp-server/bin/chatlab-mcp.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import('../dist/bin.mjs') diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json new file mode 100644 index 000000000..c81d90bd2 --- /dev/null +++ b/packages/mcp-server/package.json @@ -0,0 +1,54 @@ +{ + "name": "chatlab-mcp", + "version": "0.25.1", + "description": "ChatLab MCP Server — shared core for CLI and Desktop, also usable standalone via npx", + "type": "module", + "main": "./dist/index.mjs", + "bin": { + "chatlab-mcp": "bin/chatlab-mcp.js" + }, + "files": [ + "bin/", + "dist/" + ], + "engines": { + "node": ">=20" + }, + "publishConfig": { + "access": "public" + }, + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/ChatLab/ChatLab.git", + "directory": "packages/mcp-server" + }, + "homepage": "https://github.com/ChatLab/ChatLab", + "keywords": [ + "chatlab", + "mcp", + "model-context-protocol", + "chat", + "analysis" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "better-sqlite3": "^12.4.6", + "smol-toml": "^1.3.1", + "zod": "^3.24.4" + }, + "optionalDependencies": { + "@node-rs/jieba": "^2.0.1" + }, + "devDependencies": { + "@openchatlab/config": "workspace:*", + "@openchatlab/core": "workspace:*", + "@openchatlab/node-runtime": "workspace:*", + "@openchatlab/tools": "workspace:*", + "tsup": "^8.5.0" + } +} diff --git a/packages/mcp-server/src/bin.test.ts b/packages/mcp-server/src/bin.test.ts new file mode 100644 index 000000000..252e9df1f --- /dev/null +++ b/packages/mcp-server/src/bin.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { DataDirCompatibilityError, raiseDataDirMinRuntimeVersion } from '@openchatlab/node-runtime' +import { initStandaloneMcpRuntime } from './standalone-runtime' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-mcp-bin-')) +} + +test('initStandaloneMcpRuntime rejects incompatible data directories before stdio startup', () => { + const userDataDir = makeTempDir() + const pathProvider = initStandaloneMcpRuntime('0.26.0', userDataDir).pathProvider + raiseDataDirMinRuntimeVersion(pathProvider, { + minRuntimeVersion: '0.26.0', + dataCompatibilityVersion: 2, + reason: 'future-schema', + runtime: { version: '0.26.0', kind: 'desktop' }, + module: 'future-migration', + now: () => 1780830000, + }) + + assert.throws( + () => initStandaloneMcpRuntime('0.25.1', userDataDir), + (error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' + ) +}) diff --git a/packages/mcp-server/src/bin.ts b/packages/mcp-server/src/bin.ts new file mode 100644 index 000000000..9d83e0bfb --- /dev/null +++ b/packages/mcp-server/src/bin.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +/** + * Standalone MCP Server entry for `npx -y chatlab-mcp` + * + * Initializes runtime (config, paths, database) and starts the MCP server. + * All logs go to stderr; stdout is reserved for MCP protocol communication. + */ + +import { loadConfig } from '@openchatlab/config' +import { startMcpServer } from './server' +import { initStandaloneMcpRuntime } from './standalone-runtime' +import { getMcpPackageVersion } from './runtime-version' + +function main(): void { + const config = loadConfig() + const userDataDir = config.data.user_data_dir || undefined + const version = getMcpPackageVersion() + const { dbManager } = initStandaloneMcpRuntime(version, userDataDir) + + startMcpServer({ version, dbManager }).catch((err) => { + console.error('[chatlab-mcp] Fatal error:', err) + process.exit(1) + }) +} + +main() diff --git a/packages/mcp-server/src/env.d.ts b/packages/mcp-server/src/env.d.ts new file mode 100644 index 000000000..51ea0e976 --- /dev/null +++ b/packages/mcp-server/src/env.d.ts @@ -0,0 +1 @@ +declare const __MCP_PACKAGE_VERSION__: string diff --git a/packages/mcp-server/src/format.ts b/packages/mcp-server/src/format.ts new file mode 100644 index 000000000..0fad8d450 --- /dev/null +++ b/packages/mcp-server/src/format.ts @@ -0,0 +1,238 @@ +/** + * MCP output formatting for token-efficient LLM consumption + * + * Provides message filtering, merging, truncation, and compact text output. + * Inspired by community chatlab-mcp-server's format.ts. + */ + +const MAX_CONTENT_LENGTH = 200 +const MAX_MERGED_CONTENT_LENGTH = 400 + +const PLACEHOLDER_CONTENTS = new Set([ + '[图片]', + '[语音]', + '[视频]', + '[文件]', + '[表情]', + '[动画表情]', + '[位置]', + '[名片]', + '[红包]', + '[转账]', + '[撤回消息]', + '[分享]', + '[image]', + '[voice]', + '[video]', + '[file]', + '[sticker]', + '[animated sticker]', + '[location]', + '[contact]', + '[red packet]', + '[transfer]', + '[recalled message]', + '[photo]', + '[audio]', + '[gif]', + '[share]', +]) + +const MEANINGLESS_SHORT_EN = new Set([ + 'ok', + 'k', + 'yes', + 'no', + 'ya', + 'yep', + 'nope', + 'lol', + 'haha', + 'hehe', + 'hmm', + 'ah', + 'oh', + 'wow', + 'thx', + 'ty', + 'np', + 'gg', + 'brb', + 'idk', +]) + +const MEANINGFUL_SHORT_ZH = new Set(['好的', '不是', '是的', '可以', '不行', '好吧', '明白', '知道', '同意']) + +const SYSTEM_PATTERNS_ZH = [/^.*邀请.*加入了群聊$/, /^.*退出了群聊$/, /^.*撤回了一条消息$/, /^你撤回了一条消息$/] + +const SYSTEM_PATTERNS_EN = [ + /^.*invited.*to the group$/i, + /^.*left the group$/i, + /^.*recalled a message$/i, + /^.*joined the group$/i, + /^.*has been removed$/i, +] + +const EMOJI_ONLY = /^[\p{Emoji}\s[\]()()]+$/u + +interface FormattableMessage { + senderName: string + content: string | null + timestamp: number +} + +/** + * Check if message content is meaningful (not noise). + */ +export function isValidMessage(content: string): boolean { + const trimmed = content.trim() + if (!trimmed) return false + + if (trimmed.length <= 2 && !MEANINGFUL_SHORT_ZH.has(trimmed)) return false + if (MEANINGLESS_SHORT_EN.has(trimmed.toLowerCase())) return false + if (EMOJI_ONLY.test(trimmed)) return false + if (PLACEHOLDER_CONTENTS.has(trimmed.toLowerCase())) return false + if (SYSTEM_PATTERNS_ZH.some((p) => p.test(trimmed))) return false + if (SYSTEM_PATTERNS_EN.some((p) => p.test(trimmed))) return false + + return true +} + +function truncate(text: string, maxLen: number): string { + return text.length <= maxLen ? text : text.slice(0, maxLen) + '...' +} + +function formatTimestamp(ts: number): string { + const d = new Date(ts * 1000) + const M = d.getMonth() + 1 + const D = d.getDate() + const h = String(d.getHours()).padStart(2, '0') + const m = String(d.getMinutes()).padStart(2, '0') + return `${M}/${D} ${h}:${m}` +} + +/** + * Format messages as compact plain text with consecutive-sender merging. + * Filters out noise messages, merges same-sender runs, truncates long content. + */ +export function formatMessagesCompact(messages: FormattableMessage[]): string { + if (messages.length === 0) return '' + + const valid = messages.filter((m) => m.content && isValidMessage(m.content)) + if (valid.length === 0) return '' + + const lines: string[] = [] + let prevSender = '' + let pendingContents: string[] = [] + let pendingTs = 0 + + const flush = () => { + if (pendingContents.length === 0) return + const combined = pendingContents.join('; ') + const maxLen = pendingContents.length > 1 ? MAX_MERGED_CONTENT_LENGTH : MAX_CONTENT_LENGTH + lines.push(`${formatTimestamp(pendingTs)} ${prevSender}: ${truncate(combined, maxLen)}`) + } + + for (const msg of valid) { + const content = msg.content!.trim() + if (msg.senderName === prevSender) { + pendingContents.push(content) + } else { + flush() + prevSender = msg.senderName + pendingContents = [content] + pendingTs = msg.timestamp + } + } + flush() + + return lines.join('\n') +} + +/** + * Try to parse a tool result's JSON content and convert to compact text. + * Returns null if the content is not suitable for text formatting. + */ +export function formatToolResultAsText(content: string): string | null { + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch { + return null + } + + if (!parsed || typeof parsed !== 'object') return null + const obj = parsed as Record + + // If there's a messages array with senderName/content/timestamp, format it + if (Array.isArray(obj.messages) && obj.messages.length > 0) { + const msgs = obj.messages as FormattableMessage[] + if (msgs[0] && 'senderName' in msgs[0] && 'timestamp' in msgs[0]) { + return formatObjectWithMessages(obj, msgs) + } + } + + // If there are ranking/keywords arrays, format as numbered list + if (Array.isArray(obj.ranking) || Array.isArray(obj.keywords)) { + return formatArrayResult(obj) + } + + // If there are sessions, format them compactly + if (Array.isArray(obj.sessions)) { + return formatSessionsList(obj) + } + + return null +} + +function formatObjectWithMessages(obj: Record, msgs: FormattableMessage[]): string { + const lines: string[] = [] + + for (const [key, value] of Object.entries(obj)) { + if (key === 'messages') continue + if (value === undefined || value === null) continue + if (typeof value === 'object' && !Array.isArray(value)) continue + if (Array.isArray(value)) continue + lines.push(`${key}: ${value}`) + } + + const formatted = formatMessagesCompact(msgs) + if (formatted) { + if (lines.length > 0) lines.push('') + lines.push(formatted) + } + + return lines.join('\n') +} + +function formatArrayResult(obj: Record): string { + const lines: string[] = [] + + for (const [key, value] of Object.entries(obj)) { + if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'string') { + if (lines.length > 0) lines.push('') + lines.push(...value) + } else if (!Array.isArray(value) && value !== undefined && value !== null) { + lines.push(`${key}: ${value}`) + } + } + + return lines.join('\n') +} + +function formatSessionsList(obj: Record): string { + const sessions = obj.sessions as Array> + const total = obj.total ?? sessions.length + const lines: string[] = [`${total} sessions:`] + + for (const s of sessions) { + const name = s.name ?? s.id + const platform = s.platform ? ` (${s.platform})` : '' + const msgCount = s.totalMessages ?? s.messageCount ?? '' + const members = s.totalMembers ?? s.memberCount ?? '' + const stats = msgCount ? ` — ${msgCount} msgs, ${members} members` : '' + lines.push(`- ${name}${platform}${stats} [${s.id}]`) + } + + return lines.join('\n') +} diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts new file mode 100644 index 000000000..d1ca22255 --- /dev/null +++ b/packages/mcp-server/src/index.ts @@ -0,0 +1,9 @@ +/** + * chatlab-mcp + * + * Shared MCP Server core for CLI and Desktop helper. + * Registers ChatLab tools and resources over stdio transport. + */ + +export { startMcpServer } from './server' +export type { McpServerOptions, McpDatabaseManager } from './types' diff --git a/packages/mcp-server/src/runtime-version.test.ts b/packages/mcp-server/src/runtime-version.test.ts new file mode 100644 index 000000000..f54e764b5 --- /dev/null +++ b/packages/mcp-server/src/runtime-version.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { resolveMcpPackageVersion } from './runtime-version' + +test('resolveMcpPackageVersion uses bundled package version instead of npm caller env', () => { + const previous = process.env.npm_package_version + process.env.npm_package_version = '9.9.9' + + try { + assert.equal(resolveMcpPackageVersion('0.25.1'), '0.25.1') + } finally { + if (previous === undefined) { + delete process.env.npm_package_version + } else { + process.env.npm_package_version = previous + } + } +}) diff --git a/packages/mcp-server/src/runtime-version.ts b/packages/mcp-server/src/runtime-version.ts new file mode 100644 index 000000000..6c5f53104 --- /dev/null +++ b/packages/mcp-server/src/runtime-version.ts @@ -0,0 +1,8 @@ +export function resolveMcpPackageVersion(bundledVersion?: string): string { + const normalizedVersion = typeof bundledVersion === 'string' ? bundledVersion.trim() : '' + return normalizedVersion || '0.0.0-dev' +} + +export function getMcpPackageVersion(): string { + return resolveMcpPackageVersion(typeof __MCP_PACKAGE_VERSION__ !== 'undefined' ? __MCP_PACKAGE_VERSION__ : undefined) +} diff --git a/packages/mcp-server/src/schema.ts b/packages/mcp-server/src/schema.ts new file mode 100644 index 000000000..f4497fd9f --- /dev/null +++ b/packages/mcp-server/src/schema.ts @@ -0,0 +1,46 @@ +/** + * JSON Schema → Zod conversion for MCP tool registration + */ + +import { z } from 'zod' + +/** + * Convert simple JSON Schema properties to a Zod shape object. + * Supports string / number / boolean / enum types. + */ +export function jsonSchemaToZod( + properties: Record, + required?: string[] +): Record { + const shape: Record = {} + const requiredSet = new Set(required ?? []) + + for (const [key, prop] of Object.entries(properties)) { + let zodType: z.ZodTypeAny + + switch (prop.type) { + case 'number': + zodType = z.number().describe(prop.description ?? '') + break + case 'boolean': + zodType = z.boolean().describe(prop.description ?? '') + break + case 'string': + default: + if (prop.enum) { + zodType = z.enum(prop.enum as [string, ...string[]]).describe(prop.description ?? '') + } else { + zodType = z.string().describe(prop.description ?? '') + } + break + } + + if (!requiredSet.has(key)) { + zodType = zodType.optional() + } + + shape[key] = zodType + } + + return shape +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts new file mode 100644 index 000000000..3786f3cb3 --- /dev/null +++ b/packages/mcp-server/src/server.ts @@ -0,0 +1,174 @@ +/** + * ChatLab MCP Server core + * + * Registers @openchatlab/tools as MCP tools and exposes session data as MCP resources. + * Communicates with AI agents (ClaudeCode, Cursor, etc.) via stdio transport. + */ + +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { z } from 'zod' +import { getSessionMeta, getSessionOverview, getDatabaseSchema } from '@openchatlab/core' +import { MCP_TOOL_REGISTRY, CoreDataProvider } from '@openchatlab/tools' +import type { SessionListContext } from '@openchatlab/tools/src/definitions/sessions' +import type { McpDatabaseManager, McpServerOptions } from './types' +import { jsonSchemaToZod } from './schema' +import { formatToolResultAsText } from './format' + +const MCP_TOOL_PREFIX = 'chatlab_' + +const FORMAT_PARAM = z.enum(['text', 'json']).optional().describe('text (default) or json') + +/** Tools where JSON is a better default (raw SQL results, schema) */ +const JSON_DEFAULT_TOOLS = new Set(['execute_sql', 'get_schema']) + +function applyFormat(content: string, format: string | undefined, toolName: string): string { + const effectiveFormat = format ?? (JSON_DEFAULT_TOOLS.has(toolName) ? 'json' : 'text') + if (effectiveFormat === 'json') return content + + const textResult = formatToolResultAsText(content) + return textResult ?? content +} + +function registerTools(server: McpServer, dbManager: McpDatabaseManager): void { + for (const tool of MCP_TOOL_REGISTRY) { + const mcpName = `${MCP_TOOL_PREFIX}${tool.name}` + + if (tool.name === 'list_sessions') { + const zodShape = { + ...jsonSchemaToZod(tool.inputSchema.properties, tool.inputSchema.required), + format: FORMAT_PARAM, + } + + server.tool(mcpName, tool.description, zodShape, async (params) => { + const format = params.format as string | undefined + const context: SessionListContext = { + db: null as any, + sessionId: '', + listSessionIds: () => dbManager.listSessionIds(), + openDb: (id) => dbManager.open(id), + } + const toolParams = { ...params } as Record + delete toolParams.format + const result = await tool.handler(toolParams, context) + const text = applyFormat(result.content, format, tool.name) + return { content: [{ type: 'text' as const, text }] } + }) + continue + } + + const zodShape = { + session_id: z.string().describe('Session ID'), + ...jsonSchemaToZod(tool.inputSchema.properties, tool.inputSchema.required), + format: FORMAT_PARAM, + } + + server.tool(mcpName, tool.description, zodShape, async (params) => { + const sessionId = params.session_id as string + const format = params.format as string | undefined + const db = dbManager.open(sessionId) + if (!db) { + return { + content: [{ type: 'text' as const, text: `Session not found: ${sessionId}` }], + isError: true, + } + } + + const toolParams = { ...params } as Record + delete toolParams.session_id + delete toolParams.format + + const result = await tool.handler(toolParams, { db, sessionId, dataProvider: new CoreDataProvider(db) }) + const text = applyFormat(result.content, format, tool.name) + return { content: [{ type: 'text' as const, text }] } + }) + } +} + +function registerResources(server: McpServer, dbManager: McpDatabaseManager): void { + server.resource('sessions-list', 'chatlab://sessions', { description: '所有已导入的聊天会话列表' }, async () => { + const sessionIds = dbManager.listSessionIds() + const sessions = sessionIds + .map((id) => { + const db = dbManager.open(id) + if (!db) return null + const meta = getSessionMeta(db) + if (!meta) return null + return { id, name: meta.name, platform: meta.platform, type: meta.type } + }) + .filter(Boolean) + + return { + contents: [ + { + uri: 'chatlab://sessions', + text: JSON.stringify(sessions, null, 2), + mimeType: 'application/json', + }, + ], + } + }) + + server.resource( + 'session-meta', + new ResourceTemplate('chatlab://sessions/{sessionId}/meta', { list: undefined }), + { description: '会话元信息(名称、平台、消息数等)' }, + async (uri, params) => { + const sessionId = params.sessionId as string + const db = dbManager.open(sessionId) + if (!db) { + return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] } + } + + const meta = getSessionMeta(db) + const overview = getSessionOverview(db) + + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify({ ...meta, ...overview }, null, 2), + mimeType: 'application/json', + }, + ], + } + } + ) + + server.resource( + 'session-schema', + new ResourceTemplate('chatlab://sessions/{sessionId}/schema', { list: undefined }), + { description: '会话数据库的表结构' }, + async (uri, params) => { + const sessionId = params.sessionId as string + const db = dbManager.open(sessionId) + if (!db) { + return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] } + } + + const schema = getDatabaseSchema(db) + + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(schema, null, 2), + mimeType: 'application/json', + }, + ], + } + } + ) +} + +export async function startMcpServer(options: McpServerOptions): Promise { + const { version, name = 'chatlab', dbManager } = options + + const server = new McpServer({ name, version }) + + registerTools(server, dbManager) + registerResources(server, dbManager) + + const transport = new StdioServerTransport() + await server.connect(transport) +} diff --git a/packages/mcp-server/src/standalone-runtime.ts b/packages/mcp-server/src/standalone-runtime.ts new file mode 100644 index 000000000..7324c5455 --- /dev/null +++ b/packages/mcp-server/src/standalone-runtime.ts @@ -0,0 +1,18 @@ +import { + assertDataDirCompatible, + DatabaseManager, + NodePathProvider, + type RuntimeIdentity, +} from '@openchatlab/node-runtime' + +export function initStandaloneMcpRuntime( + version: string, + userDataDir?: string +): { dbManager: DatabaseManager; pathProvider: NodePathProvider; runtime: RuntimeIdentity } { + const pathProvider = new NodePathProvider(userDataDir) + pathProvider.ensureAllDirs() + const runtime: RuntimeIdentity = { version, kind: 'mcp' } + assertDataDirCompatible(pathProvider, runtime) + const dbManager = new DatabaseManager(pathProvider, { runtime }) + return { dbManager, pathProvider, runtime } +} diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts new file mode 100644 index 000000000..a9f4e4c12 --- /dev/null +++ b/packages/mcp-server/src/types.ts @@ -0,0 +1,21 @@ +/** + * MCP Server public types + */ + +import type { DatabaseAdapter } from '@openchatlab/core' + +/** + * Minimal database manager interface for MCP Server. + * Both CLI's DatabaseManager and Desktop's helper can satisfy this contract. + */ +export interface McpDatabaseManager { + listSessionIds(): string[] + open(sessionId: string): DatabaseAdapter | null +} + +export interface McpServerOptions { + version: string + /** MCP server name exposed to clients (default: 'chatlab') */ + name?: string + dbManager: McpDatabaseManager +} diff --git a/packages/mcp-server/tsconfig.json b/packages/mcp-server/tsconfig.json new file mode 100644 index 000000000..1e386f3bc --- /dev/null +++ b/packages/mcp-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "composite": true, + "noEmit": true + }, + "include": [ + "src/**/*.ts", + "../../packages/shared-types/**/*", + "../../packages/core/src/**/*.ts", + "../../packages/tools/src/**/*.ts" + ], + "exclude": [ + "**/__tests__/**", + "**/*.test.ts", + "**/*.spec.ts", + "src/bin.ts" + ] +} diff --git a/packages/mcp-server/tsup.config.ts b/packages/mcp-server/tsup.config.ts new file mode 100644 index 000000000..a819d4865 --- /dev/null +++ b/packages/mcp-server/tsup.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from 'tsup' +import { readFileSync } from 'node:fs' + +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string } + +export default defineConfig({ + entry: { + index: 'src/index.ts', + bin: 'src/bin.ts', + }, + format: ['esm'], + dts: false, + outDir: 'dist', + outExtension: () => ({ js: '.mjs' }), + splitting: true, + sourcemap: true, + clean: true, + target: 'node20', + platform: 'node', + define: { + __MCP_PACKAGE_VERSION__: JSON.stringify(pkg.version), + }, + noExternal: [/^@openchatlab\//], + external: ['better-sqlite3', '@node-rs/jieba'], + banner: { + js: [ + "import { createRequire as __createRequire } from 'module';", + "import { dirname as __pathDirname } from 'path';", + "import { fileURLToPath as __fileURLToPath } from 'url';", + 'const require = __createRequire(import.meta.url);', + 'const __filename = __fileURLToPath(import.meta.url);', + 'const __dirname = __pathDirname(__filename);', + ].join('\n'), + }, +}) diff --git a/packages/node-runtime/package.json b/packages/node-runtime/package.json new file mode 100644 index 000000000..1a0da06fb --- /dev/null +++ b/packages/node-runtime/package.json @@ -0,0 +1,33 @@ +{ + "name": "@openchatlab/node-runtime", + "version": "0.0.0", + "private": true, + "description": "ChatLab Node.js 运行时适配器:better-sqlite3、文件系统、路径管理", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "@earendil-works/pi-agent-core": "0.74.2", + "@earendil-works/pi-ai": "0.74.2", + "@huggingface/transformers": "^4.2.0", + "@openchatlab/core": "workspace:*", + "better-sqlite3": "^12.4.6", + "gray-matter": "^4.0.3", + "js-tiktoken": "^1.0.21", + "onnxruntime-common": "1.24.3", + "onnxruntime-node": "1.24.3", + "sharp": "0.34.5", + "sqlite-vec": "^0.1.9", + "stream-chain": "^2.2.5", + "stream-json": "^1.9.1", + "undici": "^6.25.0", + "yauzl": "^3.4.0" + }, + "optionalDependencies": { + "@node-rs/jieba": "^2.0.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/yauzl": "^3.4.0" + } +} diff --git a/packages/node-runtime/src/ai/__tests__/activate-skill-tool.test.ts b/packages/node-runtime/src/ai/__tests__/activate-skill-tool.test.ts new file mode 100644 index 000000000..d8007d9ab --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/activate-skill-tool.test.ts @@ -0,0 +1,42 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createActivateSkillTool } from '../activate-skill-tool' +import type { SkillDef } from '../types' + +const TOOL_SKILL: SkillDef = { + id: 'tool_skill', + name: 'Tool Skill', + description: 'Requires an analysis tool', + tags: ['test'], + chatScope: 'all', + tools: ['keyword_frequency'], + prompt: 'Use keyword frequency.', +} + +describe('createActivateSkillTool', () => { + it('rejects a skill when an empty allowedTools list omits required tools', async () => { + const tool = createActivateSkillTool({ + chatType: 'group', + allowedTools: [], + getSkillConfig: () => TOOL_SKILL, + }) + + const result = await tool.execute('call_1', { skill_id: 'tool_skill' }) + + assert.equal(result.details.applicable, false) + assert.deepEqual(result.details.missingTools, ['keyword_frequency']) + }) + + it('allows core tool requirements even when no analysis tools are allowed', async () => { + const tool = createActivateSkillTool({ + chatType: 'group', + allowedTools: [], + coreToolNames: new Set(['get_schema']), + getSkillConfig: () => ({ ...TOOL_SKILL, tools: ['get_schema'] }), + }) + + const result = await tool.execute('call_1', { skill_id: 'tool_skill' }) + + assert.equal(result.details.applicable, true) + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/assistant-manager.test.ts b/packages/node-runtime/src/ai/__tests__/assistant-manager.test.ts new file mode 100644 index 000000000..75b24207a --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/assistant-manager.test.ts @@ -0,0 +1,152 @@ +import { describe, it, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { AssistantManager, type AssistantManagerFs, type AssistantManagerDeps } from '../assistant-manager' + +function createMemoryFs(): AssistantManagerFs & { files: Map } { + const files = new Map() + return { + files, + ensureDir: () => { + /* no-op */ + }, + listFiles: (_dir, ext) => + Array.from(files.keys()) + .filter((f) => f.endsWith(ext)) + .map((f) => f.split('/').pop()!), + readFile: (p) => { + const content = files.get(p) + if (!content) throw new Error(`File not found: ${p}`) + return content + }, + writeFile: (p, content) => files.set(p, content), + deleteFile: (p) => files.delete(p), + fileExists: (p) => files.has(p), + joinPath: (...parts) => parts.join('/'), + } +} + +const SAMPLE_BUILTIN = `--- +id: general_cn +name: 通用助手 +presetQuestions: + - 你好 +--- +你是一个通用助手。` + +function createManager(opts?: { builtins?: Array<{ id: string; content: string }>; generalIds?: string[] }): { + manager: AssistantManager + fs: ReturnType +} { + const memFs = createMemoryFs() + let idCounter = 0 + const deps: AssistantManagerDeps = { + fs: memFs, + assistantsDir: '/data/assistants', + builtinRawConfigs: opts?.builtins || [{ id: 'general_cn', content: SAMPLE_BUILTIN }], + generalIds: opts?.generalIds || ['general_cn'], + generateId: () => `custom_${++idCounter}`, + } + return { manager: new AssistantManager(deps), fs: memFs } +} + +describe('AssistantManager', () => { + let manager: AssistantManager + let memFs: ReturnType + + beforeEach(() => { + const ctx = createManager() + manager = ctx.manager + memFs = ctx.fs + }) + + it('initializes and creates general assistants', () => { + const result = manager.init() + assert.ok(result.generalCreated) + assert.equal(result.total, 1) + assert.ok(memFs.files.has('/data/assistants/general_cn.md')) + }) + + it('does not recreate general if already exists', () => { + manager.init() + const result2 = manager.init() + assert.equal(result2.generalCreated, false) + }) + + it('getAllAssistants returns summaries', () => { + manager.init() + const all = manager.getAllAssistants() + assert.equal(all.length, 1) + assert.equal(all[0].name, '通用助手') + }) + + it('getAssistantConfig returns full config', () => { + manager.init() + const config = manager.getAssistantConfig('general_cn') + assert.ok(config) + assert.equal(config!.systemPrompt, '你是一个通用助手。') + }) + + it('creates a custom assistant', () => { + manager.init() + const result = manager.createAssistant({ + name: 'Custom', + systemPrompt: 'Be helpful.', + presetQuestions: [], + }) + assert.ok(result.success) + assert.ok(result.id) + assert.equal(manager.getAllAssistants().length, 2) + }) + + it('updates an assistant', () => { + manager.init() + manager.createAssistant({ name: 'Old', systemPrompt: 'x', presetQuestions: [] }) + const result = manager.updateAssistant('custom_1', { name: 'New' }) + assert.ok(result.success) + assert.equal(manager.getAssistantConfig('custom_1')!.name, 'New') + }) + + it('deletes a non-general assistant', () => { + manager.init() + manager.createAssistant({ name: 'ToDelete', systemPrompt: 'x', presetQuestions: [] }) + const result = manager.deleteAssistant('custom_1') + assert.ok(result.success) + assert.equal(manager.hasAssistant('custom_1'), false) + }) + + it('refuses to delete general assistant', () => { + manager.init() + const result = manager.deleteAssistant('general_cn') + assert.equal(result.success, false) + assert.ok(result.error) + }) + + it('resets a builtin assistant', () => { + manager.init() + manager.updateAssistant('general_cn', { name: 'Modified' }) + assert.equal(manager.getAssistantConfig('general_cn')!.name, 'Modified') + + const result = manager.resetAssistant('general_cn') + assert.ok(result.success) + assert.equal(manager.getAssistantConfig('general_cn')!.name, '通用助手') + }) + + it('imports from raw markdown', () => { + manager.init() + const md = `--- +id: cloud_test +name: Cloud Assistant +presetQuestions: [] +--- +Cloud system prompt.` + const result = manager.importAssistantFromMd(md) + assert.ok(result.success) + assert.equal(result.id, 'cloud_test') + assert.equal(manager.hasAssistant('cloud_test'), true) + }) + + it('isGeneralAssistant checks correctly', () => { + assert.ok(manager.isGeneralAssistant('general_cn')) + assert.ok(!manager.isGeneralAssistant('custom_1')) + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/assistant-parser.test.ts b/packages/node-runtime/src/ai/__tests__/assistant-parser.test.ts new file mode 100644 index 000000000..560020f11 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/assistant-parser.test.ts @@ -0,0 +1,23 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { parseAssistantFile } from '../assistant-parser' + +describe('parseAssistantFile', () => { + it('normalizes legacy session tool names in allowedBuiltinTools', () => { + const config = parseAssistantFile( + `--- +id: legacy_tools +name: Legacy Tools +allowedBuiltinTools: + - get_session_messages + - get_session_summaries + - keyword_frequency +--- +Use selected tools.`, + 'legacy_tools.md' + ) + + assert.ok(config) + assert.deepEqual(config.allowedBuiltinTools, ['get_segment_messages', 'get_segment_summaries', 'keyword_frequency']) + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts b/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts new file mode 100644 index 000000000..0bd802c0c --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/builtin-chart-skill.test.ts @@ -0,0 +1,56 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core' + +import { buildSkillMenuWithBuiltinChart, getSkillConfigWithBuiltinChart } from '../builtin-chart-skill' +import { buildSkillMenuText, formatSkillMenuLine } from '../skill-menu' + +function requireMenu(menu: string | null): string { + assert.ok(menu) + return menu +} + +describe('builtin chart skill helpers', () => { + it('adds chart_runtime to an empty auto skill menu', () => { + const menu = requireMenu(buildSkillMenuWithBuiltinChart(null, 'zh-CN')) + + assert.match(menu, /chart_runtime/) + assert.match(menu, /绘图助手/) + assert.match(menu, /不要输出 Python\/JS 绘图代码/) + }) + + it('appends chart_runtime to an existing auto skill menu', () => { + const baseMenu = buildSkillMenuText([ + formatSkillMenuLine({ + id: 'existing', + name: 'Existing Skill', + description: 'Existing description', + }), + ]) + + const menu = requireMenu(buildSkillMenuWithBuiltinChart(baseMenu, 'zh-CN')) + + assert.match(menu, /existing/) + assert.match(menu, /chart_runtime/) + assert.ok(menu.indexOf('existing') < menu.indexOf('chart_runtime')) + }) + + it('does not add chart_runtime when render_chart is unavailable', () => { + const menu = buildSkillMenuWithBuiltinChart(null, 'zh-CN', []) + + assert.equal(menu, null) + }) + + it('adds chart_runtime when render_chart is explicitly available', () => { + const menu = requireMenu(buildSkillMenuWithBuiltinChart(null, 'zh-CN', ['render_chart'])) + + assert.match(menu, /chart_runtime/) + }) + + it('resolves chart_runtime without a user-imported skill file', () => { + const skill = getSkillConfigWithBuiltinChart(CHART_CAPABILITY_SKILL_ID, 'en-US', () => null) + + assert.ok(skill) + assert.equal(skill.id, CHART_CAPABILITY_SKILL_ID) + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts b/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts new file mode 100644 index 000000000..bb2f9516f --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/chart-runtime.test.ts @@ -0,0 +1,137 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { + CHART_CAPABILITY_SKILL_ID, + getChartPlannerCapabilityForMessage, + getAllowedBuiltinToolsForChartAutoSkill, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, + resolveChartRuntimeForRequest, +} from '../chart-runtime' + +describe('chart runtime policy', () => { + it('keeps chart skill metadata in node runtime', () => { + const skill = getChartCapabilitySkill('en-US') + + assert.equal(skill.id, CHART_CAPABILITY_SKILL_ID) + assert.equal(skill.name, 'Chart Assistant') + assert.deepEqual(skill.tools, ['render_chart', 'get_schema']) + }) + + it('enables chart runtime only for explicit chart skill by default', () => { + assert.equal( + resolveChartRuntimeForRequest({ + skillId: CHART_CAPABILITY_SKILL_ID, + userMessage: 'draw a chart', + locale: 'en-US', + }).isChartCapability, + true + ) + + assert.equal( + resolveChartRuntimeForRequest({ + skillId: null, + userMessage: '画一个趋势图', + locale: 'zh-CN', + }).isChartCapability, + false + ) + }) + + it('keeps analytical trend questions in normal runtime for the default chart auto mode', () => { + const runtime = resolveChartRuntimeForRequest({ + skillId: null, + userMessage: '分析过去一年群里每季度消息量变化趋势,指出峰值和低谷。', + locale: 'zh-CN', + enableAutoDetection: true, + chartAutoMode: 'suggest', + }) + + assert.equal(runtime.isChartCapability, false) + }) + + it('can auto-enable chart runtime aggressively for analytical trend questions', () => { + const runtime = resolveChartRuntimeForRequest({ + skillId: null, + userMessage: '分析过去一年群里每季度消息量变化趋势,指出峰值和低谷。', + locale: 'zh-CN', + enableAutoDetection: true, + chartAutoMode: 'aggressive', + }) + + assert.equal(runtime.isChartCapability, true) + assert.equal(runtime.skillDef?.id, CHART_CAPABILITY_SKILL_ID) + assert.deepEqual(runtime.allowedBuiltinTools, ['render_chart']) + }) + + it('still auto-enables chart runtime for explicit chart requests', () => { + const runtime = resolveChartRuntimeForRequest({ + skillId: null, + userMessage: '画一个最近一年的消息量趋势图。', + locale: 'zh-CN', + enableAutoDetection: true, + chartAutoMode: 'explicit', + }) + + assert.equal(runtime.isChartCapability, true) + }) + + it('keeps chart tool allowlists free of raw SQL', () => { + assert.deepEqual(getChartCapabilityAllowedBuiltinTools(), ['render_chart']) + assert.deepEqual(getChartCapabilityAllowedBuiltinTools(['keyword_frequency', 'execute_sql']), [ + 'keyword_frequency', + 'render_chart', + ]) + }) + + it('does not narrow unrestricted auto-skill assistant tools', () => { + assert.equal(getAllowedBuiltinToolsForChartAutoSkill(undefined), undefined) + assert.deepEqual(getAllowedBuiltinToolsForChartAutoSkill([]), []) + assert.deepEqual(getAllowedBuiltinToolsForChartAutoSkill(['keyword_frequency']), [ + 'keyword_frequency', + 'render_chart', + ]) + }) + + it('does not remove raw SQL from non-chart auto-skill turns', () => { + assert.deepEqual(getAllowedBuiltinToolsForChartAutoSkill(['execute_sql', 'keyword_frequency']), [ + 'execute_sql', + 'keyword_frequency', + 'render_chart', + ]) + }) + + it('offers chart planner capability for analytical trend questions in suggest mode', () => { + const capability = getChartPlannerCapabilityForMessage({ + userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化。', + locale: 'zh-CN', + availableTools: ['get_schema', 'search_messages', 'render_chart'], + chartAutoMode: 'suggest', + }) + + assert.equal(capability?.id, 'chart_generation') + assert.deepEqual(capability?.tools, ['get_schema', 'render_chart']) + }) + + it('does not offer chart planner capability for analytical wording in explicit mode', () => { + const capability = getChartPlannerCapabilityForMessage({ + userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化。', + locale: 'zh-CN', + availableTools: ['get_schema', 'search_messages', 'render_chart'], + chartAutoMode: 'explicit', + }) + + assert.equal(capability, null) + }) + + it('does not offer chart planner capability when render_chart is unavailable', () => { + const capability = getChartPlannerCapabilityForMessage({ + userMessage: '分析过去一年群里话题的变化趋势。', + locale: 'zh-CN', + availableTools: ['get_schema', 'search_messages'], + }) + + assert.equal(capability, null) + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/chart-schema-gate.test.ts b/packages/node-runtime/src/ai/__tests__/chart-schema-gate.test.ts new file mode 100644 index 000000000..020c80668 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/chart-schema-gate.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import type { AgentTool } from '@earendil-works/pi-agent-core' +import { + CHART_SCHEMA_REQUIRED_MESSAGE, + createChartSchemaGateState, + wrapWithChartSchemaGate, +} from '../chart-schema-gate' + +function createTool(name: string, calls: string[]): AgentTool { + return { + name, + label: name, + description: name, + parameters: { type: 'object', properties: {}, required: [] }, + async execute() { + calls.push(name) + return { content: [{ type: 'text', text: `${name} ok` }], details: { name } } + }, + } +} + +describe('chart schema gate', () => { + it('blocks render_chart until get_schema has been called in the same tool set', async () => { + const calls: string[] = [] + const state = createChartSchemaGateState() + const renderChart = wrapWithChartSchemaGate(createTool('render_chart', calls), state) + + const result = await renderChart.execute('call-1', {}) + + assert.deepEqual(calls, []) + assert.deepEqual(result.content, [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }]) + assert.equal(result.details, null) + }) + + it('allows render_chart after get_schema has been called', async () => { + const calls: string[] = [] + const state = createChartSchemaGateState() + const getSchema = wrapWithChartSchemaGate(createTool('get_schema', calls), state) + const renderChart = wrapWithChartSchemaGate(createTool('render_chart', calls), state) + + await getSchema.execute('call-1', {}) + const result = await renderChart.execute('call-2', {}) + + assert.deepEqual(calls, ['get_schema', 'render_chart']) + assert.deepEqual(result.content, [{ type: 'text', text: 'render_chart ok' }]) + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/chats.test.ts b/packages/node-runtime/src/ai/__tests__/chats.test.ts new file mode 100644 index 000000000..856f50204 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/chats.test.ts @@ -0,0 +1,637 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import Database from 'better-sqlite3' +import { AIChatManager } from '../chats' +import type { ChartPayload } from '@openchatlab/core' + +const sqliteNativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING + +function createTempDir(): string { + return mkdtempSync(join(tmpdir(), 'chatlab-ai-conv-')) +} + +function createTestDatabase(filename: string): Database.Database { + return sqliteNativeBinding ? new Database(filename, { nativeBinding: sqliteNativeBinding }) : new Database(filename) +} + +function createManager(dir: string): AIChatManager { + return sqliteNativeBinding ? new AIChatManager(dir, { nativeBinding: sqliteNativeBinding }) : new AIChatManager(dir) +} + +function cleanup(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } catch { + // Windows can hold SQLite WAL handles briefly after close; temp cleanup is best-effort. + } +} + +describe('AIChatManager legacy migration', () => { + it('creates ai_chat schema for fresh databases', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('session-1', 'Fresh', 'general_cn') + manager.addMessage(conv.id, 'user', 'hello') + manager.close() + + const db = createTestDatabase(join(dir, 'conversations.db')) + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as Array<{ + name: string + }> + const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' ORDER BY name").all() as Array<{ + name: string + }> + const messageColumns = db.pragma('table_info(ai_message)') as Array<{ name: string }> + + assert.ok(tables.some((table) => table.name === 'ai_chat')) + assert.ok(tables.some((table) => table.name === 'ai_message')) + assert.ok(indexes.some((index) => index.name === 'idx_ai_chat_session')) + assert.ok(indexes.some((index) => index.name === 'idx_ai_message_ai_chat')) + assert.ok(messageColumns.some((column) => column.name === 'ai_chat_id')) + assert.equal( + (db.prepare('SELECT session_id as sessionId FROM ai_chat WHERE id = ?').get(conv.id) as { sessionId: string }) + .sessionId, + 'session-1' + ) + db.close() + } finally { + cleanup(dir) + } + }) + + it('migrates legacy ai_conversation rows into ai_chat', () => { + const dir = createTempDir() + try { + const db = createTestDatabase(join(dir, 'conversations.db')) + db.exec(` + CREATE TABLE ai_conversation ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + title TEXT, + assistant_id TEXT DEFAULT 'general_cn', + active_message_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE ai_message ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + data_keywords TEXT, + data_message_count INTEGER, + content_blocks TEXT, + token_usage TEXT, + debug_context TEXT, + parent_id TEXT, + sibling_group_id TEXT, + branch_index INTEGER DEFAULT 0 + ); + CREATE INDEX idx_ai_conversation_session ON ai_conversation(session_id); + CREATE INDEX idx_ai_message_conversation ON ai_message(conversation_id); + `) + db.prepare('INSERT INTO ai_conversation VALUES (?, ?, ?, ?, NULL, ?, ?)').run( + 'conv-migrate', + 'session-1', + 'Legacy AI Chat', + 'general_cn', + 1, + 3 + ) + db.prepare('INSERT INTO ai_message VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)').run( + 'lm1', + 'conv-migrate', + 'user', + 'first', + 1 + ) + db.prepare('INSERT INTO ai_message VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)').run( + 'lm2', + 'conv-migrate', + 'assistant', + 'second', + 2 + ) + db.close() + + const manager = createManager(dir) + const messages = manager.getMessages('conv-migrate') + assert.deepEqual( + messages.map((message) => message.content), + ['first', 'second'] + ) + manager.close() + + const migrated = createTestDatabase(join(dir, 'conversations.db')) + const tables = migrated + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as Array<{ name: string }> + const messageColumns = migrated.pragma('table_info(ai_message)') as Array<{ name: string }> + const chat = migrated.prepare('SELECT id, session_id as sessionId, title FROM ai_chat').get() as { + id: string + sessionId: string + title: string + } + const messageRows = migrated + .prepare('SELECT id, ai_chat_id as aiChatId, content FROM ai_message ORDER BY timestamp ASC') + .all() as Array<{ id: string; aiChatId: string; content: string }> + + assert.ok(tables.some((table) => table.name === 'ai_chat')) + assert.ok(!tables.some((table) => table.name === 'ai_conversation')) + assert.ok(messageColumns.some((column) => column.name === 'ai_chat_id')) + assert.ok(!messageColumns.some((column) => column.name === 'conversation_id')) + assert.deepEqual(chat, { id: 'conv-migrate', sessionId: 'session-1', title: 'Legacy AI Chat' }) + assert.deepEqual( + messageRows.map((row) => ({ aiChatId: row.aiChatId, content: row.content })), + [ + { aiChatId: 'conv-migrate', content: 'first' }, + { aiChatId: 'conv-migrate', content: 'second' }, + ] + ) + migrated.close() + } finally { + cleanup(dir) + } + }) + + it('migrates legacy flat messages into an active path', () => { + const dir = createTempDir() + try { + const db = createTestDatabase(join(dir, 'conversations.db')) + db.exec(` + CREATE TABLE ai_conversation ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + title TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE ai_message ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + data_keywords TEXT, + data_message_count INTEGER, + content_blocks TEXT + ); + `) + db.prepare('INSERT INTO ai_conversation VALUES (?, ?, ?, ?, ?)').run('conv-1', 'session-1', 'Legacy', 1, 4) + db.prepare('INSERT INTO ai_message VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL)').run( + 'm1', + 'conv-1', + 'user', + 'one', + 1 + ) + db.prepare('INSERT INTO ai_message VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL)').run( + 'm2', + 'conv-1', + 'assistant', + 'two', + 2 + ) + db.close() + + const manager = createManager(dir) + const messages = manager.getMessages('conv-1') + assert.deepEqual( + messages.map((message) => message.content), + ['one', 'two'] + ) + assert.equal(messages[0]?.parentId, null) + assert.equal(messages[1]?.parentId, 'm1') + assert.equal(manager.getAIChat('conv-1')?.activeMessageId, 'm2') + manager.close() + } finally { + cleanup(dir) + } + }) + + it('repairs partially migrated legacy rows that already have tree columns', () => { + const dir = createTempDir() + try { + const db = createTestDatabase(join(dir, 'conversations.db')) + db.exec(` + CREATE TABLE ai_conversation ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + title TEXT, + assistant_id TEXT DEFAULT 'general_cn', + active_message_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE ai_message ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + data_keywords TEXT, + data_message_count INTEGER, + content_blocks TEXT, + token_usage TEXT, + debug_context TEXT, + parent_id TEXT, + sibling_group_id TEXT, + branch_index INTEGER DEFAULT 0 + ); + `) + db.prepare('INSERT INTO ai_conversation VALUES (?, ?, ?, ?, NULL, ?, ?)').run( + 'conv-partial', + 'session-1', + 'Partial', + 'general_cn', + 1, + 3 + ) + db.prepare('INSERT INTO ai_message VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)').run( + 'pm1', + 'conv-partial', + 'user', + 'first', + 1 + ) + db.prepare('INSERT INTO ai_message VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)').run( + 'pm2', + 'conv-partial', + 'assistant', + 'second', + 2 + ) + db.close() + + const manager = createManager(dir) + const messages = manager.getMessages('conv-partial') + assert.deepEqual( + messages.map((message) => message.content), + ['first', 'second'] + ) + assert.equal(messages[0]?.parentId, null) + assert.equal(messages[1]?.parentId, 'pm1') + assert.equal(manager.getAIChat('conv-partial')?.activeMessageId, 'pm2') + manager.close() + } finally { + cleanup(dir) + } + }) +}) + +describe('AIChatManager message editing', () => { + it('updateMessageContent updates message text in place', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'Test', 'general_cn') + const msg = manager.addMessage(conv.id, 'user', 'original text') + manager.addMessage(conv.id, 'assistant', 'reply') + + manager.updateMessageContent(msg.id, 'edited text') + + const messages = manager.getMessages(conv.id) + assert.equal(messages[0]?.content, 'edited text') + assert.equal(messages[1]?.content, 'reply') + manager.close() + } finally { + cleanup(dir) + } + }) + + it('updateMessageContent throws for non-existent message', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + assert.throws(() => manager.updateMessageContent('non-existent', 'text'), /Message not found/) + manager.close() + } finally { + cleanup(dir) + } + }) + + it('deleteAndRelinkMessage removes a message and rewires children', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'Test', 'general_cn') + const userMsg = manager.addMessage(conv.id, 'user', 'question') + const aiMsg = manager.addMessage(conv.id, 'assistant', 'answer') + const followUp = manager.addMessage(conv.id, 'user', 'follow up') + manager.addMessage(conv.id, 'assistant', 'follow answer') + + manager.deleteAndRelinkMessage(conv.id, aiMsg.id) + + const messages = manager.getMessages(conv.id) + assert.equal(messages.length, 3) + assert.deepEqual( + messages.map((m) => m.content), + ['question', 'follow up', 'follow answer'] + ) + // follow up's parent should now be userMsg (was aiMsg) + assert.equal(messages[1]?.parentId, userMsg.id) + assert.equal(messages[2]?.parentId, followUp.id) + manager.close() + } finally { + cleanup(dir) + } + }) + + it('deleteAndRelinkMessage updates activeMessageId when removing the active leaf', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'Test', 'general_cn') + const userMsg = manager.addMessage(conv.id, 'user', 'question') + const aiMsg = manager.addMessage(conv.id, 'assistant', 'answer') + assert.equal(manager.getAIChat(conv.id)?.activeMessageId, aiMsg.id) + + manager.deleteAndRelinkMessage(conv.id, aiMsg.id) + + assert.equal(manager.getAIChat(conv.id)?.activeMessageId, userMsg.id) + const messages = manager.getMessages(conv.id) + assert.equal(messages.length, 1) + assert.equal(messages[0]?.content, 'question') + manager.close() + } finally { + cleanup(dir) + } + }) + + it('insertMessageAfter inserts a message in the middle of a chain', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'Test', 'general_cn') + const userMsg = manager.addMessage(conv.id, 'user', 'question') + const followUp = manager.addMessage(conv.id, 'user', 'follow up') + manager.addMessage(conv.id, 'assistant', 'follow answer') + + const inserted = manager.insertMessageAfter(conv.id, userMsg.id, 'assistant', 'inserted answer') + + const messages = manager.getMessages(conv.id) + assert.equal(messages.length, 4) + assert.deepEqual( + messages.map((m) => m.content), + ['question', 'inserted answer', 'follow up', 'follow answer'] + ) + assert.equal(inserted.parentId, userMsg.id) + // followUp's parent should be updated to the inserted message + assert.equal(messages[2]?.id, followUp.id) + assert.equal(messages[2]?.parentId, inserted.id) + manager.close() + } finally { + cleanup(dir) + } + }) + + it('insertMessageAfter appends to the end when no child exists', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'Test', 'general_cn') + const userMsg = manager.addMessage(conv.id, 'user', 'question') + + const inserted = manager.insertMessageAfter(conv.id, userMsg.id, 'assistant', 'new answer') + + const messages = manager.getMessages(conv.id) + assert.equal(messages.length, 2) + assert.deepEqual( + messages.map((m) => m.content), + ['question', 'new answer'] + ) + assert.equal(inserted.parentId, userMsg.id) + manager.close() + } finally { + cleanup(dir) + } + }) +}) + +describe('AIChatManager chart content blocks', () => { + it('persists normalized chart payloads for stable replay', () => { + const dir = createTempDir() + try { + const chart: ChartPayload = { + version: 1, + spec: { + version: 1, + type: 'bar', + title: 'Messages by member', + encoding: { x: 'name', y: 'message_count' }, + }, + dataset: { + columns: [ + { name: 'name', type: 'category' }, + { name: 'message_count', type: 'integer' }, + ], + rows: [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + ], + }, + data: { + labels: ['Alice', 'Bob'], + values: [4, 3], + }, + rowCount: 2, + } + + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'Chart Replay', 'general_cn') + manager.addMessage(conv.id, 'user', 'draw a chart') + manager.addMessage(conv.id, 'assistant', 'Here is the chart.', undefined, undefined, [{ type: 'chart', chart }]) + manager.close() + + const reloaded = createManager(dir) + const messages = reloaded.getMessages(conv.id) + const block = messages[1]?.contentBlocks?.[0] + + if (!block || block.type !== 'chart') { + assert.fail('Expected a chart content block') + } + assert.deepEqual(block?.chart, chart) + reloaded.close() + } finally { + cleanup(dir) + } + }) +}) + +describe('AIChatManager deleteMessagesFrom', () => { + it('deletes the target message and all subsequent messages', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'Test', 'general_cn') + const user1 = manager.addMessage(conv.id, 'user', 'q1') + manager.addMessage(conv.id, 'assistant', 'a1') + manager.addMessage(conv.id, 'user', 'q2') + manager.addMessage(conv.id, 'assistant', 'a2') + + manager.deleteMessagesFrom(conv.id, user1.id) + + const messages = manager.getMessages(conv.id) + assert.equal(messages.length, 0) + assert.equal(manager.getAIChat(conv.id)?.activeMessageId, null) + manager.close() + } finally { + cleanup(dir) + } + }) + + it('deletes from a mid-chain message, preserving earlier ones', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'Test', 'general_cn') + const user1 = manager.addMessage(conv.id, 'user', 'q1') + const ai1 = manager.addMessage(conv.id, 'assistant', 'a1') + manager.addMessage(conv.id, 'user', 'q2') + manager.addMessage(conv.id, 'assistant', 'a2') + + manager.deleteMessagesFrom(conv.id, ai1.id) + + const messages = manager.getMessages(conv.id) + assert.equal(messages.length, 1) + assert.equal(messages[0]?.content, 'q1') + assert.equal(manager.getAIChat(conv.id)?.activeMessageId, user1.id) + manager.close() + } finally { + cleanup(dir) + } + }) +}) + +describe('AIChatManager forkAIChat', () => { + it('creates a new conversation with copied messages up to the specified point', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'Original', 'general_cn') + manager.addMessage(conv.id, 'user', 'q1') + manager.addMessage(conv.id, 'assistant', 'a1') + const q2 = manager.addMessage(conv.id, 'user', 'q2') + manager.addMessage(conv.id, 'assistant', 'a2') + + const forked = manager.forkAIChat(conv.id, q2.id, 'Forked') + + assert.notEqual(forked.id, conv.id) + assert.equal(forked.title, 'Forked') + assert.equal(forked.sessionId, 's1') + + const forkedMessages = manager.getMessages(forked.id) + assert.equal(forkedMessages.length, 3) + assert.deepEqual( + forkedMessages.map((m) => m.content), + ['q1', 'a1', 'q2'] + ) + + // Original conversation should be untouched + const originalMessages = manager.getMessages(conv.id) + assert.equal(originalMessages.length, 4) + manager.close() + } finally { + cleanup(dir) + } + }) + + it('uses default fork title when none provided', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'MyChat', 'general_cn') + manager.addMessage(conv.id, 'user', 'q1') + const a1 = manager.addMessage(conv.id, 'assistant', 'a1') + + const forked = manager.forkAIChat(conv.id, a1.id) + + assert.equal(forked.title, 'MyChat (fork)') + const forkedMessages = manager.getMessages(forked.id) + assert.equal(forkedMessages.length, 2) + assert.deepEqual( + forkedMessages.map((m) => m.content), + ['q1', 'a1'] + ) + manager.close() + } finally { + cleanup(dir) + } + }) +}) + +describe('AIChatManager getHistoryForAgent content blocks', () => { + it('returns persisted contentBlocks so tool calls can be replayed', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'History', 'general_cn') + manager.addMessage(conv.id, 'user', 'find birthday messages') + manager.addMessage(conv.id, 'assistant', 'Searching… found 3.', undefined, undefined, [ + { type: 'text', text: 'Searching… ' }, + { + type: 'tool', + tool: { + name: 'search_messages', + displayName: 'search_messages', + status: 'done', + params: { query: 'birthday' }, + toolCallId: 'call_xyz', + result: 'found 3 messages', + }, + }, + { type: 'text', text: 'found 3.' }, + ]) + + const history = manager.getHistoryForAgent(conv.id) + assert.equal(history.length, 2) + assert.equal(history[0].contentBlocks, undefined) + const blocks = history[1].contentBlocks + assert.ok(blocks, 'assistant message should carry contentBlocks') + const toolBlock = blocks.find((b) => b.type === 'tool') + assert.ok(toolBlock && toolBlock.type === 'tool') + assert.equal(toolBlock.tool.toolCallId, 'call_xyz') + assert.equal(toolBlock.tool.result, 'found 3 messages') + manager.close() + } finally { + cleanup(dir) + } + }) + + it('keeps contentBlocks on buffer messages after a summary boundary', () => { + const dir = createTempDir() + try { + const manager = createManager(dir) + const conv = manager.createAIChat('s1', 'Summary', 'general_cn') + manager.addMessage(conv.id, 'user', 'old question') + manager.addMessage(conv.id, 'assistant', 'old answer') + // 边界之后的消息(带工具块)应保留 contentBlocks + const late = manager.addMessage(conv.id, 'assistant', 'late answer', undefined, undefined, [ + { + type: 'tool', + tool: { name: 'search_messages', displayName: 's', status: 'done', toolCallId: 'call_late', result: 'r' }, + }, + { type: 'text', text: 'late answer' }, + ]) + const boundaryTs = Math.floor(Date.now() / 1000) + 100 + manager.executeAiSQL(`UPDATE ai_message SET timestamp = ${boundaryTs} WHERE id = '${late.id}'`) + manager.addSummaryMessage(conv.id, 'summary of old context', { + bufferBoundaryTimestamp: boundaryTs, + compressedMessageCount: 2, + }) + + const history = manager.getHistoryForAgent(conv.id) + assert.equal(history[0].role, 'summary') + const lateMsg = history.find((m) => m.content === 'late answer') + assert.ok(lateMsg?.contentBlocks?.some((b) => b.type === 'tool')) + manager.close() + } finally { + cleanup(dir) + } + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts b/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts new file mode 100644 index 000000000..e42935f02 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from '../llm-builder' + +describe('normalizeAnthropicBaseUrl', () => { + it('strips trailing /v1', () => { + assert.equal(normalizeAnthropicBaseUrl('https://api.anthropic.com/v1'), 'https://api.anthropic.com') + }) + + it('strips trailing /v1/', () => { + assert.equal(normalizeAnthropicBaseUrl('https://api.anthropic.com/v1/'), 'https://api.anthropic.com') + }) + + it('leaves URL without /v1 unchanged', () => { + assert.equal(normalizeAnthropicBaseUrl('https://api.anthropic.com'), 'https://api.anthropic.com') + }) +}) + +describe('normalizeOpenAICompatibleBaseUrl', () => { + it('appends /v1 when path is empty', () => { + assert.equal(normalizeOpenAICompatibleBaseUrl('https://api.example.com'), 'https://api.example.com/v1') + }) + + it('appends /v1 when path is /', () => { + assert.equal(normalizeOpenAICompatibleBaseUrl('https://api.example.com/'), 'https://api.example.com/v1') + }) + + it('does not append when already ends with /v1', () => { + assert.equal(normalizeOpenAICompatibleBaseUrl('https://api.example.com/v1'), 'https://api.example.com/v1') + }) + + it('does not modify URLs with custom paths', () => { + assert.equal(normalizeOpenAICompatibleBaseUrl('https://api.example.com/proxy'), 'https://api.example.com/proxy') + }) + + it('returns empty string for empty input', () => { + assert.equal(normalizeOpenAICompatibleBaseUrl(''), '') + }) +}) + +describe('buildPiModel', () => { + it('builds a Google Generative AI model', () => { + const model = buildPiModel({ + provider: 'gemini', + model: 'gemini-2.0-flash', + baseUrl: 'https://generativelanguage.googleapis.com', + }) + assert.equal(model.api, 'google-generative-ai') + assert.equal(model.provider, 'google') + assert.equal(model.id, 'gemini-2.0-flash') + }) + + it('builds an Anthropic model with normalized URL', () => { + const model = buildPiModel({ + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + baseUrl: 'https://api.anthropic.com/v1', + }) + assert.equal(model.api, 'anthropic-messages') + assert.equal(model.baseUrl, 'https://api.anthropic.com') + }) + + it('builds an OpenAI model with default apiFormat', () => { + const model = buildPiModel({ + provider: 'openai', + model: 'gpt-4o', + baseUrl: 'https://api.openai.com/v1', + }) + assert.equal(model.api, 'openai-completions') + }) + + it('normalizes openai-compatible URLs', () => { + const model = buildPiModel({ + provider: 'openai-compatible', + model: 'custom-model', + baseUrl: 'https://my-gateway.example.com', + apiFormat: 'openai-completions', + }) + assert.equal(model.baseUrl, 'https://my-gateway.example.com/v1') + }) + + it('passes custom headers via options', () => { + const model = buildPiModel( + { provider: 'openai-compatible', model: 'test', baseUrl: 'https://test.com/v1' }, + { headers: { 'X-Custom': 'value' } } + ) + assert.deepEqual(model.headers, { 'X-Custom': 'value' }) + }) + + it('auto-infers reasoning=true for catalog models with reasoning capability', () => { + // o3 is in the ChatLab catalog with capabilities: ['chat', 'reasoning', ...] + const model = buildPiModel({ + provider: 'openai', + model: 'o3', + baseUrl: 'https://api.openai.com/v1', + }) + assert.equal(model.reasoning, true) + // o-series uses reasoning_effort without thinkingLevelMap + assert.equal((model.compat as Record | undefined)?.supportsReasoningEffort, true) + assert.equal((model.compat as Record | undefined)?.thinkingLevelMap, undefined) + }) + + it('auto-infers reasoning=false and compat=undefined for non-reasoning OpenAI model', () => { + // gpt-4o is not a reasoning model; no enable_thinking should ever be injected + const model = buildPiModel({ + provider: 'openai', + model: 'gpt-4o', + baseUrl: 'https://api.openai.com/v1', + }) + assert.equal(model.reasoning, false) + assert.equal(model.compat, undefined) + }) + + it('auto-infers reasoning=true and compat.thinkingFormat=qwen for official qwen reasoning model', () => { + // qwen3-max is in the qwen provider catalog with reasoning capability + const model = buildPiModel({ + provider: 'qwen', + model: 'qwen3-max', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + }) + assert.equal(model.reasoning, true) + assert.deepEqual(model.compat, { thinkingFormat: 'qwen' }) + }) + + it('auto-infers reasoning=true and compat.thinkingFormat=qwen for custom self-hosted qwen3 model', () => { + // Custom model not in catalog — falls back to name heuristic + const model = buildPiModel( + { provider: 'openai-compatible', model: 'qwen3:8b', baseUrl: 'http://localhost:11434/v1' }, + { findModelFn: () => null } + ) + assert.equal(model.reasoning, true) + assert.deepEqual(model.compat, { thinkingFormat: 'qwen' }) + }) + + it('auto-infers reasoning=true for custom model saved with only capabilities:["chat"]', () => { + // Regression: ensureCustomProvidersAndModels saves user-added models with capabilities:['chat'] + // only. The heuristic must still fire for non-builtin model definitions so that thinking + // level selections are not silently ignored in the UI. + const model = buildPiModel( + { provider: 'openai-compatible', model: 'qwen3:8b', baseUrl: 'http://localhost:11434/v1' }, + { + findModelFn: () => ({ + id: 'qwen3:8b', + providerId: 'openai-compatible', + name: 'qwen3:8b', + contextWindow: 32768, + capabilities: ['chat'] as const, + recommendedFor: ['chat'] as const, + status: 'stable' as const, + builtin: false, + editable: true, + }), + } + ) + assert.equal(model.reasoning, true) + assert.deepEqual(model.compat, { thinkingFormat: 'qwen' }) + }) + + it('auto-infers reasoning=false and compat=undefined for non-reasoning Anthropic model', () => { + const model = buildPiModel({ + provider: 'openai-compatible', + model: 'claude-3-5-sonnet', + baseUrl: 'https://api.anthropic.com/v1', + }) + assert.equal(model.reasoning, false) + assert.equal(model.compat, undefined) + }) + + it('uses thinkingFormat:deepseek (not supportsReasoningEffort) for deepseek-v4', () => { + const model = buildPiModel({ + provider: 'deepseek', + model: 'deepseek-v4-pro', + baseUrl: 'https://api.deepseek.com/v1', + }) + assert.equal(model.reasoning, true) + const compat = model.compat as Record | undefined + // Must use thinkingFormat:'deepseek' so that 'off' sends thinking:{type:"disabled"} + assert.equal(compat?.thinkingFormat, 'deepseek') + assert.equal(compat?.supportsReasoningEffort, undefined) + // thinkingLevelMap is on the model top-level (for clampThinkingLevel) + const map = model.thinkingLevelMap as Record | undefined + assert.equal(map?.high, 'high') + assert.equal(map?.xhigh, 'max') + assert.equal(map?.minimal, null) + }) + + it('uses custom findModelFn for context window', () => { + const model = buildPiModel( + { provider: 'openai', model: 'custom', baseUrl: 'https://api.openai.com/v1' }, + { + findModelFn: (_providerId, _modelId) => ({ + id: 'custom', + providerId: 'openai', + name: 'Custom', + contextWindow: 32000, + capabilities: ['chat'] as const, + recommendedFor: ['chat'] as const, + status: 'stable' as const, + builtin: false, + editable: true, + }), + } + ) + assert.equal(model.contextWindow, 32000) + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts b/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts new file mode 100644 index 000000000..80bb66511 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts @@ -0,0 +1,396 @@ +import { describe, it, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { LLMConfigStore, type ConfigStorage, type AIConfigStore, type AIServiceConfig } from '../llm-config-store' + +function createMemoryStorage(): ConfigStorage & { data: Map } { + const data = new Map() + return { + data, + readJson(key: string): T | null { + return (data.get(key) as T) ?? null + }, + writeJson(key: string, value: T): void { + data.set(key, JSON.parse(JSON.stringify(value))) + }, + } +} + +describe('LLMConfigStore', () => { + let storage: ReturnType + let store: LLMConfigStore + let idCounter: number + + beforeEach(() => { + storage = createMemoryStorage() + idCounter = 0 + store = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + }) + }) + + it('returns empty store when no data', () => { + const all = store.getAllConfigs() + assert.equal(all.length, 0) + assert.equal(store.hasActiveConfig(), false) + }) + + it('adds a config', () => { + const result = store.addConfig({ + name: 'Test', + provider: 'openai', + apiKey: 'sk-test', + model: 'gpt-4', + }) + assert.ok(result.success) + assert.equal(result.config!.id, 'id-1') + assert.equal(result.config!.name, 'Test') + assert.equal(store.getAllConfigs().length, 1) + }) + + it('sets first config as default assistant', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k', model: 'gpt-4' }) + const slot = store.getDefaultAssistantSlot() + assert.ok(slot) + assert.equal(slot!.configId, 'id-1') + assert.equal(slot!.modelId, 'gpt-4') + }) + + it('updates a config', () => { + store.addConfig({ name: 'Old', provider: 'openai', apiKey: 'k' }) + const result = store.updateConfig('id-1', { name: 'New' }) + assert.ok(result.success) + const config = store.getConfigById('id-1') + assert.equal(config!.name, 'New') + }) + + it('returns error when updating non-existent config', () => { + const result = store.updateConfig('nope', { name: 'X' }) + assert.equal(result.success, false) + assert.ok(result.error) + }) + + it('deletes a config', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k' }) + store.addConfig({ name: 'B', provider: 'openai', apiKey: 'k' }) + const result = store.deleteConfig('id-1') + assert.ok(result.success) + assert.equal(store.getAllConfigs().length, 1) + }) + + it('reassigns default assistant after deleting current default', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k', model: 'gpt-4' }) + store.addConfig({ name: 'B', provider: 'openai', apiKey: 'k', model: 'gpt-3' }) + store.deleteConfig('id-1') + const slot = store.getDefaultAssistantSlot() + assert.equal(slot!.configId, 'id-2') + }) + + it('sets and retrieves fast model', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k', model: 'gpt-4' }) + store.setFastModel({ configId: 'id-1', modelId: 'gpt-3.5' }) + const config = store.getFastModelConfig() + assert.ok(config) + assert.equal(config!.model, 'gpt-3.5') + }) + + it('fast model falls back to default when null', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k', model: 'gpt-4' }) + store.setFastModel(null) + const config = store.getFastModelConfig() + assert.ok(config) + assert.equal(config!.model, 'gpt-4') + }) + + it('strips apiKey when saving', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'secret' }) + const raw = storage.data.get('llm-config') as AIConfigStore + assert.equal(raw.configs[0].apiKey, '') + }) + + it('respects MAX_CONFIG_COUNT', () => { + for (let i = 0; i < 99; i++) { + store.addConfig({ name: `C${i}`, provider: 'openai', apiKey: 'k' }) + } + const result = store.addConfig({ name: 'Overflow', provider: 'openai', apiKey: 'k' }) + assert.equal(result.success, false) + assert.ok(result.error) + }) + + it('calls onApiKeyCreated when adding config with key', () => { + const captured: Array<{ name: string; key: string }> = [] + const storeWithHook = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + onApiKeyCreated: (config, apiKey) => { + captured.push({ name: config.name, key: apiKey }) + }, + }) + storeWithHook.addConfig({ name: 'Hooked', provider: 'openai', apiKey: 'my-key' }) + assert.equal(captured.length, 1) + assert.equal(captured[0].name, 'Hooked') + assert.equal(captured[0].key, 'my-key') + }) + + it('persists authProfile from onApiKeyCreated return value on addConfig', () => { + const storeWithAuth = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + onApiKeyCreated: (config) => { + return config.name.toLowerCase().replace(/\s+/g, '-') + }, + resolveApiKey: (_provider, authProfile) => { + if (authProfile === 'my-openai') return 'resolved-key' + return undefined + }, + }) + + storeWithAuth.addConfig({ name: 'My OpenAI', provider: 'openai', apiKey: 'sk-secret' }) + + const raw = storage.data.get('llm-config') as AIConfigStore + const savedConfig = raw.configs[0] as unknown as Record + assert.equal(savedConfig.authProfile, 'my-openai') + assert.equal(savedConfig.apiKey, '', 'apiKey should be cleared on disk') + + const loaded = storeWithAuth.getAllConfigs() + assert.equal(loaded[0].apiKey, 'resolved-key', 'resolveApiKey should use authProfile') + }) + + it('persists authProfile on updateConfig when key changes', () => { + const storeWithAuth = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + onApiKeyCreated: (config) => { + return config.name.toLowerCase().replace(/\s+/g, '-') + }, + }) + + storeWithAuth.addConfig({ name: 'Test Config', provider: 'openai', apiKey: 'sk-old' }) + storeWithAuth.updateConfig('id-1', { apiKey: 'sk-new' }) + + const raw = storage.data.get('llm-config') as AIConfigStore + const savedConfig = raw.configs[0] as unknown as Record + assert.equal(savedConfig.authProfile, 'test-config') + }) + + it('addConfig returns config with apiKey cleared', () => { + const result = store.addConfig({ name: 'A', provider: 'openai', apiKey: 'sk-secret' }) + assert.ok(result.success) + assert.equal(result.config!.apiKey, '', 'returned config should not leak apiKey') + }) + + it('calls onApiKeyDeleted with the deleted config when deleteConfig is called', () => { + const deleted: AIServiceConfig[] = [] + const storeWithHook = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + onApiKeyCreated: (config) => { + const name = config.name.toLowerCase().replace(/\s+/g, '-') + ;(config as unknown as Record).authProfile = name + return name + }, + onApiKeyDeleted: (config) => { + deleted.push(config) + }, + }) + storeWithHook.addConfig({ name: 'My Service', provider: 'openai', apiKey: 'sk-test' }) + const id = storeWithHook.getAllConfigs()[0].id + storeWithHook.deleteConfig(id) + assert.equal(deleted.length, 1, 'onApiKeyDeleted should be called once') + assert.equal( + (deleted[0] as unknown as Record).authProfile, + 'my-service', + 'deleted config should retain authProfile for cleanup' + ) + assert.equal(storeWithHook.getAllConfigs().length, 0, 'config should be removed') + }) + + it('does not call onApiKeyDeleted when another config still uses the same authProfile', () => { + const deleted: string[] = [] + const storeWithHook = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + onApiKeyCreated: (config) => { + const name = config.name.toLowerCase().replace(/\s+/g, '-') + ;(config as unknown as Record).authProfile = name + return name + }, + onApiKeyDeleted: (config) => { + const profile = (config as unknown as Record).authProfile as string | undefined + if (profile) deleted.push(profile) + }, + }) + + storeWithHook.addConfig({ name: 'OpenAI', provider: 'openai', apiKey: 'sk-first' }) + storeWithHook.addConfig({ name: 'OpenAI', provider: 'openai', apiKey: 'sk-second' }) + + storeWithHook.deleteConfig('id-1') + + assert.deepEqual(deleted, [], 'shared authProfile should stay available for remaining configs') + assert.equal( + (storage.data.get('llm-config') as AIConfigStore).configs.length, + 1, + 'only the deleted config should be removed' + ) + }) + + it('does not delete provider fallback profile still used by migrated legacy configs', () => { + const deleted: string[] = [] + storage.data.set('llm-config', { + configs: [ + { + id: 'legacy-openai', + name: 'Legacy OpenAI', + provider: 'openai', + apiKey: '', + createdAt: 1, + updatedAt: 1, + }, + { + id: 'explicit-openai', + name: 'Explicit OpenAI', + provider: 'openai', + apiKey: '', + authProfile: 'openai', + createdAt: 2, + updatedAt: 2, + }, + ], + defaultAssistant: { configId: 'legacy-openai', modelId: '' }, + fastModel: null, + } as unknown as AIConfigStore) + + const storeWithHook = new LLMConfigStore(storage, { + onApiKeyDeleted: (config) => { + const profile = (config as unknown as Record).authProfile as string | undefined + if (profile) deleted.push(profile) + }, + }) + + storeWithHook.deleteConfig('explicit-openai') + + assert.deepEqual(deleted, [], 'provider fallback profile should stay available for legacy config') + }) + + it('calls onApiKeyDeleted for old profile when rename + key change causes profile name to change', () => { + const deleted: string[] = [] + const storeWithHook = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + onApiKeyCreated: (config, _apiKey) => { + const name = config.name.toLowerCase().replace(/\s+/g, '-') + ;(config as unknown as Record).authProfile = name + return name + }, + onApiKeyDeleted: (config) => { + const profile = (config as unknown as Record).authProfile as string | undefined + if (profile) deleted.push(profile) + }, + }) + storeWithHook.addConfig({ name: 'My OpenAI', provider: 'openai', apiKey: 'sk-old' }) + const id = storeWithHook.getAllConfigs()[0].id + storeWithHook.updateConfig(id, { name: 'Work OpenAI', apiKey: 'sk-new' }) + assert.deepEqual(deleted, ['my-openai'], 'old profile should be cleaned up when profile name changes') + }) + + it('cleans old profile when profile replacement hook does not mutate the updated config', () => { + const deleted: string[] = [] + const storeWithHook = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + onApiKeyCreated: (config, _apiKey) => { + return config.name.toLowerCase().replace(/\s+/g, '-') + }, + onApiKeyDeleted: (config) => { + const profile = (config as unknown as Record).authProfile as string | undefined + if (profile) deleted.push(profile) + }, + }) + + storeWithHook.addConfig({ name: 'My OpenAI', provider: 'openai', apiKey: 'sk-old' }) + const id = storeWithHook.getAllConfigs()[0].id + + storeWithHook.updateConfig(id, { name: 'Work OpenAI', apiKey: 'sk-new' }) + + assert.deepEqual(deleted, ['my-openai'], 'old profile should be cleaned up when no remaining config uses it') + }) + + it('does not delete old profile when saving updated config fails', () => { + const deleted: string[] = [] + let failWrites = false + const failingStorage: ConfigStorage = { + readJson(key: string): T | null { + return (storage.data.get(key) as T) ?? null + }, + writeJson(key: string, value: T): void { + if (failWrites) throw new Error('disk full') + storage.data.set(key, JSON.parse(JSON.stringify(value))) + }, + } + storage.data.set('llm-config', { + configs: [ + { + id: 'cfg-1', + name: 'Old OpenAI', + provider: 'openai', + apiKey: '', + authProfile: 'old-openai', + createdAt: 1, + updatedAt: 1, + }, + ], + defaultAssistant: { configId: 'cfg-1', modelId: '' }, + fastModel: null, + } as unknown as AIConfigStore) + const storeWithHook = new LLMConfigStore(failingStorage, { + onApiKeyCreated: (config) => { + return config.name.toLowerCase().replace(/\s+/g, '-') + }, + onApiKeyDeleted: (config) => { + const profile = (config as unknown as Record).authProfile as string | undefined + if (profile) deleted.push(profile) + }, + }) + + failWrites = true + assert.throws(() => storeWithHook.updateConfig('cfg-1', { name: 'New OpenAI', apiKey: 'sk-new' }), /disk full/) + + assert.deepEqual(deleted, [], 'old profile should remain when config persistence fails') + }) + + it('does not call onApiKeyDeleted when key changes but profile name stays the same', () => { + const deleted: string[] = [] + const storeWithHook = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + onApiKeyCreated: (config, _apiKey) => { + const name = config.name.toLowerCase().replace(/\s+/g, '-') + ;(config as unknown as Record).authProfile = name + return name + }, + onApiKeyDeleted: (config) => { + const profile = (config as unknown as Record).authProfile as string | undefined + if (profile) deleted.push(profile) + }, + }) + storeWithHook.addConfig({ name: 'My OpenAI', provider: 'openai', apiKey: 'sk-old' }) + const id = storeWithHook.getAllConfigs()[0].id + storeWithHook.updateConfig(id, { apiKey: 'sk-new' }) + assert.deepEqual(deleted, [], 'no cleanup needed when profile name is unchanged') + }) + + it('resolves correct key for same-provider configs with different authProfiles', () => { + const profiles = new Map() + const storeWithAuth = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + onApiKeyCreated: (config, apiKey) => { + const profileName = config.name.toLowerCase().replace(/\s+/g, '-') + profiles.set(profileName, apiKey) + return profileName + }, + resolveApiKey: (_provider, authProfile) => { + if (authProfile) return profiles.get(authProfile) + return undefined + }, + }) + + storeWithAuth.addConfig({ name: 'Work OpenAI', provider: 'openai', apiKey: 'sk-work' }) + storeWithAuth.addConfig({ name: 'Personal OpenAI', provider: 'openai', apiKey: 'sk-personal' }) + + const configs = storeWithAuth.getAllConfigs() + assert.equal(configs.length, 2) + assert.equal(configs[0].apiKey, 'sk-work') + assert.equal(configs[1].apiKey, 'sk-personal') + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/skill-manager-core.test.ts b/packages/node-runtime/src/ai/__tests__/skill-manager-core.test.ts new file mode 100644 index 000000000..096cac8c2 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/skill-manager-core.test.ts @@ -0,0 +1,183 @@ +import { describe, it, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { SkillManagerCore, type SkillManagerFs, type SkillManagerCoreDeps } from '../skill-manager-core' + +function createMemoryFs(): SkillManagerFs & { files: Map } { + const files = new Map() + return { + files, + ensureDir: () => { + /* no-op */ + }, + listFiles: (_dir, ext) => + Array.from(files.keys()) + .filter((f) => f.endsWith(ext)) + .map((f) => f.split('/').pop()!), + readFile: (p) => { + const content = files.get(p) + if (!content) throw new Error(`File not found: ${p}`) + return content + }, + writeFile: (p, content) => files.set(p, content), + deleteFile: (p) => files.delete(p), + fileExists: (p) => files.has(p), + joinPath: (...parts) => parts.join('/'), + } +} + +const SAMPLE_SKILL = `--- +id: test_skill +name: Test Skill +description: A test skill +tags: + - test +chatScope: all +tools: [] +--- +You are a test skill.` + +const TOOL_SKILL = `--- +id: tool_skill +name: Tool Skill +description: A skill that requires an analysis tool +tags: + - test +chatScope: all +tools: + - keyword_frequency +--- +Use keyword frequency.` + +function createManager(builtins?: Array<{ id: string; content: string }>) { + const memFs = createMemoryFs() + const deps: SkillManagerCoreDeps = { + fs: memFs, + skillsDir: '/data/skills', + builtinRawSkills: builtins || [], + } + return { manager: new SkillManagerCore(deps), fs: memFs } +} + +describe('SkillManagerCore', () => { + let manager: SkillManagerCore + + beforeEach(() => { + const ctx = createManager() + manager = ctx.manager + }) + + it('initializes with empty catalog', () => { + const result = manager.init() + assert.equal(result.total, 0) + }) + + it('creates a skill from raw markdown', () => { + manager.init() + const result = manager.createSkill(SAMPLE_SKILL) + assert.ok(result.success) + assert.equal(result.id, 'test_skill') + assert.equal(manager.getAllSkills().length, 1) + }) + + it('getSkillConfig returns full def', () => { + manager.init() + manager.createSkill(SAMPLE_SKILL) + const def = manager.getSkillConfig('test_skill') + assert.ok(def) + assert.equal(def!.name, 'Test Skill') + assert.equal(def!.description, 'A test skill') + }) + + it('updates a skill', () => { + manager.init() + manager.createSkill(SAMPLE_SKILL) + const updatedMd = SAMPLE_SKILL.replace('Test Skill', 'Updated Skill') + const result = manager.updateSkill('test_skill', updatedMd) + assert.ok(result.success) + assert.equal(manager.getSkillConfig('test_skill')!.name, 'Updated Skill') + }) + + it('deletes a skill', () => { + manager.init() + manager.createSkill(SAMPLE_SKILL) + const result = manager.deleteSkill('test_skill') + assert.ok(result.success) + assert.equal(manager.getSkillConfig('test_skill'), null) + }) + + it('returns error for non-existent skill ops', () => { + manager.init() + assert.equal(manager.updateSkill('nope', 'x').success, false) + assert.equal(manager.deleteSkill('nope').success, false) + }) + + it('imports from raw markdown (cloud)', () => { + manager.init() + const result = manager.importSkillFromMd(SAMPLE_SKILL) + assert.ok(result.success) + assert.equal(result.id, 'test_skill') + }) + + it('rejects duplicate cloud import', () => { + manager.init() + manager.importSkillFromMd(SAMPLE_SKILL) + const result = manager.importSkillFromMd(SAMPLE_SKILL) + assert.equal(result.success, false) + }) + + it('getSkillMenu returns null when no skills', () => { + manager.init() + assert.equal(manager.getSkillMenu('group'), null) + }) + + it('getSkillMenu returns menu text', () => { + manager.init() + manager.createSkill(SAMPLE_SKILL) + const menu = manager.getSkillMenu('group') + assert.ok(menu) + assert.ok(menu!.includes('test_skill')) + assert.ok(menu!.includes('Test Skill')) + }) + + it('getSkillMenu filters by chatScope', () => { + manager.init() + const groupOnly = SAMPLE_SKILL.replace('chatScope: all', 'chatScope: group') + manager.createSkill(groupOnly) + assert.ok(manager.getSkillMenu('group')) + assert.equal(manager.getSkillMenu('private'), null) + }) + + it('getSkillMenu treats an empty allowedTools list as no analysis tools allowed', () => { + manager.init() + manager.createSkill(TOOL_SKILL) + + assert.equal(manager.getSkillMenu('group', []), null) + assert.match(manager.getSkillMenu('group', ['keyword_frequency']) ?? '', /tool_skill/) + }) +}) + +describe('SkillManagerCore with builtins', () => { + it('imports builtin skill', () => { + const { manager } = createManager([{ id: 'builtin_1', content: SAMPLE_SKILL }]) + manager.init() + const result = manager.importSkill('builtin_1') + assert.ok(result.success) + assert.equal(manager.getAllSkills().length, 1) + }) + + it('shows builtin catalog', () => { + const { manager } = createManager([{ id: 'builtin_1', content: SAMPLE_SKILL }]) + manager.init() + const catalog = manager.getBuiltinCatalog() + assert.equal(catalog.length, 1) + assert.equal(catalog[0].imported, false) + }) + + it('marks imported builtin in catalog', () => { + const { manager } = createManager([{ id: 'builtin_1', content: SAMPLE_SKILL }]) + manager.init() + manager.importSkill('builtin_1') + const catalog = manager.getBuiltinCatalog() + assert.equal(catalog[0].imported, true) + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/skill-manager.test.ts b/packages/node-runtime/src/ai/__tests__/skill-manager.test.ts new file mode 100644 index 000000000..4c43d3e5c --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/skill-manager.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { SkillManager } from '../skill-manager' + +const SAMPLE_SKILL = `--- +id: legacy_skill +name: Legacy Skill +description: Loaded through the legacy SkillManager API +tags: + - test +chatScope: group +tools: + - keyword_frequency +--- +You are a legacy skill.` + +const tempDirs: string[] = [] + +function createTempAiDataDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-skill-manager-')) + tempDirs.push(dir) + return dir +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('SkillManager legacy adapter', () => { + it('creates the skills directory during initialization', () => { + const aiDataDir = createTempAiDataDir() + const manager = new SkillManager(aiDataDir) + + const result = manager.init() + + assert.equal(result.total, 0) + assert.equal(existsSync(join(aiDataDir, 'skills')), true) + }) + + it('keeps the existing runtime query API while delegating to the core manager', () => { + const aiDataDir = createTempAiDataDir() + const skillsDir = join(aiDataDir, 'skills') + const manager = new SkillManager(aiDataDir) + manager.init() + writeFileSync(join(skillsDir, 'legacy_skill.md'), SAMPLE_SKILL, 'utf-8') + manager.init() + + const menu = manager.getSkillMenu('group', ['keyword_frequency']) + + assert.ok(menu) + assert.match(menu, /legacy_skill/) + assert.equal(manager.getSkillConfig('legacy_skill')?.name, 'Legacy Skill') + assert.equal(manager.getAllSkills().length, 1) + assert.equal(manager.getSkillMenu('private', ['keyword_frequency']), null) + }) +}) diff --git a/packages/node-runtime/src/ai/__tests__/skill-menu.test.ts b/packages/node-runtime/src/ai/__tests__/skill-menu.test.ts new file mode 100644 index 000000000..9e0ee1d03 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/skill-menu.test.ts @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { appendSkillMenuLines, buildSkillMenuText, formatSkillMenuLine } from '../skill-menu' + +describe('skill menu builder', () => { + it('returns null when there are no visible skill items', () => { + assert.equal(buildSkillMenuText([]), null) + }) + + it('builds the shared auto skill menu template', () => { + const menu = buildSkillMenuText([ + formatSkillMenuLine({ + id: 'summary', + name: 'Summary', + description: 'Summarize the current chat', + }), + ]) + + assert.ok(menu) + assert.match(menu, /^## 可用技能/) + assert.match(menu, /activate_skill 工具激活/) + assert.match(menu, /- summary: Summary — Summarize the current chat/) + assert.match(menu, /如果用户的问题不需要使用技能,直接回答即可。$/) + }) + + it('appends generated skill lines before the shared closing guidance', () => { + const baseMenu = buildSkillMenuText([ + formatSkillMenuLine({ + id: 'existing', + name: 'Existing Skill', + description: 'Existing description', + }), + ]) + + const menu = appendSkillMenuLines(baseMenu, [ + formatSkillMenuLine({ + id: 'chart_runtime', + name: '绘图助手', + description: '按本轮问题生成灵活的聊天数据图表', + }), + ]) + + assert.ok(menu) + assert.ok(menu.indexOf('existing') < menu.indexOf('chart_runtime')) + assert.ok(menu.indexOf('chart_runtime') < menu.indexOf('如果用户的问题不需要使用技能')) + }) +}) diff --git a/packages/node-runtime/src/ai/activate-skill-tool.ts b/packages/node-runtime/src/ai/activate-skill-tool.ts new file mode 100644 index 000000000..4b7aac889 --- /dev/null +++ b/packages/node-runtime/src/ai/activate-skill-tool.ts @@ -0,0 +1,100 @@ +/** + * activate_skill 元工具(AI 自选模式专用) + * + * LLM 判断用户问题适合某个技能时调用此工具,获取技能的完整执行指导。 + * 平台无关 — 通过 getter 函数获取技能定义。 + */ + +import type { SkillDef } from './types' + +export interface ActivateSkillToolResult { + content: Array<{ type: 'text'; text: string }> + details: Record +} + +export interface ActivateSkillToolOptions { + chatType: 'group' | 'private' + allowedTools?: string[] + coreToolNames?: Set + locale?: string + getSkillConfig: (id: string) => SkillDef | null +} + +export interface ActivateSkillTool { + name: string + label: string + description: string + parameters: Record + execute: ( + toolCallId: string, + params: unknown, + signal?: AbortSignal, + onUpdate?: unknown + ) => Promise +} + +export function createActivateSkillTool(options: ActivateSkillToolOptions): ActivateSkillTool { + const { chatType, allowedTools, coreToolNames, getSkillConfig, locale = 'zh-CN' } = options + const isZh = locale.startsWith('zh') + + return { + name: 'activate_skill', + label: 'activate_skill', + description: isZh + ? '激活一个分析技能,获取该技能的详细执行指导' + : 'Activate an analysis skill and get its detailed execution instructions', + parameters: { + type: 'object', + properties: { + skill_id: { + type: 'string', + description: isZh ? '技能 ID' : 'Skill ID', + }, + }, + required: ['skill_id'], + }, + execute: async (_toolCallId: string, params: unknown) => { + const toolParams = (params && typeof params === 'object' ? params : {}) as { skill_id?: string } + const skillId = toolParams.skill_id || '' + const skill: SkillDef | null = getSkillConfig(skillId) + if (!skill) { + return { + content: [{ type: 'text' as const, text: isZh ? '技能不存在' : 'Skill not found' }], + details: { skillId, found: false }, + } + } + + if (skill.chatScope !== 'all' && skill.chatScope !== chatType) { + const scopeMsg = isZh + ? `该技能仅适用于${skill.chatScope === 'group' ? '群聊' : '私聊'}场景` + : `This skill is only applicable to ${skill.chatScope === 'group' ? 'group chat' : 'private chat'} scenarios` + return { + content: [{ type: 'text' as const, text: scopeMsg }], + details: { skillId, found: true, applicable: false }, + } + } + + if (skill.tools.length > 0 && allowedTools !== undefined) { + const missing = skill.tools.filter((t) => !(coreToolNames?.has(t) ?? false) && !allowedTools.includes(t)) + if (missing.length > 0) { + const msg = isZh + ? `当前助手缺少该技能所需的工具:${missing.join(', ')}` + : `Current assistant lacks tools required by this skill: ${missing.join(', ')}` + return { + content: [{ type: 'text' as const, text: msg }], + details: { skillId, found: true, applicable: false, missingTools: missing }, + } + } + } + + const actionPrompt = isZh + ? '\n\n[System]: 你已成功加载该技能手册。现在,请立即、自动地开始执行步骤1,调用相关的基础数据工具,不要等待用户的进一步确认!' + : '\n\n[System]: You have successfully loaded this skill manual. Now, immediately start executing step 1 by calling the relevant data tools. Do not wait for further user confirmation!' + + return { + content: [{ type: 'text' as const, text: `${skill.prompt}${actionPrompt}` }], + details: { skillId, found: true, applicable: true }, + } + }, + } +} diff --git a/packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts b/packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts new file mode 100644 index 000000000..4bd4398e3 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for DataSnapshot assembly. + * + * Run: npx tsx --test packages/node-runtime/src/ai/agent/__tests__/data-snapshot.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createDataSnapshotFromOverview, formatDataSnapshotForPlanner } from '../data-snapshot' + +describe('createDataSnapshotFromOverview', () => { + it('maps overview members and summary count into an extended data snapshot', () => { + const snapshot = createDataSnapshotFromOverview({ + name: 'Team Chat', + platform: 'wechat', + type: 'group', + totalMessages: 200, + totalMembers: 3, + firstMessageTs: 1735689600, + lastMessageTs: 1767225599, + summaryCount: 7, + topMembers: [ + { id: 1, name: 'Alice', count: 120 }, + { id: 2, name: 'Bob', count: 80 }, + ], + }) + + assert.deepEqual(snapshot, { + version: 2, + name: 'Team Chat', + platform: 'wechat', + type: 'group', + totalMessages: 200, + totalMembers: 3, + firstMessageTs: 1735689600, + lastMessageTs: 1767225599, + activeMemberHints: [ + { memberId: 1, displayName: 'Alice', messageCount: 120, share: 60 }, + { memberId: 2, displayName: 'Bob', messageCount: 80, share: 40 }, + ], + segmentSummaries: { availableCount: 7 }, + }) + }) + + it('keeps all supplied members for small groups and caps large groups at ten hints', () => { + const topMembers = Array.from({ length: 12 }, (_, index) => ({ + id: index + 1, + name: `Member ${index + 1}`, + count: 10, + })) + + const smallGroup = createDataSnapshotFromOverview({ + name: 'Small', + platform: 'wechat', + type: 'group', + totalMessages: 120, + totalMembers: 10, + firstMessageTs: null, + lastMessageTs: null, + topMembers: topMembers.slice(0, 10), + }) + const largeGroup = createDataSnapshotFromOverview({ + name: 'Large', + platform: 'wechat', + type: 'group', + totalMessages: 120, + totalMembers: 12, + firstMessageTs: null, + lastMessageTs: null, + topMembers, + }) + + assert.ok(smallGroup?.activeMemberHints) + assert.ok(largeGroup?.activeMemberHints) + assert.equal(smallGroup.activeMemberHints.length, 10) + assert.equal(largeGroup.activeMemberHints.length, 10) + assert.equal(largeGroup.activeMemberHints.at(-1)?.memberId, 10) + }) + + it('returns a usable snapshot without member hints when overview has no topMembers', () => { + const snapshot = createDataSnapshotFromOverview({ + name: 'No Cache', + platform: 'wechat', + type: 'group', + totalMessages: 0, + totalMembers: 0, + firstMessageTs: null, + lastMessageTs: null, + }) + + assert.ok(snapshot?.segmentSummaries) + assert.equal(snapshot.segmentSummaries.availableCount, 0) + assert.deepEqual(snapshot?.activeMemberHints, []) + }) + + it('returns undefined for missing overview', () => { + assert.equal(createDataSnapshotFromOverview(null), undefined) + assert.equal(createDataSnapshotFromOverview(undefined), undefined) + }) + + it('warns the planner about default recent-day tools when data coverage stops before today', () => { + const snapshot = createDataSnapshotFromOverview({ + name: 'Dorm Chat', + platform: 'wechat', + type: 'group', + totalMessages: 100, + totalMembers: 2, + firstMessageTs: 1735689600, + lastMessageTs: 1776051993, + summaryCount: 3, + topMembers: [{ id: 1, name: 'Alice', count: 60 }], + }) + + const formatted = formatDataSnapshotForPlanner(snapshot) + + assert.match(formatted, /real current date/) + assert.match(formatted, /default recent-day tools/) + assert.match(formatted, /database bounds/) + }) +}) diff --git a/packages/node-runtime/src/ai/agent/__tests__/defaults.test.ts b/packages/node-runtime/src/ai/agent/__tests__/defaults.test.ts new file mode 100644 index 000000000..d296473e3 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/__tests__/defaults.test.ts @@ -0,0 +1,9 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { DEFAULT_MAX_TOOL_ROUNDS } from '../constants' + +describe('Agent defaults', () => { + it('uses 20 tool rounds by default', () => { + assert.equal(DEFAULT_MAX_TOOL_ROUNDS, 20) + }) +}) diff --git a/packages/node-runtime/src/ai/agent/__tests__/evaluation-set.test.ts b/packages/node-runtime/src/ai/agent/__tests__/evaluation-set.test.ts new file mode 100644 index 000000000..049a6442f --- /dev/null +++ b/packages/node-runtime/src/ai/agent/__tests__/evaluation-set.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { AI_AGENT_ROUTING_EVALUATION_SET, REQUIRED_EVALUATION_SCENARIOS } from '../evaluation-set' + +describe('AI agent routing evaluation set', () => { + it('keeps a fixed phase-one sample size with stable unique ids', () => { + assert.ok(AI_AGENT_ROUTING_EVALUATION_SET.length >= 20) + assert.ok(AI_AGENT_ROUTING_EVALUATION_SET.length <= 30) + + const ids = new Set(AI_AGENT_ROUTING_EVALUATION_SET.map((item) => item.id)) + assert.equal(ids.size, AI_AGENT_ROUTING_EVALUATION_SET.length) + for (const item of AI_AGENT_ROUTING_EVALUATION_SET) { + assert.match(item.id, /^ai-route-eval-\d{3}$/) + } + }) + + it('covers every expected route and required scenario', () => { + const routes = new Set(AI_AGENT_ROUTING_EVALUATION_SET.map((item) => item.expectedRoute)) + assert.deepEqual(routes, new Set(['direct_response', 'tool_assisted', 'planned_execution'])) + + const scenarios = new Set(AI_AGENT_ROUTING_EVALUATION_SET.flatMap((item) => item.scenarios)) + for (const scenario of REQUIRED_EVALUATION_SCENARIOS) { + assert.ok(scenarios.has(scenario), `missing scenario: ${scenario}`) + } + }) + + it('requires evidence coverage points for planned execution cases only', () => { + for (const item of AI_AGENT_ROUTING_EVALUATION_SET) { + if (item.expectedRoute === 'planned_execution') { + assert.ok(item.expectedEvidenceCoverage.length >= 2, `${item.id} should define evidence coverage`) + } else { + assert.equal(item.expectedEvidenceCoverage.length, 0, `${item.id} should not define evidence coverage`) + } + } + }) + + it('marks baseline collection as pending real-environment execution', () => { + for (const item of AI_AGENT_ROUTING_EVALUATION_SET) { + assert.equal(item.baseline.status, 'pending_real_environment_run') + assert.ok(item.baseline.notes.length > 0) + } + }) +}) diff --git a/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts b/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts new file mode 100644 index 000000000..05b81bc5b --- /dev/null +++ b/packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for shared AgentEventHandler. + * + * Run: npx tsx --test packages/node-runtime/src/ai/agent/__tests__/event-handler.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { AgentEventHandler, estimateTokensFromText } from '../event-handler' +import type { AgentStreamChunk } from '../event-handler' + +describe('estimateTokensFromText', () => { + it('returns 0 for empty text', () => { + assert.equal(estimateTokensFromText(''), 0) + }) + + it('estimates latin text at ~4 chars per token', () => { + const estimate = estimateTokensFromText('hello world, this is a test') + assert.ok(estimate > 0) + assert.ok(estimate < 20) + }) + + it('estimates CJK text at ~1 char per token', () => { + const estimate = estimateTokensFromText('你好世界这是测试') + assert.ok(estimate >= 8) + }) +}) + +describe('AgentEventHandler', () => { + it('tracks tool usage on tool_start events', () => { + const chunks: AgentStreamChunk[] = [] + const handler = new AgentEventHandler({ + onChunk: (c) => chunks.push(c), + context: {}, + systemPrompt: 'test', + }) + + handler.handleCoreEvent( + { type: 'tool_start', toolCallId: 'call_1', toolName: 'search', toolParams: { q: 'test' } }, + [] + ) + assert.deepEqual(handler.toolsUsed, ['search']) + + const startChunk = chunks.find((c) => c.type === 'tool_start') + assert.equal(startChunk?.toolCallId, 'call_1') + }) + + it('updates tool rounds on turn_end', () => { + const handler = new AgentEventHandler({ + onChunk: () => {}, + context: {}, + systemPrompt: 'test', + }) + + handler.handleCoreEvent({ type: 'turn_end', round: 3, hadToolCalls: true }, []) + assert.equal(handler.toolRounds, 3) + }) + + it('cloneUsage returns independent copy', () => { + const handler = new AgentEventHandler({ + onChunk: () => {}, + context: {}, + systemPrompt: 'test', + }) + + handler.handleCoreEvent( + { + type: 'usage_update', + usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150, cacheReadTokens: 30, cacheWriteTokens: 10 }, + }, + [] + ) + + const usage = handler.cloneUsage() + assert.equal(usage.totalTokens, 150) + assert.equal(usage.cacheReadTokens, 30) + assert.equal(usage.cacheWriteTokens, 10) + + handler.handleCoreEvent( + { + type: 'usage_update', + usage: { + promptTokens: 200, + completionTokens: 100, + totalTokens: 300, + cacheReadTokens: 60, + cacheWriteTokens: 20, + }, + }, + [] + ) + assert.equal(usage.totalTokens, 150, 'clone should be independent') + }) + + it('normalizes tool params with context limits', () => { + const handler = new AgentEventHandler({ + onChunk: () => {}, + context: { maxMessagesLimit: 10, timeFilter: { startTs: 100, endTs: 200 } }, + systemPrompt: 'test', + }) + + const params = handler.normalizeToolParams('search_messages', { query: 'test' }) + assert.equal(params.limit, 10) + assert.deepEqual(params._timeFilter, { startTs: 100, endTs: 200 }) + }) + + it('emits content chunks', () => { + const chunks: AgentStreamChunk[] = [] + const handler = new AgentEventHandler({ + onChunk: (c) => chunks.push(c), + context: {}, + systemPrompt: 'test', + }) + + handler.handleCoreEvent({ type: 'content', content: 'Hello' }, []) + const contentChunks = chunks.filter((c) => c.type === 'content') + assert.equal(contentChunks.length, 1) + assert.equal(contentChunks[0].content, 'Hello') + }) + + it('passes chart tool results through unchanged', () => { + const chunks: AgentStreamChunk[] = [] + const handler = new AgentEventHandler({ + onChunk: (c) => chunks.push(c), + context: {}, + systemPrompt: 'test', + }) + const toolResult = { + content: 'Chart generated.', + details: { + chart: { + version: 1, + type: 'pie', + title: 'Selected members', + data: [{ label: 'Alice', value: 3 }], + source: { rowCount: 1, truncated: false }, + }, + }, + } + + handler.handleCoreEvent( + { type: 'tool_end', toolCallId: 'call_chart', toolName: 'render_chart', toolResult, isError: false }, + [] + ) + + const resultChunk = chunks.find((c) => c.type === 'tool_result') + assert.equal(resultChunk?.toolName, 'render_chart') + assert.deepEqual(resultChunk?.toolResult, toolResult) + assert.equal(resultChunk?.toolCallId, 'call_chart') + assert.equal(resultChunk?.toolIsError, false) + }) +}) diff --git a/packages/node-runtime/src/ai/agent/__tests__/history.test.ts b/packages/node-runtime/src/ai/agent/__tests__/history.test.ts new file mode 100644 index 000000000..debf710c2 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/__tests__/history.test.ts @@ -0,0 +1,376 @@ +/** + * Tests for history replay (toPiHistoryMessages). + * + * Regression for: multi-turn conversations lost tool calls/results on reload, + * causing the model to hallucinate instead of re-querying chat data. + * + * Run: npx tsx --test packages/node-runtime/src/ai/agent/__tests__/history.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { toPiHistoryMessages, type ReplayOptions } from '../history' +import type { SimpleHistoryMessage } from '../types' +import type { ContentBlock } from '../../chats' + +function toolBlock(overrides: Partial['tool']> = {}): ContentBlock { + return { + type: 'tool', + tool: { + name: 'search_messages', + displayName: 'search_messages', + status: 'done', + params: { query: 'birthday' }, + toolCallId: 'call_abc', + result: 'found 3 messages', + ...overrides, + }, + } +} + +describe('toPiHistoryMessages — legacy plain history', () => { + it('converts user/assistant text messages without contentBlocks', () => { + const history: SimpleHistoryMessage[] = [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + ] + const out = toPiHistoryMessages(history) + assert.equal(out.length, 2) + assert.equal(out[0].role, 'user') + assert.deepEqual(out[0].content, [{ type: 'text', text: 'hi' }]) + assert.equal(out[1].role, 'assistant') + assert.equal(out[1].role === 'assistant' && out[1].stopReason, 'stop') + }) + + it('converts summary messages to assistant text', () => { + const out = toPiHistoryMessages([{ role: 'summary', content: 'compressed context' }]) + assert.equal(out.length, 1) + assert.equal(out[0].role, 'assistant') + assert.deepEqual(out[0].content, [{ type: 'text', text: 'compressed context' }]) + }) + + it('falls back to plain text when tool blocks lack persisted toolCallId/result (legacy rows)', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'I searched and found things.', + contentBlocks: [ + { type: 'tool', tool: { name: 'search_messages', displayName: 'search', status: 'done' } }, + { type: 'text', text: 'I searched and found things.' }, + ], + }, + ] + const out = toPiHistoryMessages(history) + assert.equal(out.length, 1) + assert.equal(out[0].role, 'assistant') + assert.deepEqual(out[0].content, [{ type: 'text', text: 'I searched and found things.' }]) + }) +}) + +describe('toPiHistoryMessages — tool call replay', () => { + it('replays persisted tool calls as toolCall/toolResult pairs with stable ids', () => { + const history: SimpleHistoryMessage[] = [ + { role: 'user', content: 'what did we say about birthdays?' }, + { + role: 'assistant', + content: 'Let me search. Found it: March 3rd.', + contentBlocks: [ + { type: 'text', text: 'Let me search. ' }, + toolBlock(), + { type: 'text', text: 'Found it: March 3rd.' }, + ], + }, + ] + + const out = toPiHistoryMessages(history) + assert.equal(out.length, 4) + + const [, callMsg, resultMsg, finalMsg] = out + assert.equal(callMsg.role, 'assistant') + if (callMsg.role !== 'assistant') return + assert.equal(callMsg.stopReason, 'toolUse') + assert.deepEqual(callMsg.content, [ + { type: 'text', text: 'Let me search. ' }, + { type: 'toolCall', id: 'call_abc', name: 'search_messages', arguments: { query: 'birthday' } }, + ]) + + assert.equal(resultMsg.role, 'toolResult') + if (resultMsg.role !== 'toolResult') return + assert.equal(resultMsg.toolCallId, 'call_abc') + assert.equal(resultMsg.toolName, 'search_messages') + assert.equal(resultMsg.isError, false) + assert.deepEqual(resultMsg.content, [{ type: 'text', text: 'found 3 messages' }]) + + assert.equal(finalMsg.role, 'assistant') + if (finalMsg.role !== 'assistant') return + assert.equal(finalMsg.stopReason, 'stop') + assert.deepEqual(finalMsg.content, [{ type: 'text', text: 'Found it: March 3rd.' }]) + }) + + it('replays the same toolCallId across repeated conversions (prompt cache stability)', () => { + const history: SimpleHistoryMessage[] = [ + { role: 'assistant', content: 'x', contentBlocks: [toolBlock(), { type: 'text', text: 'x' }] }, + ] + const ids = [toPiHistoryMessages(history), toPiHistoryMessages(history)].map((out) => { + const call = out[0] + if (call.role !== 'assistant') return undefined + const item = call.content.find((c) => c.type === 'toolCall') + return item?.type === 'toolCall' ? item.id : undefined + }) + assert.equal(ids[0], 'call_abc') + assert.equal(ids[0], ids[1]) + }) + + it('every replayed toolCall has a matching toolResult immediately after it', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'multi tool', + contentBlocks: [ + toolBlock({ toolCallId: 'call_1', result: 'r1' }), + toolBlock({ toolCallId: 'call_2', name: 'get_recent_messages', result: 'r2' }), + { type: 'text', text: 'done' }, + ], + }, + ] + const out = toPiHistoryMessages(history) + for (let i = 0; i < out.length; i++) { + const msg = out[i] + if (msg.role !== 'assistant') continue + const calls = msg.content.filter((c) => c.type === 'toolCall') + if (calls.length === 0) continue + assert.equal(calls.length, 1, 'one toolCall per replayed assistant message') + const next = out[i + 1] + assert.equal(next?.role, 'toolResult') + if (next?.role === 'toolResult' && calls[0].type === 'toolCall') { + assert.equal(next.toolCallId, calls[0].id) + } + } + }) + + it('maps error tool blocks to isError tool results', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'failed', + contentBlocks: [ + toolBlock({ status: 'error', isError: true, result: 'Error: query too broad' }), + { type: 'text', text: 'failed' }, + ], + }, + ] + const out = toPiHistoryMessages(history) + const resultMsg = out.find((m) => m.role === 'toolResult') + assert.ok(resultMsg && resultMsg.role === 'toolResult') + assert.equal(resultMsg.isError, true) + }) + + it('skips unfinished tool blocks (running/aborted) so no toolCall is left unanswered', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'partial', + contentBlocks: [ + toolBlock({ toolCallId: 'call_done', result: 'ok' }), + toolBlock({ toolCallId: 'call_pending', status: 'running', result: undefined }), + { type: 'text', text: 'partial' }, + ], + }, + ] + const out = toPiHistoryMessages(history) + const callIds = out + .filter((m) => m.role === 'assistant') + .flatMap((m) => (m.role === 'assistant' ? m.content : [])) + .filter((c) => c.type === 'toolCall') + .map((c) => (c.type === 'toolCall' ? c.id : '')) + assert.deepEqual(callIds, ['call_done']) + const resultCount = out.filter((m) => m.role === 'toolResult').length + assert.equal(resultCount, 1) + }) + + it('strips runtime-injected underscore params from replayed arguments', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'x', + contentBlocks: [ + toolBlock({ params: { query: 'a', _timeFilter: { startTs: 1, endTs: 2 } } }), + { type: 'text', text: 'x' }, + ], + }, + ] + const out = toPiHistoryMessages(history) + const call = out[0] + assert.ok(call.role === 'assistant') + const item = call.content.find((c) => c.type === 'toolCall') + assert.ok(item && item.type === 'toolCall') + assert.deepEqual(item.arguments, { query: 'a' }) + }) + + it('truncates oversized persisted results at replay time', () => { + const huge = 'x'.repeat(10000) + const history: SimpleHistoryMessage[] = [ + { role: 'assistant', content: 'x', contentBlocks: [toolBlock({ result: huge }), { type: 'text', text: 'x' }] }, + ] + const out = toPiHistoryMessages(history) + const resultMsg = out.find((m) => m.role === 'toolResult') + assert.ok(resultMsg && resultMsg.role === 'toolResult') + const text = resultMsg.content[0] + assert.ok(text.type === 'text') + assert.ok(text.text.length < huge.length) + assert.ok(text.text.endsWith('…[truncated]')) + }) + + it('replaces empty results with a placeholder and emits no trailing empty assistant message', () => { + const history: SimpleHistoryMessage[] = [ + { role: 'assistant', content: 'x', contentBlocks: [{ type: 'text', text: 'x' }, toolBlock({ result: '' })] }, + ] + const out = toPiHistoryMessages(history) + const last = out[out.length - 1] + assert.equal(last.role, 'toolResult') + if (last.role === 'toolResult') { + assert.deepEqual(last.content, [{ type: 'text', text: '(empty result)' }]) + } + const emptyAssistant = out.some((m) => m.role === 'assistant' && m.content.length === 0) + assert.equal(emptyAssistant, false) + }) + + it('does not replay think/chart/error blocks without replay options', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'x', + contentBlocks: [ + { type: 'think', tag: 'thinking', text: 'internal reasoning' }, + toolBlock(), + { type: 'error', error: { name: 'E', message: 'boom', stack: null } }, + { type: 'text', text: 'x' }, + ], + }, + ] + const out = toPiHistoryMessages(history) + const hasThinking = out.some( + (m) => m.role === 'assistant' && m.content.some((c) => c.type !== 'text' && c.type !== 'toolCall') + ) + assert.equal(hasThinking, false) + const allText = JSON.stringify(out) + assert.ok(!allText.includes('internal reasoning')) + assert.ok(!allText.includes('boom')) + }) +}) + +describe('toPiHistoryMessages — thinking replay for tool-call turns', () => { + const replayOptions: ReplayOptions = { + modelInfo: { api: 'openai-completions', provider: 'deepseek', id: 'deepseek-v4-pro' }, + thinkingSignature: 'reasoning_content', + } + + it('replays thinking blocks alongside tool calls when thinkingSignature is set', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'result', + contentBlocks: [ + { type: 'think', tag: 'thinking', text: 'I should search for birthday info' }, + toolBlock(), + { type: 'text', text: 'result' }, + ], + }, + ] + const out = toPiHistoryMessages(history, replayOptions) + const assistantToolUse = out.find((m) => m.role === 'assistant' && m.stopReason === 'toolUse') + assert.ok(assistantToolUse && assistantToolUse.role === 'assistant') + const thinkingBlock = assistantToolUse.content.find((c) => c.type === 'thinking') + assert.ok(thinkingBlock && thinkingBlock.type === 'thinking') + assert.equal(thinkingBlock.thinking, 'I should search for birthday info') + assert.equal(thinkingBlock.thinkingSignature, 'reasoning_content') + }) + + it('sets model info on replayed assistant messages', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'x', + contentBlocks: [toolBlock(), { type: 'text', text: 'x' }], + }, + ] + const out = toPiHistoryMessages(history, replayOptions) + const assistantMsg = out.find((m) => m.role === 'assistant') + assert.ok(assistantMsg && assistantMsg.role === 'assistant') + assert.equal(assistantMsg.provider, 'deepseek') + assert.equal(assistantMsg.model, 'deepseek-v4-pro') + }) + + it('does not replay thinking for messages without tool calls', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'just text', + contentBlocks: [ + { type: 'think', tag: 'thinking', text: 'some reasoning' }, + { type: 'text', text: 'just text' }, + ], + }, + ] + const out = toPiHistoryMessages(history, replayOptions) + const allText = JSON.stringify(out) + assert.ok(!allText.includes('some reasoning')) + }) + + it('replays multiple thinking blocks across multi-step tool calls', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'final', + contentBlocks: [ + { type: 'think', tag: 'thinking', text: 'step 1 reasoning' }, + toolBlock({ toolCallId: 'call_1', result: 'r1' }), + { type: 'think', tag: 'thinking', text: 'step 2 reasoning' }, + toolBlock({ toolCallId: 'call_2', name: 'get_recent_messages', result: 'r2' }), + { type: 'think', tag: 'thinking', text: 'final reasoning' }, + { type: 'text', text: 'final' }, + ], + }, + ] + const out = toPiHistoryMessages(history, replayOptions) + + const assistantMsgs = out.filter((m) => m.role === 'assistant') + assert.equal(assistantMsgs.length, 3) + + // First tool-call assistant has step 1 thinking + const first = assistantMsgs[0] + assert.ok(first.role === 'assistant' && first.stopReason === 'toolUse') + const think1 = first.content.find((c) => c.type === 'thinking') + assert.ok(think1 && think1.type === 'thinking') + assert.equal(think1.thinking, 'step 1 reasoning') + + // Second tool-call assistant has step 2 thinking + const second = assistantMsgs[1] + assert.ok(second.role === 'assistant' && second.stopReason === 'toolUse') + const think2 = second.content.find((c) => c.type === 'thinking') + assert.ok(think2 && think2.type === 'thinking') + assert.equal(think2.thinking, 'step 2 reasoning') + + // Final assistant has step 3 thinking + const third = assistantMsgs[2] + assert.ok(third.role === 'assistant' && third.stopReason === 'stop') + const think3 = third.content.find((c) => c.type === 'thinking') + assert.ok(think3 && think3.type === 'thinking') + assert.equal(think3.thinking, 'final reasoning') + }) + + it('skips empty thinking blocks during replay', () => { + const history: SimpleHistoryMessage[] = [ + { + role: 'assistant', + content: 'x', + contentBlocks: [{ type: 'think', tag: 'thinking', text: ' ' }, toolBlock(), { type: 'text', text: 'x' }], + }, + ] + const out = toPiHistoryMessages(history, replayOptions) + const assistantToolUse = out.find((m) => m.role === 'assistant' && m.stopReason === 'toolUse') + assert.ok(assistantToolUse && assistantToolUse.role === 'assistant') + const hasThinking = assistantToolUse.content.some((c) => c.type === 'thinking') + assert.equal(hasThinking, false) + }) +}) diff --git a/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts new file mode 100644 index 000000000..3442ce0bc --- /dev/null +++ b/packages/node-runtime/src/ai/agent/__tests__/planner.test.ts @@ -0,0 +1,274 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from '../planner' +import type { AnalysisPlanSummary } from '../planning-types' + +const baseInput = { + userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化,并举出证据。', + chatType: 'group' as const, + locale: 'zh-CN', + availableTools: ['search_messages', 'get_time_stats', 'get_member_stats'], +} + +const extendedDataSnapshot = { + version: 2 as const, + name: 'Team Chat', + platform: 'wechat', + type: 'group', + totalMessages: 1000, + totalMembers: 12, + firstMessageTs: 1735689600, + lastMessageTs: 1767225599, + activeMemberHints: [{ memberId: 3, displayName: 'Alice', messageCount: 300, share: 30 }], + segmentSummaries: { availableCount: 8 }, +} + +describe('createAnalysisPlanner', () => { + it('parses valid JSON and filters suggested tools to the available set', async () => { + const planner = createAnalysisPlanner({ + complete: async () => + JSON.stringify({ + title: '年度话题趋势分析', + intent: 'trend', + steps: [ + { + goal: '按季度检索代表性话题', + suggestedTools: ['search_messages', 'missing_tool'], + evidenceNeeded: '每个季度的代表消息', + }, + ], + successCriteria: ['覆盖全年', '引用证据'], + }), + }) + + const plan = await planner(baseInput) + + assert.equal(plan?.route, 'planned_execution') + assert.equal(plan?.title, '年度话题趋势分析') + assert.deepEqual(plan?.steps[0]?.suggestedTools, ['search_messages']) + }) + + it('limits steps and success criteria to five items', async () => { + const planner = createAnalysisPlanner({ + complete: async () => + JSON.stringify({ + title: '复杂分析', + intent: 'mixed', + steps: Array.from({ length: 7 }, (_, index) => ({ + goal: `step ${index + 1}`, + suggestedTools: ['search_messages'], + evidenceNeeded: `evidence ${index + 1}`, + })), + successCriteria: ['a', 'b', 'c', 'd', 'e', 'f'], + }), + }) + + const plan = await planner(baseInput) + + assert.equal(plan?.steps.length, 5) + assert.equal(plan?.successCriteria.length, 5) + }) + + it('returns null for invalid planner JSON', async () => { + const planner = createAnalysisPlanner({ + complete: async () => '{"title": "broken", "intent": "unknown", "steps": []}', + }) + + const plan = await planner(baseInput) + + assert.equal(plan, null) + }) + + it('streams planning draft as thinking and final plan text as plan deltas', async () => { + const deltas: string[] = [] + const thinkingDeltas: string[] = [] + const validationDeltas: string[] = [] + let thinkingEnded = false + let validationEnded = false + const planner = createAnalysisPlanner({ + stream: async (_prompt, callbacks) => { + callbacks.onThinkingDelta?.('先判断问题类型。') + callbacks.onThinkingEnd?.(120) + callbacks.onPlanDelta('年度话题趋势分析\n') + callbacks.onPlanDelta('1. 按季度检索代表性话题\n') + const json = JSON.stringify({ + title: '年度话题趋势分析', + intent: 'trend', + steps: [ + { + goal: '按季度检索代表性话题', + suggestedTools: ['search_messages'], + evidenceNeeded: '每个季度的代表消息', + }, + ], + successCriteria: ['覆盖全年'], + }) + callbacks.onValidationDelta?.(json) + callbacks.onValidationEnd?.(80) + return ` +先判断问题类型。 + + +年度话题趋势分析 +1. 按季度检索代表性话题 + + +${json} +` + }, + onPlanDelta: (delta) => deltas.push(delta), + onThinkingDelta: (delta) => thinkingDeltas.push(delta), + onThinkingEnd: () => { + thinkingEnded = true + }, + onValidationDelta: (delta) => validationDeltas.push(delta), + onValidationEnd: () => { + validationEnded = true + }, + }) + + const plan = await planner(baseInput) + + assert.equal(plan?.title, '年度话题趋势分析') + assert.deepEqual(deltas, ['年度话题趋势分析\n', '1. 按季度检索代表性话题\n']) + assert.deepEqual(thinkingDeltas, ['先判断问题类型。']) + assert.equal(validationDeltas.join('').includes('"suggestedTools":["search_messages"]'), true) + assert.equal(thinkingEnded, true) + assert.equal(validationEnded, true) + }) + + it('creates versioned plan content blocks', () => { + const plan: AnalysisPlanSummary = { + version: 1, + title: '年度话题趋势分析', + route: 'planned_execution', + intent: 'trend', + steps: [{ goal: '按季度检索', suggestedTools: ['search_messages'], evidenceNeeded: '季度证据' }], + successCriteria: ['覆盖全年'], + } + + const block = createPlanContentBlock(plan) + + assert.equal(block.type, 'plan') + assert.equal(block.version, 1) + assert.equal(block.status, 'created') + assert.deepEqual(block.plan, plan) + }) + + it('builds soft guidance from plan summaries', () => { + const guidance = buildPlanGuidance({ + version: 1, + title: '年度话题趋势分析', + route: 'planned_execution', + intent: 'trend', + steps: [{ goal: '按季度检索', suggestedTools: ['search_messages'], evidenceNeeded: '季度证据' }], + successCriteria: ['覆盖全年'], + }) + + assert.match(guidance, /年度话题趋势分析/) + assert.match(guidance, /search_messages/) + assert.match(guidance, /覆盖全年/) + }) + + it('includes available capability summaries in the planner prompt', async () => { + let capturedPrompt = '' + const planner = createAnalysisPlanner({ + complete: async (prompt) => { + capturedPrompt = prompt + return JSON.stringify({ + title: '趋势和图表分析', + intent: 'trend', + steps: [ + { + goal: '确认 schema 后生成趋势图', + suggestedTools: ['get_schema', 'render_chart'], + evidenceNeeded: '按季度聚合后的趋势数据', + }, + ], + successCriteria: ['给出趋势证据'], + }) + }, + }) + + const plan = await planner({ + ...baseInput, + availableTools: ['search_messages', 'get_schema', 'render_chart'], + availableCapabilities: [ + { + id: 'chart_generation', + label: '图表生成', + tools: ['get_schema', 'render_chart'], + guidance: '趋势、排名、分布、占比用图更清楚时,可先调用 get_schema,再用 render_chart 生成一张图。', + }, + ], + }) + + assert.equal(plan?.steps[0]?.suggestedTools.includes('render_chart'), true) + assert.match(capturedPrompt, /availableCapabilities/) + assert.match(capturedPrompt, /chart_generation/) + assert.match(capturedPrompt, /get_schema/) + assert.match(capturedPrompt, /render_chart/) + }) + + it('exposes the evidence intent and retrieve_chat_evidence guidance in the prompt', async () => { + let capturedPrompt = '' + const planner = createAnalysisPlanner({ + complete: async (prompt) => { + capturedPrompt = prompt + return JSON.stringify({ + title: '乐山出行次数核实', + intent: 'evidence', + steps: [ + { + goal: '检索乐山实际出行证据并统计次数', + suggestedTools: ['retrieve_chat_evidence', 'missing_tool'], + evidenceNeeded: '到达/住宿/出行相关消息', + }, + ], + successCriteria: ['给出可计入的出行次数'], + }) + }, + }) + + const plan = await planner({ + ...baseInput, + userMessage: '我们去乐山旅行过多少次?用证据检索一下。', + availableTools: ['search_messages', 'semantic_search_current_chat', 'retrieve_chat_evidence'], + }) + + // evidence intent 可解析,幻觉工具被过滤,只保留可用的 retrieve_chat_evidence + assert.equal(plan?.intent, 'evidence') + assert.deepEqual(plan?.steps[0]?.suggestedTools, ['retrieve_chat_evidence']) + assert.match(capturedPrompt, /evidence/) + assert.match(capturedPrompt, /retrieve_chat_evidence/) + }) + + it('includes extended data snapshot context in the planner prompt', async () => { + let capturedPrompt = '' + const planner = createAnalysisPlanner({ + complete: async (prompt) => { + capturedPrompt = prompt + return JSON.stringify({ + title: '年度话题趋势分析', + intent: 'trend', + steps: [ + { + goal: '先建立话题地图和发言规律', + suggestedTools: ['search_messages', 'get_time_stats'], + evidenceNeeded: '摘要、时间分布、成员活跃和代表消息', + }, + ], + successCriteria: ['覆盖真实最近一年'], + }) + }, + }) + + await planner({ ...baseInput, dataSnapshot: extendedDataSnapshot }) + + assert.match(capturedPrompt, /first_message_ts: 1735689600/) + assert.match(capturedPrompt, /last_message_ts: 1767225599/) + assert.match(capturedPrompt, /segment_summaries_available: 8/) + assert.match(capturedPrompt, /member_id=3/) + assert.match(capturedPrompt, /display_name=Alice/) + }) +}) diff --git a/packages/node-runtime/src/ai/agent/__tests__/prompt-builder.test.ts b/packages/node-runtime/src/ai/agent/__tests__/prompt-builder.test.ts new file mode 100644 index 000000000..a0ed7c943 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/__tests__/prompt-builder.test.ts @@ -0,0 +1,180 @@ +/** + * Tests for shared prompt-builder. + * + * Run: npx tsx --test packages/node-runtime/src/ai/agent/__tests__/prompt-builder.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildSystemPrompt } from '../prompt-builder' +import type { TranslateFn } from '../prompt-builder' + +const mockT: TranslateFn = (key, options) => { + if (key.startsWith('ai.agent.fallbackRoleDefinition')) return `[fallback:${key}]` + if (key === 'ai.agent.currentDateIs') return 'Current date is' + if (key === 'ai.agent.ownerNote') return `Owner: ${(options as Record)?.displayName}` + if (key === 'ai.agent.memberNoteGroup') return '[member-group]' + if (key === 'ai.agent.memberNotePrivate') return '[member-private]' + if (key === 'ai.agent.timeParamsIntro') return '[time-params]' + if (key === 'ai.agent.defaultYearNote') return `[year:${(options as Record)?.year}]` + if (key === 'ai.agent.dataSnapshotNote') { + const opts = options as Record + return `[snapshot:${opts.name}:${opts.totalMessages}:${opts.lastMessageDate}]` + } + if (key === 'ai.agent.dataSnapshotContext') { + const opts = options as Record + return `[snapshot-context] +- name: ${opts.name} +- platform: ${opts.platform} +- type: ${opts.type} +- total_messages: ${opts.totalMessages} +- total_members: ${opts.totalMembers} +- first_message_ts: ${opts.firstMessageTs} +- first_message_time: ${opts.firstMessageTime} +- last_message_ts: ${opts.lastMessageTs} +- last_message_time: ${opts.lastMessageTime} +- segment_summaries_available: ${opts.segmentSummaryCount} + +${opts.memberHintTitle} +${opts.memberHintLines} + +${opts.usageRules}` + } + if (key === 'ai.agent.dataSnapshotMemberHintsAll') return '活跃成员查询提示(全部成员):' + if (key === 'ai.agent.dataSnapshotMemberHintsTop') return '活跃成员查询提示(按历史总消息量 Top 10):' + if (key === 'ai.agent.dataSnapshotMemberHintsUnavailable') return '活跃成员查询提示:' + if (key === 'ai.agent.dataSnapshotMemberHintsEmpty') return '无可用成员提示。' + if (key === 'ai.agent.dataSnapshotUsageRules') { + return `- member_id 是工具查询提示;display_name 仅用于人类识别,可能不唯一。 +- 不要在最终回答中主动暴露 member_id 或启动上下文本身,除非用户明确要求技术细节。 +- 活跃成员排行只代表历史总消息量,不代表最近活跃情况。 +- 相对时间表达以真实当前日期为基准,而不是数据库最后消息时间。 +- 不要只为了重新发现 min/max timestamp 调用工具。 +- last_message_time 是数据库中已导入消息的截止时间,不是群组在现实中最后一次发言的时间;不要据此推断群组多久没动静。` + } + if (key === 'ai.agent.evidencePolicy') return '[evidence-policy]' + if (key === 'ai.agent.responseInstruction') return '[response-instruction]' + if (key === 'ai.agent.mentionedMembersNote') return 'Mentioned members:' + if (key === 'ai.agent.currentTask') return 'Current task' + if (key === 'ai.agent.skillPriorityNote') return '[skill-priority]' + return `[${key}]` +} + +describe('buildSystemPrompt', () => { + it('uses fallback role when no assistant prompt', () => { + const result = buildSystemPrompt({ t: mockT, chatType: 'group' }) + assert.ok(result.includes('[fallback:ai.agent.fallbackRoleDefinition.group]')) + }) + + it('uses assistant prompt when provided', () => { + const result = buildSystemPrompt({ t: mockT, assistantSystemPrompt: 'Custom assistant' }) + assert.ok(result.includes('Custom assistant')) + assert.ok(!result.includes('[fallback')) + }) + + it('includes owner info when provided', () => { + const result = buildSystemPrompt({ + t: mockT, + ownerInfo: { displayName: 'Alice', platformId: 'alice123' }, + }) + assert.ok(result.includes('Owner: Alice')) + }) + + it('includes member note for private chat', () => { + const result = buildSystemPrompt({ t: mockT, chatType: 'private' }) + assert.ok(result.includes('[member-private]')) + }) + + it('includes mentioned members', () => { + const result = buildSystemPrompt({ + t: mockT, + mentionedMembers: [{ memberId: 1, platformId: 'p1', displayName: 'Bob', aliases: ['B'], mentionText: '@Bob' }], + }) + assert.ok(result.includes('member_id=1')) + assert.ok(result.includes('aliases=B')) + }) + + it('includes skill definition when active', () => { + const result = buildSystemPrompt({ + t: mockT, + skillCtx: { skillDef: { name: 'Summarizer', prompt: 'Summarize the chat.' } }, + }) + assert.ok(result.includes('Current task')) + assert.ok(result.includes('Summarizer')) + assert.ok(result.includes('Summarize the chat.')) + }) + + it('includes skill menu when no active skill', () => { + const result = buildSystemPrompt({ + t: mockT, + skillCtx: { skillMenu: '[SKILL MENU TEXT]' }, + }) + assert.ok(result.includes('[SKILL MENU TEXT]')) + }) + + it('includes date and response instruction', () => { + const result = buildSystemPrompt({ t: mockT }) + assert.ok(result.includes('Current date is')) + assert.ok(result.includes('[response-instruction]')) + }) + + it('includes evidence policy in locked section', () => { + const result = buildSystemPrompt({ t: mockT }) + assert.ok(result.includes('[evidence-policy]')) + assert.ok(result.indexOf('[evidence-policy]') < result.indexOf('[response-instruction]')) + }) + + it('includes current data snapshot when provided', () => { + const result = buildSystemPrompt({ + t: mockT, + dataSnapshot: { + name: 'Team Chat', + platform: 'wechat', + type: 'group', + totalMessages: 1234, + totalMembers: 56, + firstMessageTs: 1700000000, + lastMessageTs: 1700003600, + capturedAt: 1700007200, + }, + }) + + assert.ok(result.includes('[snapshot-context]')) + assert.ok(result.includes('total_messages: 1234')) + assert.ok(result.includes('segment_summaries_available: 0')) + assert.ok(result.includes('[evidence-policy]')) + }) + + it('renders extended data snapshot startup context and rules', () => { + const result = buildSystemPrompt({ + t: mockT, + locale: 'zh-CN', + dataSnapshot: { + version: 2, + name: 'Team Chat', + platform: 'wechat', + type: 'group', + totalMessages: 100, + totalMembers: 2, + firstMessageTs: 1735689600, + lastMessageTs: 1767225599, + activeMemberHints: [ + { memberId: 1, displayName: 'Alice', messageCount: 60, share: 60 }, + { memberId: 2, displayName: 'Bob', messageCount: 40, share: 40 }, + ], + segmentSummaries: { availableCount: 12 }, + }, + }) + + assert.ok(result.includes('first_message_ts: 1735689600')) + assert.ok(result.includes('last_message_ts: 1767225599')) + assert.ok(result.includes('segment_summaries_available: 12')) + assert.ok(result.includes('member_id=1 | display_name=Alice | messages=60 | share=60%')) + assert.ok(result.includes('member_id=2 | display_name=Bob | messages=40 | share=40%')) + assert.ok(result.includes('历史总消息量')) + assert.ok(result.includes('真实当前日期')) + assert.ok(result.includes('不要只为了重新发现 min/max timestamp 调用工具')) + assert.ok(!result.includes('platform_id=')) + assert.ok(!result.includes('aliases=')) + }) +}) diff --git a/packages/node-runtime/src/ai/agent/__tests__/router.test.ts b/packages/node-runtime/src/ai/agent/__tests__/router.test.ts new file mode 100644 index 000000000..a5bcd5e09 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/__tests__/router.test.ts @@ -0,0 +1,204 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { createLlmRouteDecider, decideRequestRoute } from '../router' +import type { RouteDecision, RouteDecisionSource } from '../routing-types' + +const baseInput = { + userMessage: '', + chatType: 'group' as const, + locale: 'zh-CN', + availableTools: ['get_chat_overview', 'search_messages', 'get_member_stats'], +} + +const extendedDataSnapshot = { + version: 2 as const, + name: 'Team Chat', + platform: 'wechat', + type: 'group', + totalMessages: 1000, + totalMembers: 12, + firstMessageTs: 1735689600, + lastMessageTs: 1767225599, + activeMemberHints: [{ memberId: 3, displayName: 'Alice', messageCount: 300, share: 30 }], + segmentSummaries: { availableCount: 8 }, +} + +describe('decideRequestRoute', () => { + it('routes concept and help questions to direct response by rule', async () => { + const concept = await decideRequestRoute({ + ...baseInput, + userMessage: '解释一下什么是 Function Calling Agent,和 ReACT 有什么区别?', + }) + assert.equal(concept.route, 'direct_response') + assert.equal(concept.source, 'rule') + assert.ok(concept.confidence >= 0.8) + + const help = await decideRequestRoute({ + ...baseInput, + userMessage: 'ChatLab 的 AI 日志在哪里看?', + }) + assert.equal(help.route, 'direct_response') + assert.equal(help.source, 'rule') + }) + + it('routes simple data lookups to tool assisted by rule', async () => { + const decision = await decideRequestRoute({ + ...baseInput, + userMessage: '谁发言最多?给我前 5 名就行。', + }) + + assert.equal(decision.route, 'tool_assisted') + assert.equal(decision.source, 'rule') + assert.ok(decision.confidence >= 0.75) + }) + + it('routes complex evidence-heavy analysis to planned execution by rule', async () => { + const decision = await decideRequestRoute({ + ...baseInput, + userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化,并举出证据。', + }) + + assert.equal(decision.route, 'planned_execution') + assert.equal(decision.source, 'rule') + assert.ok(decision.confidence >= 0.8) + }) + + it('routes open-ended recent-year topic and core-member retrospectives to planned execution by rule', async () => { + const decision = await decideRequestRoute({ + ...baseInput, + userMessage: '请分析这个群最近一年的话题演变和核心成员变化。先建立话题地图和发言规律,再按阶段总结主要变化。', + }) + + assert.equal(decision.route, 'planned_execution') + assert.equal(decision.source, 'rule') + assert.ok(decision.confidence >= 0.8) + }) + + it('uses injected LLM fallback for ambiguous requests', async () => { + const llmDecision: RouteDecision = { + route: 'planned_execution', + confidence: 0.72, + reason: 'LLM detected multiple implicit analysis dimensions.', + source: 'llm', + } + const calls: Array<{ source: RouteDecisionSource; message: string }> = [] + const decision = await decideRequestRoute( + { + ...baseInput, + userMessage: '帮我看一下这个情况。', + }, + { + llmRouter: async (input, ruleDecision) => { + calls.push({ source: ruleDecision.source, message: input.userMessage }) + return llmDecision + }, + } + ) + + assert.deepEqual(decision, llmDecision) + assert.deepEqual(calls, [{ source: 'rule', message: '帮我看一下这个情况。' }]) + }) + + it('falls back conservatively to tool assisted when ambiguous and no LLM router is provided', async () => { + const decision = await decideRequestRoute({ + ...baseInput, + userMessage: '帮我看一下这个情况。', + }) + + assert.equal(decision.route, 'tool_assisted') + assert.equal(decision.source, 'rule') + assert.ok(decision.confidence < 0.6) + assert.match(decision.reason, /ambiguous/i) + }) + + it('creates an LLM fallback decider that parses route JSON', async () => { + const decider = createLlmRouteDecider({ + complete: async () => '{"route":"direct_response","confidence":1.4,"reason":"No local data dependency."}', + }) + + const decision = await decider( + { + ...baseInput, + userMessage: '帮我看一下这个情况。', + }, + { + route: 'tool_assisted', + confidence: 0.45, + reason: 'Ambiguous request.', + source: 'rule', + } + ) + + assert.equal(decision.route, 'direct_response') + assert.equal(decision.source, 'llm') + assert.equal(decision.confidence, 1) + assert.match(decision.reason, /No local data/) + }) + + it('includes compact extended data snapshot context in the LLM fallback prompt', async () => { + let capturedPrompt = '' + const decider = createLlmRouteDecider({ + complete: async (prompt) => { + capturedPrompt = prompt + return '{"route":"planned_execution","confidence":0.8,"reason":"Needs trend analysis."}' + }, + }) + + await decider( + { + ...baseInput, + userMessage: '帮我看一下这个情况。', + dataSnapshot: extendedDataSnapshot, + }, + { + route: 'tool_assisted', + confidence: 0.45, + reason: 'Ambiguous request.', + source: 'rule', + } + ) + + assert.match(capturedPrompt, /total_messages: 1000/) + assert.match(capturedPrompt, /total_members: 12/) + assert.match(capturedPrompt, /first_message_ts: 1735689600/) + assert.match(capturedPrompt, /last_message_ts: 1767225599/) + assert.match(capturedPrompt, /segment_summaries_available: 8/) + assert.match(capturedPrompt, /active_member_hint_count: 1/) + }) + + it('keeps the rule decision when LLM fallback output is invalid', async () => { + const decider = createLlmRouteDecider({ + complete: async () => '{"route":"unknown","confidence":0.9,"reason":"bad"}', + }) + const ruleDecision: RouteDecision = { + route: 'tool_assisted', + confidence: 0.45, + reason: 'Ambiguous request.', + source: 'rule', + } + + const decision = await decider(baseInput, ruleDecision) + + assert.equal(decision.route, 'tool_assisted') + assert.equal(decision.source, 'rule') + assert.match(decision.reason, /invalid/i) + }) + + it('does not mark invalid LLM fallback as an LLM-sourced decision', async () => { + const decision = await decideRequestRoute( + { + ...baseInput, + userMessage: '帮我看一下这个情况。', + }, + { + llmRouter: createLlmRouteDecider({ + complete: async () => '{"route":"unknown","confidence":0.9,"reason":"bad"}', + }), + } + ) + + assert.equal(decision.route, 'tool_assisted') + assert.equal(decision.source, 'rule') + assert.match(decision.reason, /invalid/i) + }) +}) diff --git a/packages/node-runtime/src/ai/agent/constants.ts b/packages/node-runtime/src/ai/agent/constants.ts new file mode 100644 index 000000000..2a7c8ba83 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/constants.ts @@ -0,0 +1 @@ +export const DEFAULT_MAX_TOOL_ROUNDS = 20 diff --git a/packages/node-runtime/src/ai/agent/core.ts b/packages/node-runtime/src/ai/agent/core.ts new file mode 100644 index 000000000..087815f64 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/core.ts @@ -0,0 +1,261 @@ +/** + * Agent Core — 共享的 PiAgentCore 编排逻辑 + * + * 封装:构建 → 历史转换 → 事件订阅 → abort 转发 → prompt 执行 → usage 收集。 + * Server 和 Electron 通过 AgentCoreOptions DI 注入平台差异。 + */ + +import { Agent as PiAgentCore } from '@earendil-works/pi-agent-core' +import type { AgentEvent as PiAgentEvent, AgentMessage as PiAgentMessage } from '@earendil-works/pi-agent-core' +import { + type Message as PiMessage, + type Usage as PiUsage, + streamSimple as defaultStreamSimple, + clampThinkingLevel, +} from '@earendil-works/pi-ai' +import { StreamingThinkTagParser, needsStreamingThinkParsing } from '@openchatlab/core' + +import type { AgentCoreOptions, AgentCoreResult, AgentTokenUsage } from './types' +import { initTokenizer } from '../tokenizer' +import { DEFAULT_MAX_TOOL_ROUNDS } from './constants' +import { toPiHistoryMessages, type ReplayOptions } from './history' + +function isPiMessage(message: PiAgentMessage): message is PiMessage { + return message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult' +} + +export async function runAgentCore(options: AgentCoreOptions): Promise { + const { + piModel, + apiKey, + systemPrompt, + tools, + history, + userMessage, + maxToolRounds = DEFAULT_MAX_TOOL_ROUNDS, + abortSignal, + steerMessage = 'Please provide your final answer based on the information gathered.', + onEvent, + onConvertToLlm, + onDebugContext, + } = options + + // 确保 cl100k rank 表已加载,压缩/预处理路径使用精确 token 计数 + await initTokenizer() + + const resolvedStreamFn = (options.streamFn ?? defaultStreamSimple) as typeof defaultStreamSimple + + const totalUsage: AgentTokenUsage = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } + const toolsUsed: string[] = [] + let toolRounds = 0 + + const addPiUsage = (usage?: PiUsage) => { + if (!usage) return + totalUsage.promptTokens += usage.input || 0 + totalUsage.completionTokens += usage.output || 0 + totalUsage.totalTokens += usage.totalTokens || usage.input + usage.output || 0 + totalUsage.cacheReadTokens += usage.cacheRead || 0 + totalUsage.cacheWriteTokens += usage.cacheWrite || 0 + } + + if (abortSignal?.aborted) { + return { usage: totalUsage, finalMessages: [], toolsUsed: [], toolRounds: 0 } + } + + // Resolve thinkingLevel for pi-agent-core: + // - 'default'/undefined → strip reasoning from piModel so pi-ai sends a plain request + // with NO reasoning params at all (model uses its native default behavior). + // - 'off' → pi-ai sends disable signals (thinking:{type:'disabled'} / enable_thinking:false) + // - 'auto' → for thinkingFormat models or effort-based models with thinkingLevelMap, + // use 'high' to enable; others no params. + // Note: pi-ai's clampThinkingLevel only covers EXTENDED_THINKING_LEVELS (no 'auto'), + // so 'auto' cannot be forwarded verbatim for effort-based models like Kimi/Doubao. + // Returning 'high' ensures reasoning is at least enabled. + // - Other levels → clamp to what pi-agent-core accepts. + const isDefault = !options.thinkingLevel || options.thinkingLevel === 'default' + const effectiveModel = isDefault + ? { ...piModel, reasoning: false, compat: undefined, thinkingLevelMap: undefined } + : piModel + + const resolvedThinkingLevel = (() => { + if (isDefault) return 'off' + const level = options.thinkingLevel! + if (level === 'auto') { + if (!piModel.reasoning) return 'off' + const compat = piModel.compat as Record | undefined + if (compat?.thinkingFormat) return 'high' + // Effort-based reasoning models (e.g., Kimi, Doubao) have a thinkingLevelMap but no + // thinkingFormat. pi-ai's clampThinkingLevel can't pass 'auto' through, so use 'high'. + if (piModel.thinkingLevelMap) return 'high' + return undefined + } + return clampThinkingLevel(piModel, level as Exclude) + })() + + const finalThinkingLevel = resolvedThinkingLevel ?? (piModel.reasoning ? undefined : 'off') + + // DeepSeek-format APIs require reasoning_content on assistant messages that + // precede tool results; build replay options so toPiHistoryMessages includes + // persisted thinking blocks in those messages. + const thinkingFormat = (piModel.compat as Record | undefined)?.thinkingFormat + const replayOptions: ReplayOptions | undefined = + piModel.reasoning && thinkingFormat === 'deepseek' + ? { + modelInfo: { api: piModel.api, provider: piModel.provider, id: piModel.id }, + thinkingSignature: 'reasoning_content', + } + : undefined + + const coreAgent = new PiAgentCore({ + initialState: { + systemPrompt, + model: effectiveModel, + thinkingLevel: finalThinkingLevel, + tools: maxToolRounds > 0 ? tools : [], + messages: toPiHistoryMessages(history, replayOptions), + }, + getApiKey: () => apiKey, + streamFn: resolvedStreamFn, + convertToLlm: (messages) => { + const filtered = messages.filter( + (msg): msg is PiMessage => msg.role === 'user' || msg.role === 'assistant' || msg.role === 'toolResult' + ) + onConvertToLlm?.(filtered) + return filtered + }, + }) + + let hasReachedToolRoundLimit = false + const thinkingStartTime = new Map() + + // For providers that embed tags in content (e.g. MiniMax), + // use a streaming parser to split thinking from content. + const useThinkParser = needsStreamingThinkParsing(piModel.provider, piModel.id) + let thinkParserStartTime: number | undefined + const thinkParser = useThinkParser + ? new StreamingThinkTagParser((ev) => { + switch (ev.type) { + case 'content': + onEvent({ type: 'content', content: ev.content }) + break + case 'thinking_start': + thinkParserStartTime = Date.now() + onEvent({ type: 'thinking_start' }) + break + case 'thinking_delta': + onEvent({ type: 'thinking_delta', content: ev.content }) + break + case 'thinking_end': { + const durationMs = thinkParserStartTime ? Date.now() - thinkParserStartTime : undefined + thinkParserStartTime = undefined + onEvent({ type: 'thinking_end', durationMs }) + break + } + } + }) + : null + + const unsubscribe = coreAgent.subscribe((event: PiAgentEvent) => { + if (event.type === 'message_update') { + const update = event.assistantMessageEvent + if (update.type === 'text_delta') { + if (thinkParser) { + thinkParser.feed(update.delta) + } else { + onEvent({ type: 'content', content: update.delta }) + } + } else if (update.type === 'thinking_start') { + thinkingStartTime.set(update.contentIndex, Date.now()) + onEvent({ type: 'thinking_start' }) + } else if (update.type === 'thinking_delta') { + onEvent({ type: 'thinking_delta', content: update.delta }) + } else if (update.type === 'thinking_end') { + const startedAt = thinkingStartTime.get(update.contentIndex) + const durationMs = startedAt ? Date.now() - startedAt : undefined + thinkingStartTime.delete(update.contentIndex) + onEvent({ type: 'thinking_end', durationMs }) + } + } else if (event.type === 'tool_execution_start') { + toolsUsed.push(event.toolName) + onEvent({ + type: 'tool_start', + toolCallId: event.toolCallId, + toolName: event.toolName, + toolParams: (event.args || {}) as Record, + }) + } else if (event.type === 'tool_execution_end') { + onEvent({ + type: 'tool_end', + toolCallId: event.toolCallId, + toolName: event.toolName, + toolResult: event.result, + isError: event.isError, + }) + } else if (event.type === 'turn_end') { + const hadToolCalls = event.toolResults.length > 0 + if (hadToolCalls) { + toolRounds += 1 + if (!hasReachedToolRoundLimit && maxToolRounds > 0 && toolRounds >= maxToolRounds) { + hasReachedToolRoundLimit = true + coreAgent.state.tools = [] + coreAgent.steer({ + role: 'user', + content: [{ type: 'text', text: steerMessage }], + timestamp: Date.now(), + } as PiMessage) + } + } + onEvent({ type: 'turn_end', round: toolRounds, hadToolCalls }) + } else if (event.type === 'message_end') { + if (event.message.role === 'assistant') { + thinkParser?.flush() + addPiUsage(event.message.usage) + onEvent({ type: 'usage_update', usage: { ...totalUsage } }) + } + } + }) + + const forwardAbort = () => coreAgent.abort() + if (abortSignal) { + abortSignal.addEventListener('abort', forwardAbort, { once: true }) + } + + try { + if (onDebugContext) { + try { + const debugMessages = [ + { role: 'system', content: systemPrompt }, + ...history.map((m) => ({ + role: m.role === 'summary' ? 'assistant' : m.role, + content: m.content, + })), + { role: 'user', content: userMessage }, + ] + onDebugContext(debugMessages) + } catch { + // silent — debug context is best-effort + } + } + + await coreAgent.prompt(userMessage) + + return { + usage: totalUsage, + error: coreAgent.state.errorMessage || undefined, + finalMessages: coreAgent.state.messages.filter(isPiMessage), + toolsUsed: [...toolsUsed], + toolRounds, + } + } finally { + unsubscribe() + if (abortSignal) { + abortSignal.removeEventListener('abort', forwardAbort) + } + } +} diff --git a/packages/node-runtime/src/ai/agent/data-snapshot.ts b/packages/node-runtime/src/ai/agent/data-snapshot.ts new file mode 100644 index 000000000..e1406a887 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/data-snapshot.ts @@ -0,0 +1,98 @@ +import type { DataSnapshot } from './prompt-builder' + +export interface ChatOverviewForSnapshot { + name: string + platform: string + type: string + totalMessages: number + totalMembers: number + firstMessageTs: number | null + lastMessageTs: number | null + topMembers?: Array<{ id: number; name: string; count: number }> + summaryCount?: number +} + +function roundShare(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.round(value * 10) / 10 +} + +function formatNullableTimestamp(timestamp: number | null | undefined): string { + return typeof timestamp === 'number' && Number.isFinite(timestamp) ? String(timestamp) : 'null' +} + +function formatSharePercent(share: number): string { + if (!Number.isFinite(share)) return '0%' + const rounded = Math.round(share * 10) / 10 + return Number.isInteger(rounded) ? `${rounded}%` : `${rounded.toFixed(1)}%` +} + +export function createDataSnapshotFromOverview( + overview: ChatOverviewForSnapshot | null | undefined +): DataSnapshot | undefined { + if (!overview) return undefined + + const topMembers = overview.topMembers ?? [] + + return { + version: 2, + name: overview.name, + platform: overview.platform, + type: overview.type, + totalMessages: overview.totalMessages, + totalMembers: overview.totalMembers, + firstMessageTs: overview.firstMessageTs, + lastMessageTs: overview.lastMessageTs, + activeMemberHints: topMembers.slice(0, 10).map((member) => ({ + memberId: member.id, + displayName: member.name, + messageCount: member.count, + share: overview.totalMessages > 0 ? roundShare((member.count / overview.totalMessages) * 100) : 0, + })), + segmentSummaries: { + availableCount: overview.summaryCount ?? 0, + }, + } +} + +export function formatDataSnapshotForRouter(dataSnapshot: DataSnapshot | undefined): string { + if (!dataSnapshot) return '(none)' + + return [ + `name: ${dataSnapshot.name}`, + `platform: ${dataSnapshot.platform}`, + `type: ${dataSnapshot.type}`, + `total_messages: ${dataSnapshot.totalMessages}`, + `total_members: ${dataSnapshot.totalMembers}`, + `first_message_ts: ${formatNullableTimestamp(dataSnapshot.firstMessageTs)}`, + `last_message_ts: ${formatNullableTimestamp(dataSnapshot.lastMessageTs)}`, + `segment_summaries_available: ${dataSnapshot.segmentSummaries?.availableCount ?? 0}`, + `active_member_hint_count: ${dataSnapshot.activeMemberHints?.length ?? 0}`, + ].join('\n') +} + +export function formatDataSnapshotForPlanner(dataSnapshot: DataSnapshot | undefined): string { + if (!dataSnapshot) return '(none)' + + const memberHints = dataSnapshot.activeMemberHints ?? [] + const memberHintLines = + memberHints.length > 0 + ? memberHints + .map( + (member, index) => + `${index + 1}. member_id=${member.memberId} | display_name=${member.displayName} | messages=${member.messageCount} | share=${formatSharePercent(member.share)}` + ) + .join('\n') + : '(none)' + + return `${formatDataSnapshotForRouter(dataSnapshot)} +active_member_hints: +${memberHintLines} +notes: +- member_id is a tool lookup hint; display_name may not be unique. +- Active member hints reflect historical total message volume, not recent activity. +- Use real current date for relative ranges; database bounds only describe data coverage. +- last_message_ts is the coverage end of the imported data, not the real-world last activity time of the group/chat; the user may not have imported newer records yet. Do not infer group inactivity from this value. +- Do not plan a tool call only to rediscover min/max timestamp. +- When using default recent-day tools, choose a range that intersects database bounds instead of probing an empty real-current-date window first.` +} diff --git a/packages/node-runtime/src/ai/agent/evaluation-set.ts b/packages/node-runtime/src/ai/agent/evaluation-set.ts new file mode 100644 index 000000000..2e13fb1f3 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/evaluation-set.ts @@ -0,0 +1,342 @@ +import type { RequestRoute } from './routing-types' + +export type EvaluationScenario = + | 'casual_chat' + | 'concept_explanation' + | 'help_or_configuration' + | 'simple_data_query' + | 'simple_search' + | 'long_range_trend_analysis' + | 'key_member_influence' + | 'relationship_analysis' + | 'topic_evolution' + | 'search_failure_recovery' + | 'multi_condition_filter' + | 'member_lookup_then_messages' + | 'insufficient_evidence' + +export interface RouteEvaluationCase { + id: string + locale: 'zh-CN' | 'en-US' + chatType: 'group' | 'private' + userMessage: string + expectedRoute: RequestRoute + scenarios: EvaluationScenario[] + expectedEvidenceCoverage: string[] + baseline: { + status: 'pending_real_environment_run' + notes: string + } +} + +export const REQUIRED_EVALUATION_SCENARIOS: readonly EvaluationScenario[] = [ + 'casual_chat', + 'concept_explanation', + 'help_or_configuration', + 'simple_data_query', + 'simple_search', + 'long_range_trend_analysis', + 'key_member_influence', + 'relationship_analysis', + 'topic_evolution', + 'search_failure_recovery', + 'multi_condition_filter', + 'member_lookup_then_messages', + 'insufficient_evidence', +] + +const pendingBaseline = { + status: 'pending_real_environment_run', + notes: 'Requires a configured LLM service and a real or fixture chat database; do not fake baseline results.', +} as const + +export const AI_AGENT_ROUTING_EVALUATION_SET: readonly RouteEvaluationCase[] = [ + { + id: 'ai-route-eval-001', + locale: 'zh-CN', + chatType: 'group', + userMessage: '你觉得我今天应该先整理资料还是先回复消息?', + expectedRoute: 'direct_response', + scenarios: ['casual_chat'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-002', + locale: 'zh-CN', + chatType: 'group', + userMessage: '解释一下什么是 Function Calling Agent,和 ReACT 有什么区别?', + expectedRoute: 'direct_response', + scenarios: ['concept_explanation'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-003', + locale: 'zh-CN', + chatType: 'group', + userMessage: 'ChatLab 的 AI 日志在哪里看?', + expectedRoute: 'direct_response', + scenarios: ['help_or_configuration'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-004', + locale: 'zh-CN', + chatType: 'private', + userMessage: '不用查聊天记录,帮我把这句话润色得自然一点:明天我可能晚点到。', + expectedRoute: 'direct_response', + scenarios: ['casual_chat'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-005', + locale: 'en-US', + chatType: 'group', + userMessage: 'Briefly explain why native function calling is safer than text-formatted actions.', + expectedRoute: 'direct_response', + scenarios: ['concept_explanation'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-006', + locale: 'zh-CN', + chatType: 'group', + userMessage: '帮我看看这个群一共有多少成员、多少条消息。', + expectedRoute: 'tool_assisted', + scenarios: ['simple_data_query'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-007', + locale: 'zh-CN', + chatType: 'group', + userMessage: '昨天大家主要聊了什么?简单概括一下。', + expectedRoute: 'tool_assisted', + scenarios: ['simple_search'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-008', + locale: 'zh-CN', + chatType: 'group', + userMessage: '找一下最近提到“报销”的聊天记录。', + expectedRoute: 'tool_assisted', + scenarios: ['simple_search'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-009', + locale: 'zh-CN', + chatType: 'group', + userMessage: '谁发言最多?给我前 5 名就行。', + expectedRoute: 'tool_assisted', + scenarios: ['simple_data_query'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-010', + locale: 'zh-CN', + chatType: 'private', + userMessage: '我和对方上周有没有聊到“面试”?', + expectedRoute: 'tool_assisted', + scenarios: ['simple_search'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-011', + locale: 'zh-CN', + chatType: 'group', + userMessage: '找一下昵称里带“小王”的成员,然后看他最近 20 条发言。', + expectedRoute: 'tool_assisted', + scenarios: ['member_lookup_then_messages'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-012', + locale: 'zh-CN', + chatType: 'group', + userMessage: '这个群今天凌晨 0 点到 6 点有没有异常活跃?', + expectedRoute: 'tool_assisted', + scenarios: ['simple_data_query'], + expectedEvidenceCoverage: [], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-013', + locale: 'zh-CN', + chatType: 'group', + userMessage: '分析过去一年群里话题的变化趋势,按季度总结主要变化,并举出证据。', + expectedRoute: 'planned_execution', + scenarios: ['long_range_trend_analysis', 'topic_evolution'], + expectedEvidenceCoverage: [ + 'Collect representative messages or summaries across at least three quarterly windows.', + 'Compare topic distribution changes over time rather than summarizing one period only.', + 'Cite concrete message examples or retrieved evidence for each major trend.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-014', + locale: 'zh-CN', + chatType: 'group', + userMessage: '找出这个群里影响力最强的 3 个人,并说明他们分别影响了哪些话题或互动。', + expectedRoute: 'planned_execution', + scenarios: ['key_member_influence'], + expectedEvidenceCoverage: [ + 'Use member activity or interaction statistics to identify candidates.', + 'Verify each candidate with message examples or conversation context.', + 'Separate message volume from influence and state limitations.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-015', + locale: 'zh-CN', + chatType: 'group', + userMessage: '分析 Alice 和 Bob 最近半年的互动关系:谁更主动、回应是否及时、主要聊什么。', + expectedRoute: 'planned_execution', + scenarios: ['relationship_analysis', 'multi_condition_filter'], + expectedEvidenceCoverage: [ + 'Resolve both members before querying their interaction.', + 'Compare initiation, response timing, and topic evidence.', + 'Use a bounded time range and cite representative exchanges.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-016', + locale: 'zh-CN', + chatType: 'group', + userMessage: '复盘今年项目相关讨论:从需求、开发、上线到反馈,每个阶段有哪些关键分歧?', + expectedRoute: 'planned_execution', + scenarios: ['long_range_trend_analysis', 'topic_evolution', 'multi_condition_filter'], + expectedEvidenceCoverage: [ + 'Segment the year into meaningful project phases.', + 'Search project-related terms and inspect context around disagreements.', + 'Connect conclusions to cited messages instead of only reporting counts.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-017', + locale: 'zh-CN', + chatType: 'group', + userMessage: '如果直接搜“团建预算”找不到结果,请换几个相关说法继续查,最后告诉我有没有讨论过。', + expectedRoute: 'planned_execution', + scenarios: ['search_failure_recovery'], + expectedEvidenceCoverage: [ + 'Try the exact query first and record whether it finds evidence.', + 'Retry with semantically related terms such as activity, reimbursement, cost, or venue.', + 'Distinguish no evidence found from evidence of absence.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-018', + locale: 'zh-CN', + chatType: 'group', + userMessage: '筛选去年 9 月到 12 月里,张三参与、和“考试”相关、并且有人回复的问题,总结主要矛盾。', + expectedRoute: 'planned_execution', + scenarios: ['multi_condition_filter', 'member_lookup_then_messages'], + expectedEvidenceCoverage: [ + 'Resolve Zhang San to member identity before filtering messages.', + 'Apply time, topic, participant, and reply/context constraints together.', + 'Summarize only conflicts supported by retrieved conversation context.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-019', + locale: 'zh-CN', + chatType: 'group', + userMessage: '判断大家是不是从 5 月开始明显减少讨论 AI 了;如果证据不够,也请说明不够。', + expectedRoute: 'planned_execution', + scenarios: ['long_range_trend_analysis', 'insufficient_evidence'], + expectedEvidenceCoverage: [ + 'Compare AI-related mentions before and after May with consistent criteria.', + 'Check enough date coverage to avoid overfitting a short window.', + 'Explicitly state uncertainty when message volume or query coverage is insufficient.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-020', + locale: 'zh-CN', + chatType: 'private', + userMessage: '总结我和对方关系的变化:从刚开始聊天到最近,有哪些明显转折点?', + expectedRoute: 'planned_execution', + scenarios: ['relationship_analysis', 'topic_evolution', 'long_range_trend_analysis'], + expectedEvidenceCoverage: [ + 'Sample early, middle, and recent conversation periods.', + 'Identify relationship changes through message context, not only sentiment words.', + 'Cite concrete exchanges for each proposed turning point.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-021', + locale: 'en-US', + chatType: 'group', + userMessage: 'Compare the top discussion themes in the first half and second half of the archive, with evidence.', + expectedRoute: 'planned_execution', + scenarios: ['long_range_trend_analysis', 'topic_evolution'], + expectedEvidenceCoverage: [ + 'Split the archive into two comparable time windows.', + 'Identify themes with retrieval or statistics in both windows.', + 'Provide evidence for differences and avoid unsupported causal claims.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-022', + locale: 'zh-CN', + chatType: 'group', + userMessage: '这个群里有没有人长期被忽略?请结合发言、回复和互动情况分析。', + expectedRoute: 'planned_execution', + scenarios: ['key_member_influence', 'relationship_analysis', 'insufficient_evidence'], + expectedEvidenceCoverage: [ + 'Use interaction or reply statistics to find low-response candidates.', + 'Inspect representative contexts before labeling anyone ignored.', + 'State limitations because absence of replies can be ambiguous.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-023', + locale: 'zh-CN', + chatType: 'group', + userMessage: '最近有没有提过“签证”?如果没有,帮我查查可能相关的“护照”“出行”“材料”。', + expectedRoute: 'planned_execution', + scenarios: ['search_failure_recovery', 'simple_search'], + expectedEvidenceCoverage: [ + 'Search the exact keyword before related alternatives.', + 'Inspect related keyword results for actual relevance.', + 'Report whether evidence is direct, indirect, or absent.', + ], + baseline: pendingBaseline, + }, + { + id: 'ai-route-eval-024', + locale: 'zh-CN', + chatType: 'group', + userMessage: '按月份分析过去一年谁在技术讨论里最活跃,以及他们关注的话题有没有变化。', + expectedRoute: 'planned_execution', + scenarios: ['long_range_trend_analysis', 'key_member_influence', 'multi_condition_filter'], + expectedEvidenceCoverage: [ + 'Filter technical discussions consistently across the full year.', + 'Compare monthly member activity rather than only global totals.', + 'Inspect topic evidence for top members across different months.', + ], + baseline: pendingBaseline, + }, +] diff --git a/packages/node-runtime/src/ai/agent/event-handler.ts b/packages/node-runtime/src/ai/agent/event-handler.ts new file mode 100644 index 000000000..c8fe0e9fa --- /dev/null +++ b/packages/node-runtime/src/ai/agent/event-handler.ts @@ -0,0 +1,254 @@ +/** + * Agent event handler — shared implementation. + * + * Maps AgentCoreEvent → stream chunks, tracks usage/tool rounds, + * estimates context tokens. Platform-agnostic via generic chunk type. + */ + +import type { Message as PiMessage } from '@earendil-works/pi-ai' +import type { AgentCoreEvent } from './types' +import type { PlanContentBlock } from './planning-types' +import type { RouteDecision } from './routing-types' + +// ==================== Shared types ==================== + +export interface TokenUsage { + promptTokens: number + completionTokens: number + totalTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +export interface AgentRuntimeStatus { + phase: 'compressing' | 'preparing' | 'thinking' | 'tool_running' | 'responding' | 'completed' | 'aborted' | 'error' + round: number + toolsUsed: number + currentTool?: string + contextTokens: number + totalUsage: TokenUsage + updatedAt: number +} + +export interface AgentStreamChunk { + type: + | 'content' + | 'think' + | 'tool_start' + | 'tool_result' + | 'status' + | 'compression_done' + | 'route' + | 'plan_delta' + | 'plan' + | 'plan_skipped' + | 'done' + | 'error' + content?: string + thinkTag?: string + thinkDurationMs?: number + toolCallId?: string + toolName?: string + toolParams?: Record + toolResult?: unknown + toolIsError?: boolean + error?: unknown + isFinished?: boolean + usage?: TokenUsage + status?: AgentRuntimeStatus + routeDecision?: RouteDecision + planDelta?: string + plan?: PlanContentBlock + compressionResult?: { + summaryContent: string + tokensBefore: number + tokensAfter: number + timestamp: number + } +} + +export interface EventHandlerContext { + maxMessagesLimit?: number + timeFilter?: { startTs: number; endTs: number } +} + +export interface EventHandlerConfig { + onChunk: (chunk: AgentStreamChunk) => void + context: EventHandlerContext + systemPrompt: string +} + +// ==================== Token estimation ==================== + +function estimateTokensFromText(text: string): number { + if (!text) return 0 + const normalized = text.replace(/\s+/g, ' ').trim() + if (!normalized) return 0 + const cjkCount = (normalized.match(/[\u3400-\u9fff\uf900-\ufaff]/g) || []).length + const latinCount = normalized.length - cjkCount + return Math.max(1, Math.ceil(cjkCount * 1.15 + latinCount / 4)) +} + +function extractMessageText(message: PiMessage): string { + if (message.role === 'user') { + if (typeof message.content === 'string') return message.content + return message.content + .map((item) => { + if (item.type === 'text') return item.text + if (item.type === 'image') return '[image]' + return '' + }) + .join('\n') + } + + if (message.role === 'assistant') { + return message.content + .map((item) => { + if (item.type === 'text') return item.text + if (item.type === 'thinking') return item.thinking + if (item.type === 'toolCall') return `${item.name} ${JSON.stringify(item.arguments || {})}` + return '' + }) + .join('\n') + } + + if (message.role === 'toolResult') { + return message.content + .map((item) => { + if (item.type === 'text') return item.text + return '[binary]' + }) + .join('\n') + } + + return '' +} + +function estimateContextTokens(systemPrompt: string, messages: PiMessage[], pendingUserMessage?: string): number { + let tokens = estimateTokensFromText(systemPrompt) + for (const message of messages) { + if (message.role === 'toolResult') continue + tokens += estimateTokensFromText(extractMessageText(message)) + } + if (pendingUserMessage) { + tokens += estimateTokensFromText(pendingUserMessage) + } + return tokens +} + +// Exported for unit testing +export { estimateTokensFromText } + +// ==================== Event handler class ==================== + +export class AgentEventHandler { + readonly toolsUsed: string[] = [] + toolRounds: number = 0 + + private totalUsage: TokenUsage = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } + private lastStatusAt = 0 + private readonly onChunk: (chunk: AgentStreamChunk) => void + private readonly context: EventHandlerContext + private readonly systemPrompt: string + + constructor(config: EventHandlerConfig) { + this.onChunk = config.onChunk + this.context = config.context + this.systemPrompt = config.systemPrompt + } + + handleCoreEvent(event: AgentCoreEvent, messages: PiMessage[]): void { + switch (event.type) { + case 'content': + this.onChunk({ type: 'content', content: event.content }) + this.emitStatus('responding', messages) + break + case 'thinking_start': + this.emitStatus('thinking', messages, { force: true }) + break + case 'thinking_delta': + this.onChunk({ type: 'think', content: event.content, thinkTag: 'thinking' }) + this.emitStatus('thinking', messages) + break + case 'thinking_end': + this.onChunk({ type: 'think', content: '', thinkTag: 'thinking', thinkDurationMs: event.durationMs }) + this.emitStatus('responding', messages, { force: true }) + break + case 'tool_start': { + const params = this.normalizeToolParams(event.toolName, event.toolParams) + this.toolsUsed.push(event.toolName) + this.onChunk({ type: 'tool_start', toolCallId: event.toolCallId, toolName: event.toolName, toolParams: params }) + this.emitStatus('tool_running', messages, { currentTool: event.toolName, force: true }) + break + } + case 'tool_end': + this.onChunk({ + type: 'tool_result', + toolCallId: event.toolCallId, + toolName: event.toolName, + toolResult: event.toolResult, + toolIsError: event.isError, + }) + this.emitStatus('thinking', messages, { force: true }) + break + case 'turn_end': + this.toolRounds = event.round + this.emitStatus('thinking', messages, { force: true }) + break + case 'usage_update': + this.totalUsage = { ...event.usage } + this.emitStatus('responding', messages, { force: true }) + break + } + } + + cloneUsage(): TokenUsage { + return { + promptTokens: this.totalUsage.promptTokens, + completionTokens: this.totalUsage.completionTokens, + totalTokens: this.totalUsage.totalTokens, + cacheReadTokens: this.totalUsage.cacheReadTokens, + cacheWriteTokens: this.totalUsage.cacheWriteTokens, + } + } + + emitStatus( + phase: AgentRuntimeStatus['phase'], + messages: PiMessage[], + options?: { pendingUserMessage?: string; currentTool?: string; force?: boolean } + ): void { + const now = Date.now() + if (!options?.force && now - this.lastStatusAt < 240) return + this.lastStatusAt = now + + const contextTokens = estimateContextTokens(this.systemPrompt, messages, options?.pendingUserMessage) + const status: AgentRuntimeStatus = { + phase, + round: this.toolRounds, + toolsUsed: this.toolsUsed.length, + currentTool: options?.currentTool, + contextTokens, + totalUsage: this.cloneUsage(), + updatedAt: now, + } + this.onChunk({ type: 'status', status }) + } + + normalizeToolParams(toolName: string, params: Record): Record { + const normalized = { ...params } + const toolsWithLimit = ['search_messages', 'get_recent_messages', 'get_conversation_between'] + if (this.context.maxMessagesLimit && toolsWithLimit.includes(toolName)) { + normalized.limit = this.context.maxMessagesLimit + } + if (this.context.timeFilter && (toolName === 'search_messages' || toolName === 'get_recent_messages')) { + normalized._timeFilter = this.context.timeFilter + } + return normalized + } +} diff --git a/packages/node-runtime/src/ai/agent/history.ts b/packages/node-runtime/src/ai/agent/history.ts new file mode 100644 index 000000000..5b04e4292 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/history.ts @@ -0,0 +1,156 @@ +/** + * History replay — converts persisted chat history into pi messages. + * + * Assistant messages whose content blocks carry persisted tool calls + * (toolCallId + result) are replayed as real toolCall/toolResult message + * pairs, so later turns see what tools were called and what they returned. + * Persisted toolCallIds are replayed verbatim to keep requests byte-stable + * across turns (prompt cache friendly). Legacy messages without tool data + * fall back to plain text replay. + * + * When model info is provided, thinking blocks are replayed alongside tool + * calls so APIs that require reasoning_content on tool-call turns (e.g. + * DeepSeek) receive the original reasoning chain instead of an empty string. + */ + +import type { Message as PiMessage, AssistantMessage, Usage as PiUsage } from '@earendil-works/pi-ai' +import { truncateToolResultText } from '@openchatlab/core' + +import type { SimpleHistoryMessage } from './types' +import type { ContentBlock } from '../chats' + +type ToolBlock = Extract +export type ReplayableToolBlock = ToolBlock & { tool: ToolBlock['tool'] & { toolCallId: string; result: string } } + +/** + * Model metadata attached to replayed assistant messages so pi-ai's + * transform-messages layer treats them as same-model (preserving thinking + * blocks in their native format instead of converting to plain text). + */ +export interface ReplayModelInfo { + api: string + provider: string + id: string +} + +export interface ReplayOptions { + modelInfo?: ReplayModelInfo + /** + * Field name used by the provider's streaming API for reasoning content + * (e.g. "reasoning_content" for DeepSeek). When set and the assistant + * message contains tool calls, persisted think blocks are replayed as + * pi-ai ThinkingContent blocks with this signature. + */ + thinkingSignature?: string +} + +function createEmptyPiUsage(): PiUsage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + } +} + +export function isReplayableToolBlock(block: ContentBlock): block is ReplayableToolBlock { + if (block.type !== 'tool') return false + const { toolCallId, result, status } = block.tool + return ( + typeof toolCallId === 'string' && + toolCallId.length > 0 && + typeof result === 'string' && + (status === 'done' || status === 'error') + ) +} + +/** Drop runtime-injected params (e.g. _timeFilter) that the model never produced. */ +function stripInternalParams(params?: Record): Record { + if (!params) return {} + const cleaned: Record = {} + for (const [key, value] of Object.entries(params)) { + if (!key.startsWith('_')) cleaned[key] = value + } + return cleaned +} + +function makeAssistantMessage( + content: AssistantMessage['content'], + stopReason: 'stop' | 'toolUse', + modelInfo?: ReplayModelInfo +): AssistantMessage { + return { + role: 'assistant', + content, + api: (modelInfo?.api ?? 'openai-completions') as AssistantMessage['api'], + provider: modelInfo?.provider ?? 'chatlab', + model: modelInfo?.id ?? 'unknown', + usage: createEmptyPiUsage(), + stopReason, + timestamp: Date.now(), + } +} + +function replayAssistantBlocks(blocks: ContentBlock[], out: PiMessage[], options?: ReplayOptions): void { + const { modelInfo, thinkingSignature } = options ?? {} + const replayThinking = !!thinkingSignature && blocks.some(isReplayableToolBlock) + + let content: AssistantMessage['content'] = [] + + for (const block of blocks) { + if (block.type === 'text') { + if (block.text.trim()) content.push({ type: 'text', text: block.text }) + } else if (block.type === 'think' && replayThinking && block.text.trim()) { + content.push({ type: 'thinking', thinking: block.text, thinkingSignature }) + } else if (isReplayableToolBlock(block)) { + content.push({ + type: 'toolCall', + id: block.tool.toolCallId, + name: block.tool.name, + arguments: stripInternalParams(block.tool.params), + }) + out.push(makeAssistantMessage(content, 'toolUse', modelInfo)) + out.push({ + role: 'toolResult', + toolCallId: block.tool.toolCallId, + toolName: block.tool.name, + content: [{ type: 'text', text: truncateToolResultText(block.tool.result) || '(empty result)' }], + isError: block.tool.isError ?? block.tool.status === 'error', + timestamp: Date.now(), + }) + content = [] + } + // chart/plan/error/summary_meta blocks and unfinished/legacy tool blocks are not replayed + } + + if (content.length > 0) { + out.push(makeAssistantMessage(content, 'stop', modelInfo)) + } +} + +export function toPiHistoryMessages(messages: SimpleHistoryMessage[], options?: ReplayOptions): PiMessage[] { + const out: PiMessage[] = [] + + for (const msg of messages) { + if (msg.role === 'user') { + out.push({ + role: 'user', + content: [{ type: 'text', text: msg.content || '' }], + timestamp: Date.now(), + }) + continue + } + + // summary 作为 assistant 消息传给 LLM(它是压缩后的上下文总结) + const replayable = msg.role === 'assistant' && msg.contentBlocks?.some(isReplayableToolBlock) + if (replayable) { + replayAssistantBlocks(msg.contentBlocks!, out, options) + } else { + out.push(makeAssistantMessage([{ type: 'text', text: msg.content || '' }], 'stop', options?.modelInfo)) + } + } + + return out +} diff --git a/packages/node-runtime/src/ai/agent/index.ts b/packages/node-runtime/src/ai/agent/index.ts new file mode 100644 index 000000000..e4ea308f4 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/index.ts @@ -0,0 +1,19 @@ +export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './types' +export { runAgentCore } from './core' +export { DEFAULT_MAX_TOOL_ROUNDS } from './constants' +export { createLlmRouteDecider, decideRequestRoute } from './router' +export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './routing-types' +export { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from './planner' +export { createDataSnapshotFromOverview } from './data-snapshot' +export type { ChatOverviewForSnapshot } from './data-snapshot' +export { buildSemanticSearchGuidance } from './semantic-search-guidance' +export type { + AnalysisPlanIntent, + AnalysisPlanner, + AnalysisPlanStep, + AnalysisPlanSummary, + PlannerCapabilitySummary, + PlannerInput, + PlanContentBlock, + PlanDraftContentBlock, +} from './planning-types' diff --git a/packages/node-runtime/src/ai/agent/planner.ts b/packages/node-runtime/src/ai/agent/planner.ts new file mode 100644 index 000000000..e1ca824df --- /dev/null +++ b/packages/node-runtime/src/ai/agent/planner.ts @@ -0,0 +1,386 @@ +import { + completeSimple, + streamSimple, + type Api as PiApi, + type Model as PiModel, + type TextContent as PiTextContent, +} from '@earendil-works/pi-ai' +import type { + AnalysisPlanIntent, + AnalysisPlanSummary, + AnalysisPlanner, + PlanContentBlock, + PlannerInput, +} from './planning-types' +import { formatDataSnapshotForPlanner } from './data-snapshot' + +export type PlannerCompletionResult = string | { text: string } + +export interface PlannerStreamCallbacks { + onPlanDelta: (delta: string) => void + onThinkingDelta?: (delta: string) => void + onThinkingEnd?: (durationMs?: number) => void + onValidationDelta?: (delta: string) => void + onValidationEnd?: (durationMs?: number) => void +} + +export interface CreateAnalysisPlannerOptions { + piModel?: PiModel + apiKey?: string + complete?: (prompt: string, signal?: AbortSignal) => Promise | PlannerCompletionResult + stream?: (prompt: string, callbacks: PlannerStreamCallbacks, signal?: AbortSignal) => Promise + onPlanDelta?: (delta: string) => void + onThinkingDelta?: (delta: string) => void + onThinkingEnd?: (durationMs?: number) => void + onValidationDelta?: (delta: string) => void + onValidationEnd?: (durationMs?: number) => void + maxTokens?: number + temperature?: number +} + +const PLAN_INTENTS: readonly AnalysisPlanIntent[] = [ + 'summary', + 'trend', + 'relationship', + 'search', + 'comparison', + 'evidence', + 'mixed', +] +const STREAM_MARKER_HOLDBACK_CHARS = Math.max(''.length, ''.length, ''.length) - 1 + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isIntent(value: unknown): value is AnalysisPlanIntent { + return typeof value === 'string' && PLAN_INTENTS.includes(value as AnalysisPlanIntent) +} + +function normalizeString(value: unknown): string | null { + if (typeof value !== 'string') return null + const text = value.trim() + return text.length > 0 ? text : null +} + +function extractJsonObject(text: string): string { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i) + if (fenced?.[1]) return fenced[1].trim() + const start = text.indexOf('{') + const end = text.lastIndexOf('}') + if (start >= 0 && end > start) return text.slice(start, end + 1) + return text.trim() +} + +function parsePlanJson(rawText: string, availableTools: Set): AnalysisPlanSummary | null { + try { + const parsed = JSON.parse(extractJsonObject(rawText)) as unknown + if (!isRecord(parsed)) return null + const title = normalizeString(parsed.title) + if (!title || !isIntent(parsed.intent) || !Array.isArray(parsed.steps)) return null + + // 中文注释:Planner 输出来自 LLM,必须在进入 Agent guidance 前做结构约束; + // 这里裁剪长度并过滤不存在的工具,避免把幻觉工具名注入后续执行上下文。 + const steps = parsed.steps + .filter(isRecord) + .map((step) => { + const goal = normalizeString(step.goal) + const evidenceNeeded = normalizeString(step.evidenceNeeded) + if (!goal || !evidenceNeeded) return null + const suggestedTools = Array.isArray(step.suggestedTools) + ? step.suggestedTools.filter((tool): tool is string => typeof tool === 'string' && availableTools.has(tool)) + : [] + return { goal, suggestedTools: Array.from(new Set(suggestedTools)), evidenceNeeded } + }) + .filter((step): step is NonNullable => step !== null) + .slice(0, 5) + + if (steps.length === 0) return null + + const successCriteria = Array.isArray(parsed.successCriteria) + ? parsed.successCriteria + .map(normalizeString) + .filter((item): item is string => item !== null) + .slice(0, 5) + : [] + if (successCriteria.length === 0) return null + + return { + version: 1, + title, + route: 'planned_execution', + intent: parsed.intent, + steps, + successCriteria, + } + } catch { + return null + } +} + +function resultToText(result: PlannerCompletionResult): string { + return typeof result === 'string' ? result : result.text +} + +function buildPlannerPrompt(input: PlannerInput): string { + const dataSnapshot = formatDataSnapshotForPlanner(input.dataSnapshot) + const availableCapabilities = + input.availableCapabilities && input.availableCapabilities.length > 0 + ? input.availableCapabilities + .map((capability) => { + const tools = capability.tools.join(', ') || 'none' + return ` - ${capability.id} (${capability.label}; tools: ${tools}): ${capability.guidance}` + }) + .join('\n') + : '(none)' + + return `Create a concise user-visible analysis plan for a ChatLab planned_execution request. + +Return exactly three blocks: + +A brief user-visible planning note in the user's locale. Keep it high-level; do not reveal hidden chain-of-thought. + + +A concise user-facing analysis approach in the user's locale. Do not include suggested tools, evidence fields, or success criteria; those details belong in . + + +{ + "title": "short title", + "intent": "summary|trend|relationship|search|comparison|evidence|mixed", + "steps": [ + {"goal": "what to investigate", "suggestedTools": ["tool_name"], "evidenceNeeded": "what evidence is needed"} + ], + "successCriteria": ["criterion"] +} + + +Limits: +- Max 5 steps. +- Max 5 successCriteria. +- suggestedTools must come only from availableTools. +- If availableCapabilities are listed, you may plan steps that use their tools when the capability helps the user. +- Do not reveal hidden chain-of-thought; draft and plan are user-readable summaries only. +- The plan is soft guidance, not a mandatory execution script. + +Planning strategy: +- For open-ended topic, community profile, influence, interaction-pattern, or multi-dimensional retrospective analysis, first plan a lightweight reconnaissance step to build a topic/member activity map before drawing conclusions. +- Prefer segment summaries, keyword frequency, time distribution, member activity, and representative message retrieval when available. +- Do not create a dedicated step whose only purpose is finding max timestamp or confirming latest message time. +- Use intent "evidence" for historical fact / occurrence / count / "did we ever" / proof-from-chat questions that need an evidence chain or a counted/conservative conclusion. When the intent is "evidence" and retrieve_chat_evidence is in availableTools, suggest exactly ["retrieve_chat_evidence"] for that step instead of search_messages or semantic_search_current_chat, so the model does not pick only one low-level retrieval path. + +Context: +- locale: ${input.locale} +- chatType: ${input.chatType} +- availableTools: ${input.availableTools.join(', ') || '(none)'} +- availableCapabilities: +${availableCapabilities} +- dataSnapshot: ${dataSnapshot} +- assistantSummary: ${input.assistantSummary ?? '(none)'} +- skillSummary: ${input.skillSummary ?? '(none)'} +- recentIntentSummary: ${input.recentIntentSummary ?? '(none)'} + +User request: +${input.userMessage}` +} + +export function createAnalysisPlanner(options: CreateAnalysisPlannerOptions): AnalysisPlanner { + return async (input, signal) => { + try { + const prompt = buildPlannerPrompt(input) + const callbacks: PlannerStreamCallbacks = { + onPlanDelta: options.onPlanDelta ?? (() => {}), + onThinkingDelta: options.onThinkingDelta, + onThinkingEnd: options.onThinkingEnd, + onValidationDelta: options.onValidationDelta, + onValidationEnd: options.onValidationEnd, + } + const rawResult = options.stream + ? await options.stream(prompt, callbacks, signal) + : options.complete + ? await options.complete(prompt, signal) + : options.onPlanDelta + ? await streamWithPiAi(prompt, options, signal) + : await completeWithPiAi(prompt, options, signal) + return parsePlanJson(resultToText(rawResult), new Set(input.availableTools)) + } catch { + return null + } + } +} + +export function createPlanContentBlock( + plan: AnalysisPlanSummary, + status: PlanContentBlock['status'] = 'created' +): PlanContentBlock { + return { + type: 'plan', + version: 1, + status, + plan, + } +} + +export function buildPlanGuidance(plan: AnalysisPlanSummary): string { + const steps = plan.steps + .map((step, index) => { + const tools = step.suggestedTools.length > 0 ? step.suggestedTools.join(', ') : 'none' + return `${index + 1}. ${step.goal}\n Evidence needed: ${step.evidenceNeeded}\n Suggested tools: ${tools}` + }) + .join('\n') + const successCriteria = plan.successCriteria.map((item) => `- ${item}`).join('\n') + + return `A suggested analysis plan is available below. Use it as guidance when helpful, but do not follow it mechanically if the user's question can be answered more directly. + +Plan title: ${plan.title} +Intent: ${plan.intent} + +Steps: +${steps} + +Success criteria: +${successCriteria}` +} + +async function completeWithPiAi( + prompt: string, + options: CreateAnalysisPlannerOptions, + signal?: AbortSignal +): Promise { + if (!options.piModel || !options.apiKey) { + throw new Error('Planner requires piModel and apiKey') + } + + const result = await completeSimple( + options.piModel, + { + systemPrompt: 'You are a strict planner for ChatLab analysis. Return the requested blocks only.', + messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any, + }, + { + apiKey: options.apiKey, + maxTokens: options.maxTokens ?? 900, + temperature: options.temperature ?? 0.2, + signal, + } + ) + + return { + text: result.content + .filter((item): item is PiTextContent => item.type === 'text') + .map((item) => item.text) + .join(''), + } +} + +function extractTaggedBlockText(rawText: string, tag: 'draft' | 'plan' | 'json', nextTags: string[]): string { + const openTag = `<${tag}>` + const closeTag = `` + const blockStart = rawText.indexOf(openTag) + if (blockStart < 0) return '' + + const bodyStart = blockStart + openTag.length + const closeStart = rawText.indexOf(closeTag, bodyStart) + const nextStarts = nextTags.map((nextTag) => rawText.indexOf(nextTag, bodyStart)).filter((index) => index >= 0) + const endCandidates = [closeStart, ...nextStarts].filter((index) => index >= 0) + const bodyEnd = + endCandidates.length > 0 + ? Math.min(...endCandidates) + : Math.max(bodyStart, rawText.length - STREAM_MARKER_HOLDBACK_CHARS) + const text = rawText.slice(bodyStart, bodyEnd) + return text.replace(/^\s+/, '') +} + +async function streamWithPiAi( + prompt: string, + options: CreateAnalysisPlannerOptions, + signal?: AbortSignal +): Promise { + if (!options.piModel || !options.apiKey) { + throw new Error('Planner requires piModel and apiKey') + } + + const stream = streamSimple( + options.piModel, + { + systemPrompt: 'You are a strict planner for ChatLab analysis. Return the requested plan and JSON blocks only.', + messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any, + }, + { + apiKey: options.apiKey, + maxTokens: options.maxTokens ?? 900, + temperature: options.temperature ?? 0.2, + signal, + } + ) + + let text = '' + let emittedDraftLength = 0 + let emittedPlanLength = 0 + let emittedJsonLength = 0 + let thinkingStartedAt: number | undefined + let draftThinkingStartedAt: number | undefined + let draftThinkingEnded = false + let validationStartedAt: number | undefined + let validationEnded = false + + for await (const event of stream) { + if (signal?.aborted) break + if (event.type === 'thinking_start') { + thinkingStartedAt = Date.now() + continue + } + if (event.type === 'thinking_delta') { + options.onThinkingDelta?.(event.delta) + continue + } + if (event.type === 'thinking_end') { + const durationMs = thinkingStartedAt ? Date.now() - thinkingStartedAt : undefined + thinkingStartedAt = undefined + options.onThinkingEnd?.(durationMs) + continue + } + if (event.type !== 'text_delta') continue + + text += event.delta + const draftText = extractTaggedBlockText(text, 'draft', ['', '']) + if (draftText.length > emittedDraftLength) { + if (!draftThinkingStartedAt) draftThinkingStartedAt = Date.now() + const delta = draftText.slice(emittedDraftLength) + emittedDraftLength = draftText.length + if (delta.trim().length > 0 || /\s/.test(delta)) { + options.onThinkingDelta?.(delta) + } + } + if (!draftThinkingEnded && emittedDraftLength > 0 && (text.includes('') || text.includes(''))) { + draftThinkingEnded = true + const durationMs = draftThinkingStartedAt ? Date.now() - draftThinkingStartedAt : undefined + options.onThinkingEnd?.(durationMs) + } + + const planText = extractTaggedBlockText(text, 'plan', ['']) + if (planText.length > emittedPlanLength) { + const delta = planText.slice(emittedPlanLength) + emittedPlanLength = planText.length + if (delta.trim().length > 0 || /\s/.test(delta)) { + options.onPlanDelta?.(delta) + } + } + + const jsonText = extractTaggedBlockText(text, 'json', []) + if (jsonText.length > emittedJsonLength) { + if (!validationStartedAt) validationStartedAt = Date.now() + const delta = jsonText.slice(emittedJsonLength) + emittedJsonLength = jsonText.length + if (delta.trim().length > 0 || /\s/.test(delta)) { + options.onValidationDelta?.(delta) + } + } + if (!validationEnded && emittedJsonLength > 0 && text.includes('')) { + validationEnded = true + const durationMs = validationStartedAt ? Date.now() - validationStartedAt : undefined + options.onValidationEnd?.(durationMs) + } + } + + return { text } +} diff --git a/packages/node-runtime/src/ai/agent/planning-types.ts b/packages/node-runtime/src/ai/agent/planning-types.ts new file mode 100644 index 000000000..9fad89897 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/planning-types.ts @@ -0,0 +1,54 @@ +import type { DataSnapshot } from './prompt-builder' + +export type AnalysisPlanIntent = 'summary' | 'trend' | 'relationship' | 'search' | 'comparison' | 'evidence' | 'mixed' + +export interface AnalysisPlanStep { + goal: string + suggestedTools: string[] + evidenceNeeded: string +} + +export interface AnalysisPlanSummary { + version: 1 + title: string + route: 'planned_execution' + intent: AnalysisPlanIntent + steps: AnalysisPlanStep[] + successCriteria: string[] +} + +export interface PlannerCapabilitySummary { + id: string + label: string + tools: string[] + guidance: string +} + +export interface PlanContentBlock { + type: 'plan' + version: 1 + status: 'created' | 'executing' | 'done' | 'skipped' + plan: AnalysisPlanSummary + displayText?: string +} + +export interface PlanDraftContentBlock { + type: 'plan_draft' + version: 1 + status: 'streaming' + text: string +} + +export interface PlannerInput { + userMessage: string + chatType: 'group' | 'private' + locale: string + dataSnapshot?: DataSnapshot + availableTools: string[] + availableCapabilities?: PlannerCapabilitySummary[] + assistantSummary?: string + skillSummary?: string + recentIntentSummary?: string +} + +export type AnalysisPlanner = (input: PlannerInput, signal?: AbortSignal) => Promise diff --git a/packages/node-runtime/src/ai/agent/prompt-builder.ts b/packages/node-runtime/src/ai/agent/prompt-builder.ts new file mode 100644 index 000000000..612aef321 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/prompt-builder.ts @@ -0,0 +1,221 @@ +/** + * Agent system prompt builder — shared implementation. + * + * The i18n translation function is injected via `t` parameter, + * making this module platform-agnostic. + */ + +export interface OwnerInfo { + platformId: string + displayName: string +} + +export interface MentionedMember { + memberId: number + platformId: string + displayName: string + aliases: string[] + mentionText: string +} + +export interface SkillContext { + skillDef?: { name: string; prompt: string } + skillMenu?: string +} + +export interface ActiveMemberHint { + memberId: number + displayName: string + messageCount: number + share: number +} + +export interface DataSnapshot { + version?: 2 + name: string + platform: string + type: string + totalMessages: number + totalMembers: number + firstMessageTs: number | null + lastMessageTs: number | null + capturedAt?: number + activeMemberHints?: ActiveMemberHint[] + segmentSummaries?: { + availableCount: number + } +} + +export type TranslateFn = (key: string, options?: Record) => string + +export interface BuildSystemPromptOptions { + t: TranslateFn + chatType?: 'group' | 'private' + assistantSystemPrompt?: string + ownerInfo?: OwnerInfo + locale?: string + skillCtx?: SkillContext + mentionedMembers?: MentionedMember[] + dataSnapshot?: DataSnapshot +} + +function agentT(t: TranslateFn, key: string, locale: string, options?: Record): string { + return t(key, { lng: locale, ...options }) +} + +function formatNullableTimestamp(timestamp: number | null | undefined): string { + return typeof timestamp === 'number' && Number.isFinite(timestamp) ? String(timestamp) : 'null' +} + +function formatSharePercent(share: number): string { + if (!Number.isFinite(share)) return '0%' + const rounded = Math.round(share * 10) / 10 + return Number.isInteger(rounded) ? `${rounded}%` : `${rounded.toFixed(1)}%` +} + +function normalizeInlineText(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +export function formatDataSnapshotContext(t: TranslateFn, dataSnapshot: DataSnapshot, locale: string): string { + const memberHints = dataSnapshot.activeMemberHints ?? [] + const memberHintTitle = + memberHints.length === 0 + ? agentT(t, 'ai.agent.dataSnapshotMemberHintsUnavailable', locale) + : dataSnapshot.totalMembers <= 10 + ? agentT(t, 'ai.agent.dataSnapshotMemberHintsAll', locale) + : agentT(t, 'ai.agent.dataSnapshotMemberHintsTop', locale) + + // 中文注释:启动上下文会进入每轮系统提示词,字段顺序和标签必须稳定,便于模型缓存和后续 smoke 对比。 + const memberHintLines = + memberHints.length > 0 + ? memberHints + .map((member, index) => { + return `${index + 1}. member_id=${member.memberId} | display_name=${normalizeInlineText(member.displayName)} | messages=${member.messageCount} | share=${formatSharePercent(member.share)}` + }) + .join('\n') + : agentT(t, 'ai.agent.dataSnapshotMemberHintsEmpty', locale) + + return agentT(t, 'ai.agent.dataSnapshotContext', locale, { + name: dataSnapshot.name, + platform: dataSnapshot.platform, + type: dataSnapshot.type, + totalMessages: dataSnapshot.totalMessages, + totalMembers: dataSnapshot.totalMembers, + firstMessageTs: formatNullableTimestamp(dataSnapshot.firstMessageTs), + firstMessageTime: formatTimestamp(dataSnapshot.firstMessageTs, locale), + lastMessageTs: formatNullableTimestamp(dataSnapshot.lastMessageTs), + lastMessageTime: formatTimestamp(dataSnapshot.lastMessageTs, locale), + segmentSummaryCount: dataSnapshot.segmentSummaries?.availableCount ?? 0, + memberHintTitle, + memberHintLines, + usageRules: agentT(t, 'ai.agent.dataSnapshotUsageRules', locale), + }) +} + +function getLockedPromptSection( + t: TranslateFn, + chatType: 'group' | 'private', + ownerInfo: OwnerInfo | undefined, + locale: string, + mentionedMembers: MentionedMember[] | undefined, + dataSnapshot: DataSnapshot | undefined +): string { + const now = new Date() + const dateLocale = locale.startsWith('zh') ? 'zh-CN' : 'en-US' + const currentDate = now.toLocaleDateString(dateLocale, { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long', + }) + + const isPrivate = chatType === 'private' + const chatContext = agentT(t, `ai.agent.chatContext.${chatType}`, locale) + + const ownerNote = ownerInfo + ? agentT(t, 'ai.agent.ownerNote', locale, { + displayName: ownerInfo.displayName, + platformId: ownerInfo.platformId, + chatContext, + }) + : '' + + const memberNote = isPrivate + ? agentT(t, 'ai.agent.memberNotePrivate', locale) + : agentT(t, 'ai.agent.memberNoteGroup', locale) + + const mentionedMembersNote = + mentionedMembers && mentionedMembers.length > 0 + ? `${agentT(t, 'ai.agent.mentionedMembersNote', locale)}\n${mentionedMembers + .map((member) => { + const aliasPart = member.aliases.length > 0 ? ` | aliases=${member.aliases.join(',')}` : '' + return `- member_id=${member.memberId} | mention=${member.mentionText} | display_name=${member.displayName} | platform_id=${member.platformId}${aliasPart}` + }) + .join('\n')}\n` + : '' + + const year = now.getFullYear() + const dataSnapshotNote = dataSnapshot ? `${formatDataSnapshotContext(t, dataSnapshot, locale)}\n` : '' + + return `${agentT(t, 'ai.agent.currentDateIs', locale)} ${currentDate}。 +${ownerNote} +${mentionedMembersNote} +${dataSnapshotNote} +${memberNote} +${agentT(t, 'ai.agent.timeParamsIntro', locale)} +${agentT(t, 'ai.agent.defaultYearNote', locale, { year })} +${agentT(t, 'ai.agent.evidencePolicy', locale)} + +${agentT(t, 'ai.agent.responseInstruction', locale)}` +} + +function getFallbackRoleDefinition(t: TranslateFn, chatType: 'group' | 'private', locale: string): string { + return agentT(t, `ai.agent.fallbackRoleDefinition.${chatType}`, locale) +} + +function formatTimestamp(timestamp: number | null | undefined, locale: string): string { + if (!timestamp) return locale.startsWith('zh') ? '未知' : 'unknown' + + const date = new Date(timestamp * 1000) + if (Number.isNaN(date.getTime())) return locale.startsWith('zh') ? '未知' : 'unknown' + + const dateLocale = locale.startsWith('zh') ? 'zh-CN' : locale + return date.toLocaleString(dateLocale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +export function buildSystemPrompt(options: BuildSystemPromptOptions): string { + const { + t, + chatType = 'group', + assistantSystemPrompt, + ownerInfo, + locale = 'zh-CN', + skillCtx, + mentionedMembers, + dataSnapshot, + } = options + + const systemPrompt = assistantSystemPrompt || getFallbackRoleDefinition(t, chatType, locale) + const lockedSection = getLockedPromptSection(t, chatType, ownerInfo, locale, mentionedMembers, dataSnapshot) + + let skillSection = '' + if (skillCtx?.skillDef) { + skillSection = + `\n## ${agentT(t, 'ai.agent.currentTask', locale)}:${skillCtx.skillDef.name}\n` + + `${agentT(t, 'ai.agent.skillPriorityNote', locale)}\n` + + skillCtx.skillDef.prompt + } else if (skillCtx?.skillMenu) { + skillSection = `\n${skillCtx.skillMenu}` + } + + return `${systemPrompt}${skillSection} + +${lockedSection}` +} diff --git a/packages/node-runtime/src/ai/agent/router.ts b/packages/node-runtime/src/ai/agent/router.ts new file mode 100644 index 000000000..00df70c67 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/router.ts @@ -0,0 +1,308 @@ +import type { LlmRouteDecider, RouteDecision, RouterInput } from './routing-types' +import { formatDataSnapshotForRouter } from './data-snapshot' +import { + completeSimple, + type Api as PiApi, + type Model as PiModel, + type TextContent as PiTextContent, +} from '@earendil-works/pi-ai' + +export interface DecideRequestRouteOptions { + llmRouter?: LlmRouteDecider + llmFallbackConfidenceThreshold?: number +} + +export type LlmRouterCompletionResult = + | string + | { + text: string + usage?: RouteDecision['usage'] + } + +export interface CreateLlmRouteDeciderOptions { + piModel?: PiModel + apiKey?: string + complete?: (prompt: string, signal?: AbortSignal) => Promise | LlmRouterCompletionResult + abortSignal?: AbortSignal + maxTokens?: number + temperature?: number +} + +const DEFAULT_LLM_FALLBACK_CONFIDENCE_THRESHOLD = 0.6 + +function clampConfidence(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.max(0, Math.min(1, value)) +} + +function normalizeText(text: string): string { + return text.trim().replace(/\s+/g, ' ') +} + +function matchAny(text: string, patterns: RegExp[]): boolean { + return patterns.some((pattern) => pattern.test(text)) +} + +function isRoute(value: unknown): value is RouteDecision['route'] { + return value === 'direct_response' || value === 'tool_assisted' || value === 'planned_execution' +} + +function resultToText(result: LlmRouterCompletionResult): { text: string; usage?: RouteDecision['usage'] } { + return typeof result === 'string' ? { text: result } : result +} + +function extractJsonObject(text: string): string { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i) + if (fenced?.[1]) return fenced[1].trim() + const start = text.indexOf('{') + const end = text.lastIndexOf('}') + if (start >= 0 && end > start) return text.slice(start, end + 1) + return text.trim() +} + +function parseLlmDecision(rawText: string, usage?: RouteDecision['usage']): RouteDecision | null { + try { + const parsed = JSON.parse(extractJsonObject(rawText)) as Record + if (!isRoute(parsed.route)) return null + return { + route: parsed.route, + confidence: clampConfidence(typeof parsed.confidence === 'number' ? parsed.confidence : 0.5), + reason: typeof parsed.reason === 'string' && parsed.reason.trim() ? parsed.reason.trim() : 'LLM route decision.', + source: 'llm', + usage, + } + } catch { + return null + } +} + +function buildLlmRouterPrompt(input: RouterInput, ruleDecision: RouteDecision): string { + const toolNames = input.availableTools?.length ? input.availableTools.join(', ') : '(none)' + const dataSnapshot = formatDataSnapshotForRouter(input.dataSnapshot) + + return `Classify the user's ChatLab request into exactly one route. + +Routes: +- direct_response: no local chat data/tools needed. +- tool_assisted: local chat data/tools needed, but the task is simple. +- planned_execution: complex analysis, multi-step evidence gathering, retries, long-range comparison, relationship analysis, or insufficient-evidence handling. + +Return strict JSON only: +{"route":"direct_response|tool_assisted|planned_execution","confidence":0.0,"reason":"short reason"} + +Context: +- locale: ${input.locale} +- chatType: ${input.chatType} +- availableTools: ${toolNames} +- dataSnapshot: ${dataSnapshot} +- assistantSummary: ${input.assistantSummary ?? '(none)'} +- skillSummary: ${input.skillSummary ?? '(none)'} +- recentIntentSummary: ${input.recentIntentSummary ?? '(none)'} +- ruleDecision: ${ruleDecision.route}, confidence=${ruleDecision.confidence}, reason=${ruleDecision.reason} + +User request: +${input.userMessage}` +} + +function directRuleDecision(text: string): RouteDecision | null { + if ( + matchAny(text, [/不用查聊天记录|不用查询|不用查数据|不用看本地数据/i, /润色|改写|翻译|polish|rewrite|translate/i]) + ) { + return { + route: 'direct_response', + confidence: 0.95, + reason: 'User explicitly asked for a non-data response.', + source: 'rule', + } + } + + if (matchAny(text, [/什么是|解释一下|概念|区别|explain|briefly explain|why .*function calling|react/i])) { + return { + route: 'direct_response', + confidence: 0.86, + reason: 'Conceptual explanation without local chat-data dependency.', + source: 'rule', + } + } + + if (matchAny(text, [/日志在哪里|怎么配置|设置|帮助|help|where .*log/i])) { + return { + route: 'direct_response', + confidence: 0.84, + reason: 'Help or configuration request does not require chat tools.', + source: 'rule', + } + } + + if (matchAny(text, [/你觉得|闲聊|随便聊聊|建议我|should i/i])) { + return { + route: 'direct_response', + confidence: 0.78, + reason: 'Conversational request without clear data dependency.', + source: 'rule', + } + } + + return null +} + +function plannedRuleDecision(text: string): RouteDecision | null { + if (matchAny(text, [/如果.*找不到.*继续查|换几个相关|换几个.*继续|search.*if.*not.*found/i])) { + return { + route: 'planned_execution', + confidence: 0.88, + reason: 'Request asks for search failure recovery and retry strategy.', + source: 'rule', + } + } + + const dimensions = [ + matchAny(text, [/分析|复盘|总结.*规律|趋势|变化|演变|compare|comparison/i]), + matchAny(text, [ + /过去一年|最近一年|近一年|过去.*年|最近半年|今年|去年|上半年|下半年|长期|按季度|按月份|first half|second half/i, + ]), + matchAny(text, [/关系|互动|影响力|核心成员|最强|最活跃|被忽略|主动|回应|turning point|转折点/i]), + matchAny(text, [/证据|举出|例子|关键分歧|主要矛盾|evidence/i]), + matchAny(text, [/并且|同时|以及|分别|每个阶段|按阶段|阶段|多条件|参与|回复|话题|主题|发言规律|topic|theme/i]), + matchAny(text, [/证据不够|不足以|不够|uncertain|insufficient/i]), + ] + const score = dimensions.filter(Boolean).length + if (score >= 2) { + return { + route: 'planned_execution', + confidence: Math.min(0.95, 0.72 + score * 0.04), + reason: `Rule matched ${score} complex-analysis signals.`, + source: 'rule', + } + } + + return null +} + +function toolRuleDecision(text: string): RouteDecision | null { + if ( + matchAny(text, [ + /一共有多少|多少成员|多少条消息|谁发言最多|前\s*\d+\s*名|top\s*\d+/i, + /异常活跃|活跃度|发言最多|message count|member count/i, + ]) + ) { + return { + route: 'tool_assisted', + confidence: 0.82, + reason: 'Simple statistics request can be answered by one or a few tools.', + source: 'rule', + } + } + + if ( + matchAny(text, [ + /找一下|查一下|有没有聊到|有没有提过|最近提到|昨天.*聊了什么|最近.*发言/i, + /search|find|mentioned|talked about/i, + ]) + ) { + return { + route: 'tool_assisted', + confidence: 0.78, + reason: 'Simple search or lookup request with a single clear target.', + source: 'rule', + } + } + + return null +} + +function ruleDecision(input: RouterInput): RouteDecision { + const text = normalizeText(input.userMessage) + + // 中文注释:先识别复杂分析,再识别简单工具查询,最后识别直答; + // 这样“分析/证据/长时间范围”等高认知负载请求不会被“找一下”之类词误判成简单搜索。 + return ( + plannedRuleDecision(text) ?? + toolRuleDecision(text) ?? + directRuleDecision(text) ?? { + route: 'tool_assisted', + confidence: 0.45, + reason: 'Ambiguous request; conservatively keep the existing tool-assisted Agent path.', + source: 'rule', + } + ) +} + +export async function decideRequestRoute( + input: RouterInput, + options: DecideRequestRouteOptions = {} +): Promise { + const decision = ruleDecision(input) + const threshold = options.llmFallbackConfidenceThreshold ?? DEFAULT_LLM_FALLBACK_CONFIDENCE_THRESHOLD + if (decision.confidence >= threshold || !options.llmRouter) { + return { ...decision, confidence: clampConfidence(decision.confidence) } + } + + const llmDecision = await options.llmRouter(input, decision) + return { + ...llmDecision, + confidence: clampConfidence(llmDecision.confidence), + } +} + +export function createLlmRouteDecider(options: CreateLlmRouteDeciderOptions): LlmRouteDecider { + return async (input, ruleDecision) => { + const prompt = buildLlmRouterPrompt(input, ruleDecision) + + try { + const rawResult = options.complete + ? await options.complete(prompt, options.abortSignal) + : await completeWithPiAi(prompt, options) + const { text, usage } = resultToText(rawResult) + const decision = parseLlmDecision(text, usage) + if (decision) return decision + return { + ...ruleDecision, + reason: `${ruleDecision.reason} LLM fallback returned invalid route JSON.`, + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + ...ruleDecision, + reason: `${ruleDecision.reason} LLM fallback failed: ${message}`, + } + } + } +} + +async function completeWithPiAi( + prompt: string, + options: CreateLlmRouteDeciderOptions +): Promise { + if (!options.piModel || !options.apiKey) { + throw new Error('LLM router requires piModel and apiKey') + } + + const result = await completeSimple( + options.piModel, + { + systemPrompt: 'You are a strict JSON classifier for ChatLab AI routing. Return JSON only.', + messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any, + }, + { + apiKey: options.apiKey, + maxTokens: options.maxTokens ?? 180, + temperature: options.temperature ?? 0, + signal: options.abortSignal, + } + ) + + const text = result.content + .filter((item): item is PiTextContent => item.type === 'text') + .map((item) => item.text) + .join('') + + return { + text, + usage: { + promptTokens: result.usage?.input, + completionTokens: result.usage?.output, + totalTokens: result.usage?.totalTokens, + }, + } +} diff --git a/packages/node-runtime/src/ai/agent/routing-types.ts b/packages/node-runtime/src/ai/agent/routing-types.ts new file mode 100644 index 000000000..765f1758b --- /dev/null +++ b/packages/node-runtime/src/ai/agent/routing-types.ts @@ -0,0 +1,33 @@ +import type { DataSnapshot } from './prompt-builder' + +export type RequestRoute = 'direct_response' | 'tool_assisted' | 'planned_execution' +export type RouteDecisionSource = 'rule' | 'llm' + +export interface RouterInput { + userMessage: string + chatType: 'group' | 'private' + locale: string + dataSnapshot?: DataSnapshot + availableTools?: string[] + assistantSummary?: string + skillSummary?: string + recentIntentSummary?: string +} + +export interface RouteDecision { + route: RequestRoute + /** Confidence is normalized to 0-1. Rule fallbacks below 0.6 should be treated as uncertain. */ + confidence: number + reason: string + source: RouteDecisionSource + usage?: { + promptTokens?: number + completionTokens?: number + totalTokens?: number + } +} + +export type LlmRouteDecider = ( + input: RouterInput, + ruleDecision: RouteDecision +) => Promise | RouteDecision diff --git a/packages/node-runtime/src/ai/agent/semantic-search-guidance.test.ts b/packages/node-runtime/src/ai/agent/semantic-search-guidance.test.ts new file mode 100644 index 000000000..ac18cfc80 --- /dev/null +++ b/packages/node-runtime/src/ai/agent/semantic-search-guidance.test.ts @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { buildSemanticSearchGuidance } from './semantic-search-guidance' + +describe('buildSemanticSearchGuidance', () => { + it('routes enumeration / topic-discovery questions to semantic search (zh)', () => { + const text = buildSemanticSearchGuidance('zh-CN') + assert.ok(text.includes('semantic_search_current_chat')) + // 枚举/盘点类问题应明确指向向量检索 + assert.ok(text.includes('哪些') || text.includes('盘点')) + // 显式劝阻多轮关键词穷举(本次回归的核心) + assert.ok(text.includes('穷举')) + assert.ok(text.includes('retrieve_chat_evidence')) + assert.ok(text.includes('search_messages')) + }) + + it('routes enumeration / topic-discovery questions to semantic search (en)', () => { + const text = buildSemanticSearchGuidance('en-US') + assert.ok(text.includes('semantic_search_current_chat')) + assert.ok(/enumerate|inventory|which ones/i.test(text)) + assert.ok(/brute-force/i.test(text)) + assert.ok(text.includes('retrieve_chat_evidence')) + assert.ok(text.includes('search_messages')) + }) +}) diff --git a/packages/node-runtime/src/ai/agent/semantic-search-guidance.ts b/packages/node-runtime/src/ai/agent/semantic-search-guidance.ts new file mode 100644 index 000000000..4280b8a7d --- /dev/null +++ b/packages/node-runtime/src/ai/agent/semantic-search-guidance.ts @@ -0,0 +1,29 @@ +/** + * 语义检索工具引导语 + * + * 仅当当前会话语义索引可检索、工具被暴露给 LLM 时,由两端 runner 注入 system prompt。 + * 引导模型在需要历史证据时调用 semantic_search_current_chat,避免寒暄/写作类问题无谓检索。 + */ + +function isChinese(locale?: string): boolean { + return (locale ?? '').toLowerCase().startsWith('zh') +} + +export function buildSemanticSearchGuidance(locale?: string): string { + if (isChinese(locale)) { + return [ + '检索本对话历史时按需选择工具:', + '需要证据链 / 事件次数统计 / 是否发生过 / “我们有没有/去过几次”这类历史事实判断时,优先调用 retrieve_chat_evidence(它会综合语义与关键词并给出可计入/不计入/不确定的证据)。', + '想盘点或归纳某类话题、列举“聊过哪些X / 有哪些 / 都聊过什么 / 提到过哪些 / 喜欢什么”,或查找语义相关片段时,调用 semantic_search_current_chat 做向量检索:一次检索即可召回语义相关片段,覆盖未出现字面关键词的内容;不要用多轮 search_messages 逐个猜测关键词穷举。', + '只有在已知确切字面词、原话、特定发送者或时间范围时,才用 search_messages 精确查找。', + '寒暄、写作、解释通用概念等不依赖历史证据的问题不要调用检索工具。', + ].join('') + } + return [ + 'Choose a retrieval tool by need when searching THIS conversation history: ', + 'for evidence chains, event counts, whether something happened, or "how many times / did we ever" historical fact judgments, prefer retrieve_chat_evidence (it combines semantic + keyword retrieval and returns included/excluded/uncertain evidence). ', + 'To inventory or summarize a topic, enumerate "what X did we discuss / which ones / what did we talk about / what do we like", or find semantically related excerpts, call semantic_search_current_chat (vector search): a single search recalls related excerpts including ones without the literal keyword — do NOT brute-force many rounds of search_messages guessing keywords. ', + 'Use search_messages only when you already know the exact literal word, quote, specific sender, or time range. ', + 'Do not call retrieval tools for greetings, writing, or explaining general concepts that need no historical evidence.', + ].join('') +} diff --git a/packages/node-runtime/src/ai/agent/types.ts b/packages/node-runtime/src/ai/agent/types.ts new file mode 100644 index 000000000..a4aeae35a --- /dev/null +++ b/packages/node-runtime/src/ai/agent/types.ts @@ -0,0 +1,68 @@ +/** + * Agent Core 共享类型 + * + * 定义 runAgentCore 的输入/输出/事件接口, + * 供 Server 和 Electron 两端通过 DI 适配。 + */ + +import type { AgentTool } from '@earendil-works/pi-agent-core' +import type { Model, Api, Message } from '@earendil-works/pi-ai' +import type { ThinkingLevel } from '@openchatlab/core' +import type { ContentBlock } from '../chats' + +export interface AgentTokenUsage { + promptTokens: number + completionTokens: number + totalTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +export interface SimpleHistoryMessage { + role: 'user' | 'assistant' | 'summary' + content: string + /** Persisted content blocks; tool blocks with toolCallId+result are replayed as real toolCall/toolResult pairs. */ + contentBlocks?: ContentBlock[] +} + +export type AgentCoreEvent = + | { type: 'content'; content: string } + | { type: 'thinking_start' } + | { type: 'thinking_delta'; content: string } + | { type: 'thinking_end'; durationMs?: number } + | { type: 'tool_start'; toolCallId: string; toolName: string; toolParams: Record } + | { type: 'tool_end'; toolCallId: string; toolName: string; toolResult: unknown; isError: boolean } + | { type: 'turn_end'; round: number; hadToolCalls: boolean } + | { type: 'usage_update'; usage: AgentTokenUsage } + +export interface AgentCoreOptions { + piModel: Model + apiKey: string + systemPrompt: string + tools: AgentTool[] + history: SimpleHistoryMessage[] + userMessage: string + maxToolRounds?: number + abortSignal?: AbortSignal + steerMessage?: string + /** Override the thinking level for this request. Clamped to what the model supports. */ + thinkingLevel?: ThinkingLevel + onEvent: (event: AgentCoreEvent) => void + /** + * 自定义 stream 函数,默认使用 pi-ai 的 streamSimple。 + * Electron 可传入包装版以捕获 onPayload 用于错误诊断。 + */ + streamFn?: unknown + /** 每次 convertToLlm 执行后回调,供 Electron debug 日志使用 */ + onConvertToLlm?: (filteredMessages: Message[]) => void + /** 执行前回调完整的调试上下文(system prompt + history + user message) */ + onDebugContext?: (messages: Array<{ role: string; content: string }>) => void +} + +export interface AgentCoreResult { + usage: AgentTokenUsage + error?: string + finalMessages: Message[] + toolsUsed: string[] + toolRounds: number +} diff --git a/packages/node-runtime/src/ai/ai-logger.ts b/packages/node-runtime/src/ai/ai-logger.ts new file mode 100644 index 000000000..9af79b79a --- /dev/null +++ b/packages/node-runtime/src/ai/ai-logger.ts @@ -0,0 +1,151 @@ +/** + * AI 日志模块(平台无关) + * + * 将 AI 相关操作日志写入本地文件。Electron 和 Server/CLI 共用。 + */ + +import * as fs from 'fs' +import * as path from 'path' + +type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' + +export class AiLogger { + private debugMode = false + private logDir: string + private logFile: string | null = null + private logStream: fs.WriteStream | null = null + + constructor(logsDir: string) { + this.logDir = path.join(logsDir, 'ai') + } + + setDebugMode(enabled: boolean): void { + this.debugMode = enabled + } + + isDebugMode(): boolean { + return this.debugMode + } + + debug(category: string, message: string, data?: unknown): void { + this.writeLog('DEBUG', category, message, data) + } + + info(category: string, message: string, data?: unknown): void { + this.writeLog('INFO', category, message, data) + } + + warn(category: string, message: string, data?: unknown): void { + this.writeLog('WARN', category, message, data) + } + + error(category: string, message: string, data?: unknown): void { + this.writeLog('ERROR', category, message, data) + } + + close(): void { + if (this.logStream) { + this.logStream.end() + this.logStream = null + } + } + + getLogPath(): string { + return this.getLogFilePath() + } + + getExistingLogPath(): string | null { + if (this.logFile && fs.existsSync(this.logFile)) { + return this.logFile + } + return null + } + + private ensureLogDir(): void { + if (!fs.existsSync(this.logDir)) { + fs.mkdirSync(this.logDir, { recursive: true }) + } + } + + private getLogFilePath(): string { + if (this.logFile) return this.logFile + + this.ensureLogDir() + const now = new Date() + const date = now.toISOString().split('T')[0] + const hours = String(now.getHours()).padStart(2, '0') + const minutes = String(now.getMinutes()).padStart(2, '0') + this.logFile = path.join(this.logDir, `ai_${date}_${hours}-${minutes}.log`) + + return this.logFile + } + + private getLogStream(): fs.WriteStream { + if (this.logStream) return this.logStream + + const filePath = this.getLogFilePath() + const isNewOrEmptyFile = !fs.existsSync(filePath) || fs.statSync(filePath).size === 0 + this.logStream = fs.createWriteStream(filePath, { flags: 'a', encoding: 'utf-8' }) + if (isNewOrEmptyFile) { + this.logStream.write(`Local Path: ${filePath.replace(/ /g, '\\ ')}\n\n`) + } + + return this.logStream + } + + private writeLog(level: LogLevel, category: string, message: string, data?: unknown): void { + const timestamp = new Date().toISOString() + let logLine = `[${timestamp}] [${level}] [${category}] ${message}` + + if (data !== undefined) { + try { + const dataStr = typeof data === 'string' ? data : JSON.stringify(data, null, 2) + if (!this.debugMode && dataStr.length > 2000) { + logLine += `\n${dataStr.slice(0, 2000)}...[truncated, ${dataStr.length} chars total]` + } else { + logLine += `\n${dataStr}` + } + } catch { + logLine += `\n[unserializable data]` + } + } + + logLine += '\n' + + try { + const stream = this.getLogStream() + stream.write(logLine) + } catch (error) { + console.error('[AiLogger] Failed to write log:', error) + } + + if (level === 'WARN' || level === 'ERROR') { + console.log(`[AI] ${message}`) + } + } +} + +export function extractErrorInfo(error: unknown): Record { + if (error instanceof Error) { + const info: Record = { + name: error.name, + message: error.message, + } + if ('cause' in error && error.cause) { + info.cause = extractErrorInfo(error.cause) + } + return info + } + if (typeof error === 'object' && error !== null) { + return { raw: JSON.stringify(error) } + } + return { message: String(error) } +} + +export function extractErrorStack(error: unknown, stackLines: number = 5): string | null { + if (error instanceof Error && error.stack) { + const lines = error.stack.split('\n') + return lines.slice(1, stackLines + 1).join('\n') + } + return null +} diff --git a/packages/node-runtime/src/ai/assistant-manager.ts b/packages/node-runtime/src/ai/assistant-manager.ts new file mode 100644 index 000000000..a1d597cf3 --- /dev/null +++ b/packages/node-runtime/src/ai/assistant-manager.ts @@ -0,0 +1,289 @@ +/** + * Platform-agnostic assistant manager. + * Abstracts file system operations and builtin resource loading + * via dependency injection. + */ + +import { parseAssistantFile, serializeAssistant } from './assistant-parser' +import type { AssistantConfig, AssistantSummary } from './types' + +// ==================== Result types ==================== + +export interface AssistantInitResult { + total: number + generalCreated: boolean +} + +export interface AssistantSaveResult { + success: boolean + error?: string +} + +export interface BuiltinAssistantInfo { + id: string + name: string + systemPrompt: string + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] + imported: boolean +} + +// ==================== Dependency abstraction ==================== + +export interface AssistantManagerFs { + ensureDir(dir: string): void + listFiles(dir: string, ext: string): string[] + readFile(filePath: string): string + writeFile(filePath: string, content: string): void + deleteFile(filePath: string): void + fileExists(filePath: string): boolean + joinPath(...parts: string[]): string +} + +export interface AssistantManagerDeps { + fs: AssistantManagerFs + assistantsDir: string + builtinRawConfigs?: Array<{ id: string; content: string }> + generalIds?: string[] + generateId?: () => string + logger?: { + info: (category: string, message: string, data?: unknown) => void + warn: (category: string, message: string, data?: unknown) => void + error: (category: string, message: string, data?: unknown) => void + } +} + +// ==================== Manager ==================== + +function toSummary(config: AssistantConfig): AssistantSummary { + return { + id: config.id, + name: config.name, + systemPrompt: config.systemPrompt, + presetQuestions: config.presetQuestions, + builtinId: config.builtinId, + applicableChatTypes: config.applicableChatTypes, + supportedLocales: config.supportedLocales, + } +} + +export class AssistantManager { + private deps: AssistantManagerDeps + private generalIds: string[] + private builtinCache = new Map() + private cache = new Map() + private initialized = false + + constructor(deps: AssistantManagerDeps) { + this.deps = deps + this.generalIds = deps.generalIds || ['general_cn', 'general_en', 'general_ja'] + this.initBuiltinCache() + } + + private initBuiltinCache(): void { + if (!this.deps.builtinRawConfigs) return + for (const { id, content } of this.deps.builtinRawConfigs) { + const config = parseAssistantFile(content, `${id}.md`) + if (config) this.builtinCache.set(config.id, config) + } + } + + private getBuiltinConfig(id: string): AssistantConfig | undefined { + return this.builtinCache.get(id) + } + + private ensureInitialized(): void { + if (!this.initialized) this.init() + } + + // ==================== Init ==================== + + init(): AssistantInitResult { + const { fs, assistantsDir } = this.deps + fs.ensureDir(assistantsDir) + + const generalCreated = this.ensureGeneralAssistants() + this.loadAll() + + this.initialized = true + this.deps.logger?.info('AssistantManager', 'Initialized', { + total: this.cache.size, + generalCreated, + }) + + return { total: this.cache.size, generalCreated } + } + + private ensureGeneralAssistants(): boolean { + const { fs, assistantsDir } = this.deps + let anyCreated = false + for (const id of this.generalIds) { + const config = this.getBuiltinConfig(id) + if (!config) continue + + const filePath = fs.joinPath(assistantsDir, `${id}.md`) + if (fs.fileExists(filePath)) continue + + fs.writeFile(filePath, serializeAssistant({ ...config, builtinId: config.id })) + anyCreated = true + } + return anyCreated + } + + private loadAll(): void { + const { fs, assistantsDir } = this.deps + this.cache.clear() + + const files = fs.listFiles(assistantsDir, '.md') + for (const file of files) { + try { + const filePath = fs.joinPath(assistantsDir, file) + const content = fs.readFile(filePath) + const config = parseAssistantFile(content, filePath) + if (config) { + this.cache.set(config.id, config) + } else { + this.deps.logger?.warn('AssistantManager', `Failed to parse: ${file}`) + } + } catch (error) { + this.deps.logger?.warn('AssistantManager', `Failed to load: ${file}`, { error: String(error) }) + } + } + } + + // ==================== Query ==================== + + getAllAssistants(): AssistantSummary[] { + this.ensureInitialized() + return Array.from(this.cache.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(toSummary) + } + + getAssistantConfig(id: string): AssistantConfig | null { + this.ensureInitialized() + return this.cache.get(id) ?? null + } + + hasAssistant(id: string): boolean { + this.ensureInitialized() + return this.cache.has(id) + } + + getBuiltinCatalog(): BuiltinAssistantInfo[] { + this.ensureInitialized() + return [] + } + + isGeneralAssistant(id: string): boolean { + return this.generalIds.includes(id) + } + + // ==================== Import ==================== + + importAssistant(builtinId: string): AssistantSaveResult { + this.ensureInitialized() + + const builtinConfig = this.getBuiltinConfig(builtinId) + if (!builtinConfig) return { success: false, error: `Builtin assistant not found: ${builtinId}` } + + const existing = this.findByBuiltinId(builtinId) + if (existing) return { success: false, error: `Assistant already imported: ${builtinId}` } + + return this.saveToDisk({ ...builtinConfig, builtinId: builtinConfig.id }) + } + + reimportAssistant(id: string): AssistantSaveResult { + this.ensureInitialized() + + const existing = this.cache.get(id) + if (!existing) return { success: false, error: `Assistant not found: ${id}` } + if (!existing.builtinId) return { success: false, error: 'Only imported builtin assistants can be reimported' } + + const builtinConfig = this.getBuiltinConfig(existing.builtinId) + if (!builtinConfig) return { success: false, error: `Builtin template not found: ${existing.builtinId}` } + + return this.saveToDisk({ ...builtinConfig, id: existing.id, builtinId: existing.builtinId }) + } + + importAssistantFromMd(rawMd: string): AssistantSaveResult & { id?: string } { + this.ensureInitialized() + + const config = parseAssistantFile(rawMd, 'cloud_import.md') + if (!config) return { success: false, error: 'Failed to parse assistant markdown' } + + if (this.cache.has(config.id)) return { success: false, error: `Assistant already exists: ${config.id}` } + + const result = this.saveToDisk(config) + return { ...result, id: result.success ? config.id : undefined } + } + + // ==================== Mutate ==================== + + updateAssistant(id: string, updates: Partial): AssistantSaveResult { + this.ensureInitialized() + + const existing = this.cache.get(id) + if (!existing) return { success: false, error: `Assistant not found: ${id}` } + + return this.saveToDisk({ ...existing, ...updates, id }) + } + + createAssistant(config: Omit): AssistantSaveResult & { id?: string } { + this.ensureInitialized() + + const id = this.deps.generateId?.() || `custom_${Date.now().toString(36)}` + const newConfig: AssistantConfig = { ...config, id, builtinId: undefined } + + const result = this.saveToDisk(newConfig) + return { ...result, id: result.success ? id : undefined } + } + + deleteAssistant(id: string): AssistantSaveResult { + this.ensureInitialized() + + if (this.generalIds.includes(id)) return { success: false, error: 'Cannot delete the default assistant (general)' } + + const existing = this.cache.get(id) + if (!existing) return { success: false, error: `Assistant not found: ${id}` } + + try { + const filePath = this.deps.fs.joinPath(this.deps.assistantsDir, `${id}.md`) + if (this.deps.fs.fileExists(filePath)) this.deps.fs.deleteFile(filePath) + this.cache.delete(id) + return { success: true } + } catch (error) { + return { success: false, error: String(error) } + } + } + + resetAssistant(id: string): AssistantSaveResult { + this.ensureInitialized() + + const existing = this.cache.get(id) + if (!existing?.builtinId) return { success: false, error: 'Only builtin assistants can be reset' } + + const builtinConfig = this.getBuiltinConfig(existing.builtinId) + if (!builtinConfig) return { success: false, error: `Builtin config not found: ${existing.builtinId}` } + + return this.saveToDisk({ ...builtinConfig, id: existing.id, builtinId: existing.builtinId }) + } + + // ==================== Internal ==================== + + private findByBuiltinId(builtinId: string): AssistantConfig | undefined { + return Array.from(this.cache.values()).find((c) => c.builtinId === builtinId) + } + + private saveToDisk(config: AssistantConfig): AssistantSaveResult { + try { + const filePath = this.deps.fs.joinPath(this.deps.assistantsDir, `${config.id}.md`) + this.deps.fs.writeFile(filePath, serializeAssistant(config)) + this.cache.set(config.id, config) + return { success: true } + } catch (error) { + this.deps.logger?.error('AssistantManager', `Failed to save: ${config.id}`, { error: String(error) }) + return { success: false, error: String(error) } + } + } +} diff --git a/packages/node-runtime/src/ai/assistant-parser.ts b/packages/node-runtime/src/ai/assistant-parser.ts new file mode 100644 index 000000000..daf2517f5 --- /dev/null +++ b/packages/node-runtime/src/ai/assistant-parser.ts @@ -0,0 +1,57 @@ +/** + * 助手 MD 文件解析器(平台无关,Node.js 实现) + */ + +import * as path from 'path' +import matter from 'gray-matter' +import { normalizeBuiltinToolNames } from '@openchatlab/core' +import type { AssistantConfig } from './types' + +export function parseAssistantFile(content: string, filePath: string): AssistantConfig | null { + try { + const { data: fm, content: body } = matter(content) + + const id = fm.id ?? path.basename(filePath, '.md') + const name = fm.name + if (!name) return null + + return { + id, + name, + systemPrompt: body.trim(), + presetQuestions: parseStringArray(fm.presetQuestions), + allowedBuiltinTools: normalizeBuiltinToolNames(parseStringArray(fm.allowedBuiltinTools)), + builtinId: typeof fm.builtinId === 'string' ? fm.builtinId : undefined, + applicableChatTypes: parseChatTypes(fm.applicableChatTypes), + supportedLocales: parseStringArray(fm.supportedLocales), + } + } catch { + return null + } +} + +export function serializeAssistant(config: AssistantConfig): string { + const fm: Record = { + id: config.id, + name: config.name, + } + + if (config.builtinId) fm.builtinId = config.builtinId + if (config.applicableChatTypes?.length) fm.applicableChatTypes = config.applicableChatTypes + if (config.supportedLocales?.length) fm.supportedLocales = config.supportedLocales + if (config.allowedBuiltinTools?.length) fm.allowedBuiltinTools = normalizeBuiltinToolNames(config.allowedBuiltinTools) + if (config.presetQuestions?.length) fm.presetQuestions = config.presetQuestions + + return matter.stringify(`\n${config.systemPrompt}\n`, fm) +} + +function parseStringArray(raw: unknown): string[] { + if (Array.isArray(raw)) return raw.map(String).filter(Boolean) + return [] +} + +function parseChatTypes(raw: unknown): ('group' | 'private')[] | undefined { + if (!Array.isArray(raw)) return undefined + const valid = raw.filter((v): v is 'group' | 'private' => v === 'group' || v === 'private') + return valid.length > 0 ? valid : undefined +} diff --git a/packages/node-runtime/src/ai/builtin-chart-skill.ts b/packages/node-runtime/src/ai/builtin-chart-skill.ts new file mode 100644 index 000000000..a24dc3a87 --- /dev/null +++ b/packages/node-runtime/src/ai/builtin-chart-skill.ts @@ -0,0 +1,5 @@ +export { + buildSkillMenuWithBuiltinChart, + getChartCapabilitySkill as getBuiltinChartSkill, + getSkillConfigWithBuiltinChart, +} from './chart-runtime' diff --git a/packages/node-runtime/src/ai/chart-runtime.ts b/packages/node-runtime/src/ai/chart-runtime.ts new file mode 100644 index 000000000..52ccb501e --- /dev/null +++ b/packages/node-runtime/src/ai/chart-runtime.ts @@ -0,0 +1,248 @@ +import { CHART_CAPABILITY_SKILL_ID } from '@openchatlab/core' +import { appendSkillMenuLines, formatSkillMenuLine } from './skill-menu' +import type { SkillDef } from './types' +import type { PlannerCapabilitySummary } from './agent/planning-types' +import type { ChartAutoMode } from '@openchatlab/shared-types' + +export { CHART_CAPABILITY_SKILL_ID } + +export const CHART_CAPABILITY_CORE_TOOLS = ['get_schema'] as const +export const CHART_CAPABILITY_ANALYSIS_TOOLS = ['render_chart'] as const + +const RAW_SQL_TOOL_NAMES = new Set(['execute_sql']) + +export interface ResolveChartRuntimeOptions { + skillId?: string | null + userMessage?: string + locale?: string + assistantAllowedTools?: readonly string[] | null + enableAutoDetection?: boolean + enableAnalyticalAutoDetection?: boolean + chartAutoMode?: ChartAutoMode +} + +export interface ResolvedChartRuntime { + isChartCapability: boolean + skillDef?: SkillDef + allowedBuiltinTools?: string[] +} + +export function getChartCapabilityAllowedBuiltinTools(allowedTools?: readonly string[] | null): string[] { + const baseTools = (allowedTools ?? []).filter((toolName) => !RAW_SQL_TOOL_NAMES.has(toolName)) + return Array.from(new Set([...baseTools, ...CHART_CAPABILITY_ANALYSIS_TOOLS])) +} + +export function getAllowedBuiltinToolsForChartAutoSkill(allowedTools?: readonly string[] | null): string[] | undefined { + if (!allowedTools) return undefined + if (allowedTools.length === 0) return [] + return Array.from(new Set([...allowedTools, ...CHART_CAPABILITY_ANALYSIS_TOOLS])) +} + +export function shouldUseChartCapabilityForMessage(message: string): boolean { + const normalized = message.trim().toLowerCase() + if (!normalized) return false + + return /(?:画|绘制|生成|做|出|展示).{0,8}(?:图|图表)|(?:图表|可视化|饼图|柱状图|条形图|折线图|热力图|趋势图|占比图|分布图|chart|charts|plot|visuali[sz]e|visuali[sz]ation|pie chart|bar chart|line chart|heatmap)/i.test( + normalized + ) +} + +export function shouldOfferChartCapabilityForAnalyticalMessage(message: string): boolean { + const normalized = message.trim().toLowerCase() + if (!normalized) return false + if (shouldUseChartCapabilityForMessage(normalized)) return true + + return /(?:趋势|变化|走势|排名|排行|前\s*\d+|top\s*\d*|最多|最少|高频|分布|占比|比例|构成|份额|活跃度|热度|按.{0,8}统计|统计.{0,8}按|trend|ranking|rank|top\s*\d*|distribution|ratio|share|percentage|breakdown)/i.test( + normalized + ) +} + +export function getChartPlannerCapabilityForMessage(options: { + userMessage: string + locale?: string + availableTools: readonly string[] + chartAutoMode?: ChartAutoMode +}): PlannerCapabilitySummary | null { + const availableToolSet = new Set(options.availableTools) + if (!availableToolSet.has('get_schema') || !availableToolSet.has('render_chart')) { + return null + } + const chartAutoMode = options.chartAutoMode ?? 'suggest' + const shouldOffer = + chartAutoMode === 'explicit' + ? shouldUseChartCapabilityForMessage(options.userMessage) + : shouldOfferChartCapabilityForAnalyticalMessage(options.userMessage) + if (!shouldOffer) { + return null + } + + const isZh = (options.locale ?? 'zh-CN').startsWith('zh') + return { + id: 'chart_generation', + label: isZh ? '图表生成' : 'Chart generation', + tools: ['get_schema', 'render_chart'], + guidance: isZh + ? '用户明确要求图表,或趋势、排名、分布、占比等分析用图更清楚时,可以在计划中加入最多一张图表步骤;必须先用 get_schema 确认 schema,再用 render_chart 生成图表;不要输出绘图代码。' + : 'When the user explicitly asks for charts, or when trends, rankings, distributions, or ratios are clearer visually, you may add at most one chart step to the plan; call get_schema before render_chart; do not output chart-rendering code.', + } +} + +export function resolveChartRuntimeForRequest(options: ResolveChartRuntimeOptions): ResolvedChartRuntime { + const locale = options.locale ?? 'zh-CN' + const isExplicitChartSkill = options.skillId === CHART_CAPABILITY_SKILL_ID + const allowAnalyticalAutoDetection = + options.chartAutoMode !== undefined + ? options.chartAutoMode === 'aggressive' + : options.enableAnalyticalAutoDetection === true + const shouldAutoDetectChart = + shouldUseChartCapabilityForMessage(options.userMessage ?? '') || + (allowAnalyticalAutoDetection && shouldOfferChartCapabilityForAnalyticalMessage(options.userMessage ?? '')) + const isAutoChartSkill = options.enableAutoDetection === true && !options.skillId && shouldAutoDetectChart + + if (!isExplicitChartSkill && !isAutoChartSkill) { + return { isChartCapability: false } + } + + return { + isChartCapability: true, + skillDef: { ...getChartCapabilitySkill(locale), chatScope: 'all' }, + allowedBuiltinTools: getChartCapabilityAllowedBuiltinTools(options.assistantAllowedTools), + } +} + +export function getChartCapabilitySkill(locale: string = 'zh-CN'): SkillDef { + const isZh = locale.startsWith('zh') + return { + id: CHART_CAPABILITY_SKILL_ID, + name: isZh ? '绘图助手' : 'Chart Assistant', + description: isZh ? '按本轮问题生成灵活的聊天数据图表' : 'Generate flexible charts for this chat question', + tags: [isZh ? '图表' : 'chart'], + chatScope: 'all', + tools: ['render_chart', 'get_schema'], + prompt: isZh ? ZH_PROMPT : EN_PROMPT, + builtinId: CHART_CAPABILITY_SKILL_ID, + } +} + +function getChartMenuLine(locale: string): string { + const skill = getChartCapabilitySkill(locale) + const isZh = locale.startsWith('zh') + const guidance = isZh + ? '用户明确要求图表、画图、占比、趋势、分布、饼图、柱状图、折线图或热力图时优先激活;不要输出 Python/JS 绘图代码' + : 'Activate first when the user explicitly asks for charts, visualization, ratios, trends, distributions, pie, bar, line, or heatmap charts; do not output Python/JS chart code' + return formatSkillMenuLine({ + id: skill.id, + name: skill.name, + description: skill.description, + guidance, + }) +} + +export function buildSkillMenuWithBuiltinChart( + baseMenu: string | null | undefined, + locale: string = 'zh-CN', + allowedTools?: readonly string[] | null +): string | null { + if (allowedTools && !allowedTools.includes(CHART_CAPABILITY_ANALYSIS_TOOLS[0])) return baseMenu ?? null + if (baseMenu?.includes(CHART_CAPABILITY_SKILL_ID)) return baseMenu + + const chartLine = getChartMenuLine(locale) + return appendSkillMenuLines(baseMenu, [chartLine]) ?? '' +} + +export function getSkillConfigWithBuiltinChart( + id: string, + locale: string = 'zh-CN', + getSkillConfig: (id: string) => SkillDef | null +): SkillDef | null { + if (id === CHART_CAPABILITY_SKILL_ID) return getChartCapabilitySkill(locale) + return getSkillConfig(id) +} + +const ZH_PROMPT = `你是 ChatLab 绘图助手。本轮用户希望你在回答里自然嵌入一张或多张图表。 + +你必须通过 render_chart 工具生成图表。不要输出 HTML、JavaScript、SVG、Canvas、ECharts option、Markdown 图片链接(如 ![图表](chart1.png))或任何渲染代码。 + +硬性规则: +1. 第一次写 SQL 前必须先调用 get_schema。禁止猜测表名、字段名或时间字段。 +2. “最近”“过去 N 天/年”等相对时间以系统提示中的真实当前日期为基准;如果启动上下文显示数据库覆盖不足,只分析用户时间范围与数据库覆盖范围的交集并说明缺口。 +3. ChatLab 的 message.ts 是秒级 Unix 时间戳,使用 date(ts, 'unixepoch', 'localtime');禁止写 ts/1000。 +4. “最近的 N 人/成员”必须先用数据定义并筛选出 N 个成员,再只统计这些成员。 +5. 折线图有多条线时,每条线必须有明确 series 字段含义;按日期统计时应补齐日期 × series 的 0 值,避免缺失日期导致折线断裂或误连。 +6. 工具失败、SQL 修正、schema 探索过程不要写进最终回答;最终只保留原生图表和简短结论,禁止用 chart1.png、chart2.png 等文件名引用图表。 +7. 调用 render_chart 时,ChatLab 会立即在该位置插入原生图表;需要图表就直接调用工具,不要写 SQL 注释、Markdown 图片或“已生成图表/见上图”这类文本占位。 +8. 用户要求多张图时,图表应分散出现在相关总结或解释附近;不要把所有图集中生成后再在最后用文字引用。 +9. 为图表准备数据时,优先调用高层工具(如 get_time_stats、member_stats)而非自己写 SQL;只有高层工具无法覆盖所需筛选或聚合时才用 sql。render_chart 不要用于元数据探测、标量查询、单行日期字符串或纯说明性结果。 +10. render_chart 的工具结果会包含数据预览(Data preview);用它分析峰值、低谷和差异,不要为了读取数值重复生成等价图表。 +11. 图表解释只能描述数据直接支持的趋势、峰值、低谷、差异和同步性,禁止夸张拟人化或无法从图中验证的关系判断。 + +工作流程: +1. 调用 get_schema。 +2. 根据 schema、用户要求、系统当前日期和启动上下文确定统计对象、时间范围、维度、指标和图表类型。 +3. 如需调查事实、建立话题地图、筛选成员或确认统计口径,先用可用的普通分析工具完成;不要为了调查而生成临时图表。 +4. 准备图表数据——先问自己:哪个可用的高层工具能直接提供这张图所需的数据?get_time_stats 能给时间分布、member_stats 能给成员排名;能用就调用,将 data 数组作为 rows 传入 render_chart。只有在高层工具无法覆盖所需筛选或聚合时,才编写 SQL。 +5. 在希望图表出现的位置调用 render_chart,提交数据(rows 或 sql)和 ChartSpec v1。 +6. 图表生成后,用 1-3 句简洁文字解释结论。用户要求多张图时,可以多次调用 render_chart,并把每张图放在最相关的段落附近。 + +常用语、口头禅、高频短句类图表必须排除非真人文本:发送者为系统消息、媒体占位(如 [表情包]、[图片]、[语音]、[视频]、[文件]、[链接]、[红包]、[转账])以及撤回/删除提示都不能进入统计。 + +ChartSpec v1 支持: +- bar: encoding.x + encoding.y +- line: encoding.x + encoding.y,可选 encoding.series 表示每条线的含义 +- pie: encoding.label + encoding.value +- heatmap: encoding.x + encoding.y + encoding.value + +ChartSpec 示例: +{ + "version": 1, + "type": "line", + "title": "最近 30 天成员发言趋势", + "encoding": { "x": "day", "y": "msg_count", "series": "member_name" }, + "unit": "条" +} + +用户明确要求图表时必须尝试生成。用户没有明确要求时,只有排名、趋势、分布、占比或二维密度明显更清楚时才主动生成,且默认最多一张。` + +const EN_PROMPT = `You are the ChatLab Chart Assistant. In this turn, the user wants one or more charts embedded naturally in the answer. + +You must generate charts through the render_chart tool. Do not output HTML, JavaScript, SVG, Canvas, ECharts options, Markdown image links such as ![Chart](chart1.png), or rendering code. + +Hard rules: +1. Always call get_schema before writing the first SQL query. Do not guess table names, field names, or timestamp fields. +2. For relative ranges like "recent" or "past N days/years", use the real current date from the system prompt as the baseline. If startup context shows incomplete database coverage, analyze only the intersection between the requested range and database coverage, and state the gap. +3. ChatLab message.ts is a Unix timestamp in seconds. Use date(ts, 'unixepoch', 'localtime'); never write ts/1000. +4. For "latest N members" or "recent N people", first define and select those N members from the data, then count only those members. +5. For multi-series line charts, every line must have an explicit series field. For daily counts, fill the date x series grid with zero values so missing days do not break or mislead the line. +6. Do not include failed SQL attempts, schema exploration, or retry reasoning in the final answer. Final answer should contain only native charts and a short conclusion; never reference charts with filenames like chart1.png or chart2.png. +7. When you call render_chart, ChatLab immediately inserts the native chart at that position. If a chart is needed, call the tool directly; do not write SQL-comment placeholders, Markdown images, or text such as "generated chart" or "see chart above". +8. If the user asks for multiple charts, place each chart near the relevant summary or explanation. Do not generate all charts together and then reference them later with text. +9. When preparing chart data, prefer calling a high-level tool (such as get_time_stats, member_stats) over writing SQL; only use sql when no high-level tool can satisfy the required filters or aggregations. Do not use render_chart for metadata inspection, scalar queries, single-row date strings, or purely explanatory results. +10. render_chart tool results include a Data preview; use it to identify peaks, lows, and differences. Do not generate equivalent charts again just to read values. +11. Explain only trends, peaks, lows, differences, and synchrony directly supported by chart data. Do not make exaggerated personality or relationship claims. + +Workflow: +1. Call get_schema. +2. Derive the target members, time range, dimensions, metrics, and chart type from schema, the user request, system current date, and startup context. +3. If you need to investigate facts, build a topic map, select members, or confirm statistical definitions, use available ordinary analysis tools first; do not generate temporary charts for investigation. +4. Prepare chart data — ask yourself first: which available high-level tool can directly provide the data this chart needs? get_time_stats covers time distributions; member_stats covers member rankings. If one fits, call it and pass its data array as rows to render_chart. Only write SQL when no high-level tool can cover the required filters or aggregations. +5. Call render_chart at the position where the chart should appear, with data (rows or sql) and ChartSpec v1. +6. After the chart is generated, explain the key finding in 1-3 concise sentences. If the user asks for multiple charts, call render_chart multiple times and place each chart near its most relevant section. + +For catchphrase, common phrase, or frequent short-text charts, exclude non-human text from the SQL: system-message senders, media placeholders such as [Sticker], [Image], [Photo], [Voice], [Video], [File], [Link], and recall/deletion notices must not be counted. + +ChartSpec v1 supports: +- bar: encoding.x + encoding.y +- line: encoding.x + encoding.y, optional encoding.series for the meaning of each line +- pie: encoding.label + encoding.value +- heatmap: encoding.x + encoding.y + encoding.value + +ChartSpec example: +{ + "version": 1, + "type": "line", + "title": "Member message trend in the last 30 days", + "encoding": { "x": "day", "y": "msg_count", "series": "member_name" }, + "unit": "messages" +} + +When the user explicitly asks for charts, you must try to generate them. If the user does not explicitly ask, add at most one chart only when ranking, trend, distribution, ratio, or two-dimensional density is clearly easier to understand visually.` diff --git a/packages/node-runtime/src/ai/chart-schema-gate.ts b/packages/node-runtime/src/ai/chart-schema-gate.ts new file mode 100644 index 000000000..0351ab600 --- /dev/null +++ b/packages/node-runtime/src/ai/chart-schema-gate.ts @@ -0,0 +1,41 @@ +import type { AgentTool, AgentToolResult } from '@earendil-works/pi-agent-core' + +export const CHART_SCHEMA_REQUIRED_MESSAGE = + 'Error: Call get_schema before render_chart. Do not guess table names, fields, or timestamp units.' + +export interface ChartSchemaGateState { + schemaSeen: boolean +} + +export function createChartSchemaGateState(): ChartSchemaGateState { + return { schemaSeen: false } +} + +export function wrapWithChartSchemaGate(tool: AgentTool, state: ChartSchemaGateState): AgentTool { + const originalExecute = tool.execute + + return { + ...tool, + execute: async ( + toolCallId: string, + params: any, + signal?: AbortSignal, + onUpdate?: unknown + ): Promise> => { + if (tool.name === 'render_chart' && !state.schemaSeen) { + return { + content: [{ type: 'text', text: CHART_SCHEMA_REQUIRED_MESSAGE }], + details: null, + } as AgentToolResult + } + + const result = await originalExecute(toolCallId, params, signal, onUpdate as never) + + if (tool.name === 'get_schema') { + state.schemaSeen = true + } + + return result + }, + } +} diff --git a/packages/node-runtime/src/ai/chats.ts b/packages/node-runtime/src/ai/chats.ts new file mode 100644 index 000000000..18bdb237e --- /dev/null +++ b/packages/node-runtime/src/ai/chats.ts @@ -0,0 +1,1005 @@ +/** + * AI 对话历史管理模块(平台无关) + * + * 管理 AI 对话的持久化存储(conversations.db), + * 供 Electron 主进程和 CLI serve 共用。 + */ + +import Database from 'better-sqlite3' +import * as fs from 'fs' +import * as path from 'path' +import type { ChartPayload, ChatEvidencePayload } from '@openchatlab/core' +import type { PlanContentBlock, PlanDraftContentBlock } from './agent' + +const DEFAULT_GENERAL_ID = 'general_cn' + +// ==================== 类型定义 ==================== + +export interface AIChat { + id: string + sessionId: string + title: string | null + assistantId: string + activeMessageId?: string | null + createdAt: number + updatedAt: number +} + +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'think'; tag: string; text: string; durationMs?: number } + | { type: 'chart'; chart: ChartPayload } + | { type: 'evidence'; evidence: ChatEvidencePayload } + | PlanContentBlock + | PlanDraftContentBlock + | { + type: 'tool' + tool: { + name: string + displayName: string + status: 'running' | 'done' | 'error' + params?: Record + /** Provider-issued tool call id, persisted so history replay keeps stable ids (prompt cache friendly). */ + toolCallId?: string + /** Truncated text of the tool result as seen by the model. Absent on legacy rows and unfinished calls. */ + result?: string + /** Full safe text result for UI display; not used for history replay. */ + displayResult?: string + /** Whether the tool execution failed (pi-level isError, distinct from UI status). */ + isError?: boolean + } + } + | { type: 'error'; error: { name: string | null; message: string; stack: string | null } } + | { + type: 'summary_meta' + bufferBoundaryTimestamp: number + compressedMessageCount: number + } + +export type AIMessageRole = 'user' | 'assistant' | 'summary' + +export interface TokenUsageData { + promptTokens: number + completionTokens: number + totalTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number +} + +export interface AIMessage { + id: string + aiChatId: string + role: AIMessageRole + content: string + timestamp: number + parentId?: string | null + dataKeywords?: string[] + dataMessageCount?: number + contentBlocks?: ContentBlock[] + tokenUsage?: TokenUsageData +} + +interface AIMessageRow { + id: string + aiChatId: string + role: string + content: string + timestamp: number + parentId: string | null + siblingGroupId: string | null + branchIndex: number | null + dataKeywords: string | null + dataMessageCount: number | null + contentBlocks: string | null + tokenUsage: string | null +} + +export interface AIChatManagerLogger { + warn(category: string, message: string, extra?: Record): void +} + +const defaultLogger: AIChatManagerLogger = { + warn(_category, message, extra) { + console.warn(`[AI Chats] ${message}`, extra ?? '') + }, +} + +// ==================== AIChatManager ==================== + +export class AIChatManager { + private db: Database.Database | null = null + private readonly aiDataDir: string + private readonly logger: AIChatManagerLogger + private readonly nativeBinding?: string + private readonly pendingDebugContextMap = new Map() + + constructor(aiDataDir: string, options?: { logger?: AIChatManagerLogger; nativeBinding?: string }) { + this.aiDataDir = aiDataDir + this.logger = options?.logger ?? defaultLogger + this.nativeBinding = options?.nativeBinding + } + + private ensureDir(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + } + + private getDb(): Database.Database { + if (this.db) return this.db + + this.ensureDir(this.aiDataDir) + const dbPath = path.join(this.aiDataDir, 'conversations.db') + this.db = this.nativeBinding ? new Database(dbPath, { nativeBinding: this.nativeBinding }) : new Database(dbPath) + this.db.pragma('journal_mode = WAL') + + this.db.exec(` + CREATE TABLE IF NOT EXISTS ai_chat ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + title TEXT, + assistant_id TEXT DEFAULT '${DEFAULT_GENERAL_ID}', + active_message_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ai_message ( + id TEXT PRIMARY KEY, + ai_chat_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + data_keywords TEXT, + data_message_count INTEGER, + content_blocks TEXT, + parent_id TEXT, + sibling_group_id TEXT, + branch_index INTEGER DEFAULT 0, + debug_context TEXT, + token_usage TEXT, + FOREIGN KEY(ai_chat_id) REFERENCES ai_chat(id) ON DELETE CASCADE + ); + + `) + + this.migrateDatabase(this.db) + return this.db + } + + private tableExists(db: Database.Database, tableName: string): boolean { + const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName) as + | { 1: number } + | undefined + return !!row + } + + private getTableColumns(db: Database.Database, tableName: string): string[] { + const tableInfo = db.pragma(`table_info(${tableName})`) as Array<{ name: string }> + return tableInfo.map((col) => col.name) + } + + private ensureMessageMigrationColumns(db: Database.Database, tableName: string): void { + const messageColumns = this.getTableColumns(db, tableName) + if (!messageColumns.includes('content_blocks')) { + db.exec(`ALTER TABLE ${tableName} ADD COLUMN content_blocks TEXT`) + } + if (!messageColumns.includes('token_usage')) { + db.exec(`ALTER TABLE ${tableName} ADD COLUMN token_usage TEXT`) + } + if (!messageColumns.includes('debug_context')) { + db.exec(`ALTER TABLE ${tableName} ADD COLUMN debug_context TEXT`) + } + if (!messageColumns.includes('parent_id')) { + db.exec(`ALTER TABLE ${tableName} ADD COLUMN parent_id TEXT`) + } + if (!messageColumns.includes('sibling_group_id')) { + db.exec(`ALTER TABLE ${tableName} ADD COLUMN sibling_group_id TEXT`) + } + if (!messageColumns.includes('branch_index')) { + db.exec(`ALTER TABLE ${tableName} ADD COLUMN branch_index INTEGER DEFAULT 0`) + } + } + + private migrateDatabase(db: Database.Database): void { + try { + const hasLegacyConversationTable = this.tableExists(db, 'ai_conversation') + + if (hasLegacyConversationTable) { + const convColumns = this.getTableColumns(db, 'ai_conversation') + if (!convColumns.includes('assistant_id')) { + db.exec(`ALTER TABLE ai_conversation ADD COLUMN assistant_id TEXT DEFAULT '${DEFAULT_GENERAL_ID}'`) + } + if (!convColumns.includes('active_message_id')) { + db.exec('ALTER TABLE ai_conversation ADD COLUMN active_message_id TEXT') + } + + db.exec(` + INSERT OR IGNORE INTO ai_chat ( + id, session_id, title, assistant_id, active_message_id, created_at, updated_at + ) + SELECT id, session_id, title, COALESCE(assistant_id, '${DEFAULT_GENERAL_ID}'), + active_message_id, created_at, updated_at + FROM ai_conversation + `) + } + + this.ensureMessageMigrationColumns(db, 'ai_message') + + const messageColumns = this.getTableColumns(db, 'ai_message') + const hadLegacyConversationId = messageColumns.includes('conversation_id') + const needsMessageTreeBackfill = + !messageColumns.includes('parent_id') || + !messageColumns.includes('sibling_group_id') || + !messageColumns.includes('branch_index') || + hadLegacyConversationId + + if (hadLegacyConversationId && !messageColumns.includes('ai_chat_id')) { + db.exec(` + DROP INDEX IF EXISTS idx_ai_message_conversation; + ALTER TABLE ai_message RENAME TO ai_message_legacy; + + CREATE TABLE ai_message ( + id TEXT PRIMARY KEY, + ai_chat_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp INTEGER NOT NULL, + data_keywords TEXT, + data_message_count INTEGER, + content_blocks TEXT, + parent_id TEXT, + sibling_group_id TEXT, + branch_index INTEGER DEFAULT 0, + debug_context TEXT, + token_usage TEXT, + FOREIGN KEY(ai_chat_id) REFERENCES ai_chat(id) ON DELETE CASCADE + ); + + INSERT INTO ai_message ( + id, ai_chat_id, role, content, timestamp, data_keywords, data_message_count, + content_blocks, parent_id, sibling_group_id, branch_index, debug_context, token_usage + ) + SELECT id, conversation_id, role, content, timestamp, data_keywords, data_message_count, + content_blocks, parent_id, sibling_group_id, branch_index, debug_context, token_usage + FROM ai_message_legacy; + + DROP TABLE ai_message_legacy; + `) + } + + if (hasLegacyConversationTable) { + db.exec(` + DROP INDEX IF EXISTS idx_ai_conversation_session; + DROP TABLE ai_conversation; + `) + } + + if (needsMessageTreeBackfill || this.hasUnbackfilledMessageTree(db)) { + this.backfillMessageTree(db) + } + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_ai_chat_session ON ai_chat(session_id); + CREATE INDEX IF NOT EXISTS idx_ai_message_ai_chat ON ai_message(ai_chat_id); + CREATE INDEX IF NOT EXISTS idx_ai_message_parent ON ai_message(parent_id); + CREATE INDEX IF NOT EXISTS idx_ai_message_sibling ON ai_message(sibling_group_id); + `) + } catch (error) { + console.error('[AI DB Migration] Migration failed:', error) + } + } + + private hasUnbackfilledMessageTree(db: Database.Database): boolean { + const row = db + .prepare( + `SELECT 1 + FROM ai_chat c + WHERE EXISTS ( + SELECT 1 FROM ai_message m + WHERE m.ai_chat_id = c.id + ) + AND ( + c.active_message_id IS NULL + OR EXISTS ( + SELECT 1 FROM ai_message m + WHERE m.ai_chat_id = c.id + AND (m.sibling_group_id IS NULL OR m.branch_index IS NULL) + ) + ) + LIMIT 1` + ) + .get() + return !!row + } + + private backfillMessageTree(db: Database.Database): void { + const aiChats = db.prepare('SELECT id FROM ai_chat').all() as Array<{ id: string }> + const updateMessage = db.prepare( + 'UPDATE ai_message SET parent_id = ?, sibling_group_id = COALESCE(sibling_group_id, ?), branch_index = COALESCE(branch_index, 0) WHERE id = ?' + ) + const updateAIChat = db.prepare('UPDATE ai_chat SET active_message_id = ? WHERE id = ?') + + const tx = db.transaction(() => { + for (const aiChat of aiChats) { + const messages = db + .prepare( + `SELECT id, parent_id as parentId, sibling_group_id as siblingGroupId + FROM ai_message WHERE ai_chat_id = ? ORDER BY timestamp ASC, id ASC` + ) + .all(aiChat.id) as Array<{ id: string; parentId: string | null; siblingGroupId: string | null }> + + let previousId: string | null = null + for (const message of messages) { + const parentId = message.parentId === undefined ? previousId : (message.parentId ?? previousId) + updateMessage.run(parentId, message.siblingGroupId ?? message.id, message.id) + previousId = message.id + } + + const aiChatRow = db + .prepare('SELECT active_message_id as activeMessageId FROM ai_chat WHERE id = ?') + .get(aiChat.id) as { activeMessageId: string | null } | undefined + if (!aiChatRow?.activeMessageId && previousId) { + updateAIChat.run(previousId, aiChat.id) + } + } + }) + + tx() + } + + private generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + } + + private parseMessageRow(row: AIMessageRow): AIMessage { + return { + id: row.id, + aiChatId: row.aiChatId, + role: row.role as AIMessageRole, + content: row.content, + timestamp: row.timestamp, + parentId: row.parentId ?? null, + dataKeywords: row.dataKeywords ? JSON.parse(row.dataKeywords) : undefined, + dataMessageCount: row.dataMessageCount ?? undefined, + contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined, + tokenUsage: row.tokenUsage ? JSON.parse(row.tokenUsage) : undefined, + } + } + + private getMessageRow(messageId: string): AIMessageRow | null { + const db = this.getDb() + const row = db + .prepare( + `SELECT id, ai_chat_id as aiChatId, role, content, timestamp, + parent_id as parentId, sibling_group_id as siblingGroupId, branch_index as branchIndex, + data_keywords as dataKeywords, data_message_count as dataMessageCount, + content_blocks as contentBlocks, token_usage as tokenUsage + FROM ai_message WHERE id = ?` + ) + .get(messageId) as AIMessageRow | undefined + return row ?? null + } + + private getActiveMessageId(aiChatId: string): string | null { + const db = this.getDb() + const row = db.prepare('SELECT active_message_id as activeMessageId FROM ai_chat WHERE id = ?').get(aiChatId) as + | { activeMessageId: string | null } + | undefined + if (row?.activeMessageId) { + const activeExists = db.prepare('SELECT 1 FROM ai_message WHERE id = ?').get(row.activeMessageId) + if (activeExists) return row.activeMessageId + } + + const fallback = db + .prepare('SELECT id FROM ai_message WHERE ai_chat_id = ? ORDER BY timestamp DESC, id DESC LIMIT 1') + .get(aiChatId) as { id: string } | undefined + if (fallback?.id) { + db.prepare('UPDATE ai_chat SET active_message_id = ? WHERE id = ?').run(fallback.id, aiChatId) + return fallback.id + } + return null + } + + private getAllMessageRows(aiChatId: string): AIMessageRow[] { + return this.getDb() + .prepare( + `SELECT id, ai_chat_id as aiChatId, role, content, timestamp, + parent_id as parentId, sibling_group_id as siblingGroupId, branch_index as branchIndex, + data_keywords as dataKeywords, data_message_count as dataMessageCount, + content_blocks as contentBlocks, token_usage as tokenUsage + FROM ai_message WHERE ai_chat_id = ? ORDER BY timestamp ASC, id ASC` + ) + .all(aiChatId) as AIMessageRow[] + } + + private getActivePathRows(aiChatId: string, leafMessageId?: string | null): AIMessageRow[] { + if (leafMessageId === null) return [] + + const allRows = this.getAllMessageRows(aiChatId) + if (allRows.length === 0) return [] + + const rowMap = new Map(allRows.map((row) => [row.id, row])) + let currentId = leafMessageId ?? this.getActiveMessageId(aiChatId) + const path: AIMessageRow[] = [] + const seen = new Set() + + while (currentId && !seen.has(currentId)) { + seen.add(currentId) + const row = rowMap.get(currentId) + if (!row) break + path.push(row) + currentId = row.parentId + } + + return path.length > 0 ? path.reverse() : allRows + } + + // ==================== 生命周期 ==================== + + close(): void { + if (this.db) { + this.db.close() + this.db = null + } + } + + // ==================== Debug ==================== + + getAiSchema(): Array<{ + name: string + columns: Array<{ name: string; type: string; notnull: boolean; pk: boolean }> + }> { + const db = this.getDb() + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + .all() as Array<{ name: string }> + + return tables.map((t) => { + const columns = db.pragma(`table_info("${t.name}")`) as Array<{ + name: string + type: string + notnull: number + pk: number + }> + return { + name: t.name, + columns: columns.map((c) => ({ + name: c.name, + type: c.type, + notnull: !!c.notnull, + pk: !!c.pk, + })), + } + }) + } + + executeAiSQL(sql: string): { + columns: string[] + rows: unknown[][] + rowCount: number + duration: number + limited: boolean + } { + const db = this.getDb() + const start = Date.now() + const trimmed = sql.trim() + const isSelect = /^SELECT/i.test(trimmed) + + if (isSelect) { + const stmt = db.prepare(trimmed) + const rows = stmt.all() as Record[] + const duration = Date.now() - start + const columns = rows.length > 0 ? Object.keys(rows[0]) : [] + return { + columns, + rows: rows.map((r) => columns.map((c) => r[c])), + rowCount: rows.length, + duration, + limited: false, + } + } else { + const result = db.prepare(trimmed).run() + const duration = Date.now() - start + return { + columns: ['changes', 'lastInsertRowid'], + rows: [[result.changes, Number(result.lastInsertRowid)]], + rowCount: 1, + duration, + limited: false, + } + } + } + + // ==================== 对话管理 ==================== + + createAIChat(sessionId: string, title: string | undefined, assistantId: string): AIChat { + const db = this.getDb() + const now = Math.floor(Date.now() / 1000) + const id = this.generateId('conv') + + db.prepare( + `INSERT INTO ai_chat (id, session_id, title, assistant_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).run(id, sessionId, title || null, assistantId, now, now) + + return { id, sessionId, title: title || null, assistantId, activeMessageId: null, createdAt: now, updatedAt: now } + } + + getAIChatCountsBySession(): Map { + const result = new Map() + try { + const db = this.getDb() + const rows = db.prepare('SELECT session_id, COUNT(*) as count FROM ai_chat GROUP BY session_id').all() as Array<{ + session_id: string + count: number + }> + for (const row of rows) { + result.set(row.session_id, row.count) + } + } catch { + // AI DB may not be initialized yet + } + return result + } + + getAIChats(sessionId: string): AIChat[] { + const db = this.getDb() + return db + .prepare( + `SELECT id, session_id as sessionId, title, assistant_id as assistantId, + active_message_id as activeMessageId, + created_at as createdAt, updated_at as updatedAt + FROM ai_chat WHERE session_id = ? ORDER BY updated_at DESC` + ) + .all(sessionId) as AIChat[] + } + + getAIChat(aiChatId: string): AIChat | null { + const db = this.getDb() + const row = db + .prepare( + `SELECT id, session_id as sessionId, title, assistant_id as assistantId, + active_message_id as activeMessageId, + created_at as createdAt, updated_at as updatedAt + FROM ai_chat WHERE id = ?` + ) + .get(aiChatId) as AIChat | undefined + return row || null + } + + updateAIChatTitle(aiChatId: string, title: string): boolean { + const db = this.getDb() + const now = Math.floor(Date.now() / 1000) + const result = db.prepare('UPDATE ai_chat SET title = ?, updated_at = ? WHERE id = ?').run(title, now, aiChatId) + return result.changes > 0 + } + + deleteAIChat(aiChatId: string): boolean { + const db = this.getDb() + db.prepare('DELETE FROM ai_message WHERE ai_chat_id = ?').run(aiChatId) + const result = db.prepare('DELETE FROM ai_chat WHERE id = ?').run(aiChatId) + return result.changes > 0 + } + + // ==================== 消息管理 ==================== + + addMessage( + aiChatId: string, + role: AIMessageRole, + content: string, + dataKeywords?: string[], + dataMessageCount?: number, + contentBlocks?: ContentBlock[], + tokenUsage?: TokenUsageData + ): AIMessage { + const db = this.getDb() + const now = Math.floor(Date.now() / 1000) + const id = this.generateId('msg') + const parentId = this.getActiveMessageId(aiChatId) + const siblingGroupId = id + const branchIndex = 0 + + const pendingDebug = role === 'assistant' ? this.pendingDebugContextMap.get(aiChatId) : undefined + if (pendingDebug) { + this.pendingDebugContextMap.delete(aiChatId) + } + + db.prepare( + `INSERT INTO ai_message ( + id, ai_chat_id, role, content, timestamp, data_keywords, data_message_count, + content_blocks, token_usage, debug_context, parent_id, sibling_group_id, branch_index + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + aiChatId, + role, + content, + now, + dataKeywords ? JSON.stringify(dataKeywords) : null, + dataMessageCount ?? null, + contentBlocks ? JSON.stringify(contentBlocks) : null, + tokenUsage ? JSON.stringify(tokenUsage) : null, + pendingDebug ?? null, + parentId, + siblingGroupId, + branchIndex + ) + + db.prepare('UPDATE ai_chat SET active_message_id = ?, updated_at = ? WHERE id = ?').run(id, now, aiChatId) + + return { + id, + aiChatId, + role, + content, + timestamp: now, + parentId, + dataKeywords, + dataMessageCount, + contentBlocks, + tokenUsage, + } + } + + getMessages(aiChatId: string): AIMessage[] { + return this.getActivePathRows(aiChatId).map((row) => this.parseMessageRow(row)) + } + + deleteMessage(messageId: string): boolean { + const db = this.getDb() + const result = db.prepare('DELETE FROM ai_message WHERE id = ?').run(messageId) + return result.changes > 0 + } + + deleteMessagesFrom(aiChatId: string, messageId: string): void { + const db = this.getDb() + const target = this.getMessageRow(messageId) + if (!target || target.aiChatId !== aiChatId) { + throw new Error('Message not found in AI chat') + } + + const activePath = this.getActivePathRows(aiChatId) + const targetIndex = activePath.findIndex((row) => row.id === messageId) + if (targetIndex < 0) { + throw new Error('Message not on active path') + } + + const idsToDelete = activePath.slice(targetIndex).map((row) => row.id) + const placeholders = idsToDelete.map(() => '?').join(', ') + db.prepare(`DELETE FROM ai_message WHERE id IN (${placeholders})`).run(...idsToDelete) + + const newLeafId = targetIndex > 0 ? activePath[targetIndex - 1]!.id : null + const now = Math.floor(Date.now() / 1000) + db.prepare('UPDATE ai_chat SET active_message_id = ?, updated_at = ? WHERE id = ?').run(newLeafId, now, aiChatId) + } + + forkAIChat(sourceAIChatId: string, upToMessageId: string, title?: string): AIChat { + const db = this.getDb() + const source = this.getAIChat(sourceAIChatId) + if (!source) { + throw new Error('Source AI chat not found') + } + + const activePath = this.getActivePathRows(sourceAIChatId) + const cutIndex = activePath.findIndex((row) => row.id === upToMessageId) + if (cutIndex < 0) { + throw new Error('Message not on active path') + } + + const messagesToCopy = activePath.slice(0, cutIndex + 1) + const now = Math.floor(Date.now() / 1000) + const newConvId = this.generateId('conv') + const forkTitle = title || `${source.title || 'Untitled'} (fork)` + + db.prepare( + `INSERT INTO ai_chat (id, session_id, title, assistant_id, active_message_id, created_at, updated_at) + VALUES (?, ?, ?, ?, NULL, ?, ?)` + ).run(newConvId, source.sessionId, forkTitle, source.assistantId, now, now) + + const idMap = new Map() + let lastNewId: string | null = null + + for (const row of messagesToCopy) { + const newMsgId = this.generateId('msg') + idMap.set(row.id, newMsgId) + const newParentId = row.parentId ? (idMap.get(row.parentId) ?? null) : null + + db.prepare( + `INSERT INTO ai_message ( + id, ai_chat_id, role, content, timestamp, data_keywords, data_message_count, + content_blocks, token_usage, debug_context, parent_id, sibling_group_id, branch_index + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, 0)` + ).run( + newMsgId, + newConvId, + row.role, + row.content, + row.timestamp, + row.dataKeywords, + row.dataMessageCount, + row.contentBlocks, + row.tokenUsage, + newParentId, + newMsgId + ) + lastNewId = newMsgId + } + + if (lastNewId) { + db.prepare('UPDATE ai_chat SET active_message_id = ? WHERE id = ?').run(lastNewId, newConvId) + } + + return { + id: newConvId, + sessionId: source.sessionId, + title: forkTitle, + assistantId: source.assistantId, + activeMessageId: lastNewId, + createdAt: now, + updatedAt: now, + } + } + + updateMessageContent(messageId: string, newContent: string): void { + const db = this.getDb() + const result = db.prepare('UPDATE ai_message SET content = ? WHERE id = ?').run(newContent, messageId) + if (result.changes === 0) throw new Error('Message not found') + } + + deleteAndRelinkMessage(aiChatId: string, messageId: string): void { + const db = this.getDb() + const target = this.getMessageRow(messageId) + if (!target || target.aiChatId !== aiChatId) { + throw new Error('Message not found in AI chat') + } + + db.prepare('UPDATE ai_message SET parent_id = ? WHERE parent_id = ? AND ai_chat_id = ?').run( + target.parentId, + messageId, + aiChatId + ) + + const conv = this.getAIChat(aiChatId) + if (conv?.activeMessageId === messageId) { + const now = Math.floor(Date.now() / 1000) + db.prepare('UPDATE ai_chat SET active_message_id = ?, updated_at = ? WHERE id = ?').run( + target.parentId, + now, + aiChatId + ) + } + + db.prepare('DELETE FROM ai_message WHERE id = ?').run(messageId) + } + + insertMessageAfter( + aiChatId: string, + afterMessageId: string, + role: AIMessageRole, + content: string, + contentBlocks?: ContentBlock[], + tokenUsage?: TokenUsageData + ): AIMessage { + const db = this.getDb() + const now = Math.floor(Date.now() / 1000) + const id = this.generateId('msg') + + const pendingDebug = role === 'assistant' ? this.pendingDebugContextMap.get(aiChatId) : undefined + if (pendingDebug) { + this.pendingDebugContextMap.delete(aiChatId) + } + + const childRow = db + .prepare('SELECT id FROM ai_message WHERE parent_id = ? AND ai_chat_id = ? LIMIT 1') + .get(afterMessageId, aiChatId) as { id: string } | undefined + + db.prepare( + `INSERT INTO ai_message ( + id, ai_chat_id, role, content, timestamp, data_keywords, data_message_count, + content_blocks, token_usage, debug_context, parent_id, sibling_group_id, branch_index + ) + VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?, 0)` + ).run( + id, + aiChatId, + role, + content, + now, + contentBlocks ? JSON.stringify(contentBlocks) : null, + tokenUsage ? JSON.stringify(tokenUsage) : null, + pendingDebug ?? null, + afterMessageId, + id + ) + + if (childRow) { + db.prepare('UPDATE ai_message SET parent_id = ? WHERE id = ?').run(id, childRow.id) + db.prepare('UPDATE ai_chat SET updated_at = ? WHERE id = ?').run(now, aiChatId) + } else { + db.prepare('UPDATE ai_chat SET active_message_id = ?, updated_at = ? WHERE id = ?').run(id, now, aiChatId) + } + + return { + id, + aiChatId, + role, + content, + timestamp: now, + parentId: afterMessageId, + contentBlocks, + tokenUsage, + } + } + + getAIChatTokenUsage(aiChatId: string): TokenUsageData { + const rows = this.getActivePathRows(aiChatId) + const result: TokenUsageData = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + } + for (const row of rows) { + if (row.tokenUsage) { + const usage = JSON.parse(row.tokenUsage) as TokenUsageData + result.promptTokens += usage.promptTokens + result.completionTokens += usage.completionTokens + result.totalTokens += usage.totalTokens + result.cacheReadTokens! += usage.cacheReadTokens || 0 + result.cacheWriteTokens! += usage.cacheWriteTokens || 0 + } + } + return result + } + + // ==================== Debug context ==================== + + setPendingDebugContext(aiChatId: string, debugContext: string): void { + this.pendingDebugContextMap.set(aiChatId, debugContext) + } + + setDebugContext(messageId: string, debugContext: string): void { + const db = this.getDb() + db.prepare('UPDATE ai_message SET debug_context = ? WHERE id = ?').run(debugContext, messageId) + } + + clearAllDebugContext(): number { + const db = this.getDb() + const result = db.prepare('UPDATE ai_message SET debug_context = NULL WHERE debug_context IS NOT NULL').run() + return result.changes + } + + // ==================== Agent 专用 ==================== + + getHistoryForAgent( + aiChatId: string, + maxMessages?: number, + leafMessageId?: string | null + ): Array<{ role: 'user' | 'assistant' | 'summary'; content: string; contentBlocks?: ContentBlock[] }> { + const messages = this.getActivePathRows(aiChatId, leafMessageId).map((row) => this.parseMessageRow(row)) + const validMessages = messages.filter( + (m) => + (m.role === 'user' || m.role === 'assistant' || m.role === 'summary') && + (m.content?.trim() || (m.role === 'assistant' && m.contentBlocks?.some((b) => b.type === 'tool'))) + ) + + let summaryMsg: AIMessage | undefined + for (let i = validMessages.length - 1; i >= 0; i--) { + if (validMessages[i].role === 'summary') { + summaryMsg = validMessages[i] + break + } + } + + let result: Array<{ role: 'user' | 'assistant' | 'summary'; content: string; contentBlocks?: ContentBlock[] }> + + if (summaryMsg) { + const metaBlock = summaryMsg.contentBlocks?.find( + (b): b is Extract => b.type === 'summary_meta' + ) + const bufferBoundary = metaBlock?.bufferBoundaryTimestamp + + if (!metaBlock) { + this.logger.warn('AIChats', 'summary message missing summary_meta; agent context will be summary-only', { + aiChatId, + messageId: summaryMsg.id, + }) + } + + const contextMessages = bufferBoundary + ? validMessages.filter((m) => m.role !== 'summary' && m.timestamp >= bufferBoundary) + : [] + + result = [ + { role: 'summary' as const, content: summaryMsg.content }, + ...contextMessages.map((m) => ({ role: m.role, content: m.content, contentBlocks: m.contentBlocks })), + ] + } else { + result = validMessages.map((m) => ({ role: m.role, content: m.content, contentBlocks: m.contentBlocks })) + } + + if (maxMessages && result.length > maxMessages) { + if (result.length > 0 && result[0].role === 'summary') { + const rest = result.slice(1) + const truncated = rest.slice(-(maxMessages - 1)) + return [result[0], ...truncated] + } + return result.slice(-maxMessages) + } + return result + } + + // ==================== Summary / 压缩专用 ==================== + + addSummaryMessage( + aiChatId: string, + content: string, + meta: { bufferBoundaryTimestamp: number; compressedMessageCount: number } + ): AIMessage { + const contentBlocks: ContentBlock[] = [ + { + type: 'summary_meta', + bufferBoundaryTimestamp: meta.bufferBoundaryTimestamp, + compressedMessageCount: meta.compressedMessageCount, + }, + ] + + return this.addMessage(aiChatId, 'summary', content, undefined, undefined, contentBlocks) + } + + getLatestSummary(aiChatId: string): AIMessage | null { + const row = [...this.getActivePathRows(aiChatId)].reverse().find((message) => message.role === 'summary') + return row ? this.parseMessageRow(row) : null + } + + getMessagesAfterSummary( + aiChatId: string, + summaryTimestamp: number + ): Array<{ role: AIMessageRole; content: string; timestamp: number; contentBlocks?: ContentBlock[] }> { + return this.getActivePathRows(aiChatId) + .filter((row) => row.timestamp > summaryTimestamp && (row.role === 'user' || row.role === 'assistant')) + .map((row) => this.toCompressionMessage(row)) + } + + getAllUserAssistantMessages( + aiChatId: string + ): Array<{ role: AIMessageRole; content: string; timestamp: number; contentBlocks?: ContentBlock[] }> { + return this.getActivePathRows(aiChatId) + .filter((row) => row.role === 'user' || row.role === 'assistant') + .map((row) => this.toCompressionMessage(row)) + } + + private toCompressionMessage(row: AIMessageRow): { + role: AIMessageRole + content: string + timestamp: number + contentBlocks?: ContentBlock[] + } { + return { + role: row.role as AIMessageRole, + content: row.content, + timestamp: row.timestamp, + contentBlocks: row.contentBlocks ? JSON.parse(row.contentBlocks) : undefined, + } + } + + getMessageCountAfterSummary(aiChatId: string): number { + const summary = this.getLatestSummary(aiChatId) + if (!summary) { + return this.getActivePathRows(aiChatId).filter((row) => row.role === 'user' || row.role === 'assistant').length + } + + const metaBlock = summary.contentBlocks?.find( + (b): b is Extract => b.type === 'summary_meta' + ) + const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp + + return this.getActivePathRows(aiChatId).filter( + (row) => row.timestamp >= boundary && (row.role === 'user' || row.role === 'assistant') + ).length + } +} diff --git a/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts b/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts new file mode 100644 index 000000000..15c85a923 --- /dev/null +++ b/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts @@ -0,0 +1,159 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AIChatManager } from '../../chats' +import type { ContentBlock } from '../../chats' +import { checkAndCompress } from '../compressor' +import type { CompressionConfig, CompressionLlmAdapter } from '../types' + +const sqliteNativeBinding = process.env.CHATLAB_TEST_SQLITE_NATIVE_BINDING + +function createTempDir(): string { + return mkdtempSync(join(tmpdir(), 'chatlab-compression-')) +} + +function createManager(dir: string): AIChatManager { + return sqliteNativeBinding ? new AIChatManager(dir, { nativeBinding: sqliteNativeBinding }) : new AIChatManager(dir) +} + +function cleanup(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } catch { + // Windows can hold SQLite WAL handles briefly after close; temp cleanup is best-effort. + } +} + +const CONFIG: CompressionConfig = { + enabled: true, + tokenThresholdPercent: 75, + bufferSizePercent: 20, +} + +function createAdapter(captured: { prompt: string | null }): CompressionLlmAdapter { + return { + contextWindow: 1000, + compress: async (prompt: string) => { + captured.prompt = prompt + return 'COMPRESSED SUMMARY' + }, + } +} + +/** A tool result large enough that replaying it dominates the context window. */ +function bigToolResult(marker: string): string { + return `${marker} ` + Array.from({ length: 700 }, (_, i) => `record${i} value${i * 3}`).join(' ') +} + +function toolBlock(name: string, result: string | undefined): ContentBlock { + return { + type: 'tool', + tool: { + name, + displayName: name, + status: 'done', + params: { query: 'stats' }, + ...(result !== undefined ? { toolCallId: `call_${name}`, result } : {}), + }, + } +} + +/** + * Seeds a conversation whose plain message text is tiny, but whose persisted + * tool results (replayed into the LLM context each turn) are large. + */ +function seedToolHeavyChat(manager: AIChatManager, withToolResults: boolean): string { + const chat = manager.createAIChat('session-1', 'Compression', 'general_cn') + const result1 = withToolResults ? bigToolResult('TOOL_DATA_ALPHA') : undefined + const result2 = withToolResults ? bigToolResult('TOOL_DATA_BETA') : undefined + + manager.addMessage(chat.id, 'user', 'show me stats') + manager.addMessage(chat.id, 'assistant', 'here are the stats', undefined, undefined, [toolBlock('query_a', result1)]) + manager.addMessage(chat.id, 'user', 'and more details') + manager.addMessage(chat.id, 'assistant', 'more stats', undefined, undefined, [toolBlock('query_b', result2)]) + manager.addMessage(chat.id, 'user', 'thanks') + manager.addMessage(chat.id, 'assistant', 'you are welcome') + return chat.id +} + +describe('checkAndCompress tool result token accounting', () => { + it('counts replayed tool results toward the compression threshold', async () => { + const dir = createTempDir() + const manager = createManager(dir) + try { + const chatId = seedToolHeavyChat(manager, true) + const captured: { prompt: string | null } = { prompt: null } + + const result = await checkAndCompress(chatId, CONFIG, 'system', createAdapter(captured), manager) + + assert.equal(result.compressed, true) + assert.equal(result.reason, 'success') + assert.ok(result.tokensBefore! > result.tokensAfter!) + assert.ok(manager.getLatestSummary(chatId), 'summary message should be persisted') + + // Tool results must be part of the compression input so the summary + // can preserve their key data points. + assert.ok(captured.prompt!.includes('TOOL_DATA_ALPHA')) + assert.ok(captured.prompt!.includes('TOOL_DATA_BETA')) + assert.ok(captured.prompt!.includes('[Tool result: query_a]')) + } finally { + manager.close() + cleanup(dir) + } + }) + + it('does not count tool blocks without a persisted result (not replayed)', async () => { + const dir = createTempDir() + const manager = createManager(dir) + try { + const chatId = seedToolHeavyChat(manager, false) + const captured: { prompt: string | null } = { prompt: null } + + const result = await checkAndCompress(chatId, CONFIG, 'system', createAdapter(captured), manager) + + assert.equal(result.compressed, false) + assert.equal(result.reason, 'skipped_below_threshold') + assert.equal(captured.prompt, null) + assert.equal(manager.getLatestSummary(chatId), null) + } finally { + manager.close() + cleanup(dir) + } + }) + + it('counts tool results on the progressive path (after an existing summary)', async () => { + const dir = createTempDir() + const manager = createManager(dir) + try { + const chat = manager.createAIChat('session-1', 'Compression', 'general_cn') + manager.addSummaryMessage(chat.id, 'old summary of earlier topics', { + bufferBoundaryTimestamp: Math.floor(Date.now() / 1000) - 100, + compressedMessageCount: 10, + }) + manager.addMessage(chat.id, 'user', 'show me stats') + manager.addMessage(chat.id, 'assistant', 'here are the stats', undefined, undefined, [ + toolBlock('query_a', bigToolResult('TOOL_DATA_GAMMA')), + ]) + manager.addMessage(chat.id, 'user', 'and more details') + manager.addMessage(chat.id, 'assistant', 'more stats', undefined, undefined, [ + toolBlock('query_b', bigToolResult('TOOL_DATA_DELTA')), + ]) + manager.addMessage(chat.id, 'user', 'thanks') + manager.addMessage(chat.id, 'assistant', 'you are welcome') + + const captured: { prompt: string | null } = { prompt: null } + const result = await checkAndCompress(chat.id, CONFIG, 'system', createAdapter(captured), manager) + + assert.equal(result.compressed, true) + assert.equal(result.reason, 'success') + assert.ok(captured.prompt!.includes('[PREVIOUS SUMMARY')) + assert.ok(captured.prompt!.includes('old summary of earlier topics')) + assert.ok(captured.prompt!.includes('TOOL_DATA_GAMMA')) + } finally { + manager.close() + cleanup(dir) + } + }) +}) diff --git a/packages/node-runtime/src/ai/compression/adapter-factory.ts b/packages/node-runtime/src/ai/compression/adapter-factory.ts new file mode 100644 index 000000000..25eff5abd --- /dev/null +++ b/packages/node-runtime/src/ai/compression/adapter-factory.ts @@ -0,0 +1,51 @@ +import { + completeSimple, + type Model as PiModel, + type Api as PiApi, + type TextContent as PiTextContent, +} from '@earendil-works/pi-ai' +import type { CompressionLlmAdapter } from './types' + +export interface CreateCompressionLlmAdapterOptions { + piModel: PiModel + apiKey: string + contextWindow?: number + onCompressing?: () => void + onError?: (error: unknown) => void +} + +const DEFAULT_CONTEXT_WINDOW = 128000 + +/** + * Create a CompressionLlmAdapter from a PiModel and API key. + * Shared factory used by both Electron and Server. + */ +export function createCompressionLlmAdapter(options: CreateCompressionLlmAdapterOptions): CompressionLlmAdapter { + const { piModel, apiKey, onCompressing, onError } = options + const contextWindow = options.contextWindow ?? piModel.contextWindow ?? DEFAULT_CONTEXT_WINDOW + + return { + contextWindow, + compress: async (prompt: string, maxTokens: number) => { + onCompressing?.() + try { + const result = await completeSimple( + piModel, + { + systemPrompt: undefined, + messages: [{ role: 'user', content: [{ type: 'text', text: prompt }], timestamp: Date.now() }] as any, + }, + { apiKey, maxTokens } + ) + const text = result.content + .filter((item): item is PiTextContent => item.type === 'text') + .map((item) => item.text) + .join('') + return text || null + } catch (error) { + onError?.(error) + return null + } + }, + } +} diff --git a/packages/node-runtime/src/ai/compression/compressor.ts b/packages/node-runtime/src/ai/compression/compressor.ts new file mode 100644 index 000000000..2c06d66a3 --- /dev/null +++ b/packages/node-runtime/src/ai/compression/compressor.ts @@ -0,0 +1,282 @@ +/** + * 上下文压缩核心逻辑(平台无关) + * + * 通过 CompressionLlmAdapter 抽象 LLM 调用, + * 通过 AIChatManager 操作对话数据。 + */ + +import { truncateToolResultText } from '@openchatlab/core' + +import { countTokens, countMessagesTokens } from '../tokenizer' +import { isReplayableToolBlock } from '../agent/history' +import type { AIChatManager, ContentBlock, AIMessageRole } from '../chats' +import type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './types' + +interface CompressibleMessage { + role: string + content: string + timestamp: number + contentBlocks?: ContentBlock[] +} + +const DEFAULT_CONTEXT_WINDOW = 128000 + +const INITIAL_COMPRESSION_PROMPT = `You are a context compression assistant. Compress the conversation below into a structured summary. + +STRICT RULES: +- Output ONLY the summary content. No greetings, no preamble, no meta-commentary, no word/token counts. +- Use the same language as the conversation. +- Maximum output length: {maxTokens} tokens. Be concise. +- NEVER reproduce any single message verbatim. Always paraphrase and compress. +- Cover ALL topics discussed — no single topic should exceed 30% of the summary. +- Organize by topic/thread, using brief headers (e.g. "## Topic"). +- Preserve: key facts, conclusions, user preferences, data points, names, important timestamps, action items. +- Omit: pleasantries, filler, redundant back-and-forth, detailed tables (summarize their conclusions instead). + +CONVERSATION: +{messages}` + +const PROGRESSIVE_COMPRESSION_PROMPT = `You are a context compression assistant performing an INCREMENTAL summary update. + +You will receive: +1. A [PREVIOUS SUMMARY] — this represents the compressed history of earlier conversation. Its content MUST be preserved in your output. +2. [NEW MESSAGES] — recent messages that need to be merged into the summary. + +STRICT RULES: +- Output ONLY the updated summary. No greetings, no preamble, no meta-commentary. +- Use the same language as the conversation. +- Maximum output length: {maxTokens} tokens. Be concise. +- CRITICAL: You MUST retain ALL key points from the previous summary. Do not discard prior context. +- NEVER reproduce any single message verbatim. Always paraphrase and compress. +- Merge new information into appropriate existing topic sections, or add new sections. +- Cover ALL topics — no single topic should exceed 30% of the summary. +- Organize by topic/thread, using brief headers (e.g. "## Topic"). +- Preserve: key facts, conclusions, user preferences, data points, names, important timestamps, action items. +- Omit: pleasantries, filler, redundant back-and-forth, detailed tables (summarize their conclusions instead). + +{messages}` + +const defaultLogger: CompressionLogger = { + info: () => {}, + warn: () => {}, + error: () => {}, +} + +export async function checkAndCompress( + aiChatId: string, + config: CompressionConfig, + systemPrompt: string, + llmAdapter: CompressionLlmAdapter, + convManager: AIChatManager, + logger: CompressionLogger = defaultLogger +): Promise { + if (!config.enabled) { + return { compressed: false, reason: 'skipped_disabled' } + } + + try { + const contextWindow = llmAdapter.contextWindow || DEFAULT_CONTEXT_WINDOW + const thresholdTokens = Math.floor(contextWindow * (config.tokenThresholdPercent / 100) * 0.95) + + const summary = convManager.getLatestSummary(aiChatId) + + let messages: Array<{ role: AIMessageRole; content: string; timestamp: number; contentBlocks?: ContentBlock[] }> + if (summary) { + const metaBlock = summary.contentBlocks?.find( + (b): b is Extract => b.type === 'summary_meta' + ) + const boundary = metaBlock?.bufferBoundaryTimestamp ?? summary.timestamp + messages = convManager.getMessagesAfterSummary(aiChatId, boundary - 1) + } else { + messages = convManager.getAllUserAssistantMessages(aiChatId) + } + + const historyForTokenCount: Array<{ role: string; content: string }> = [] + if (summary) { + historyForTokenCount.push({ role: 'assistant', content: summary.content }) + } + for (const msg of messages) { + historyForTokenCount.push({ role: msg.role, content: msg.content }) + // Persisted tool results are replayed as toolCall/toolResult pairs each + // turn (see agent/history.ts), so they occupy real context and must be counted. + for (const toolText of replayedToolResultTexts(msg.contentBlocks)) { + historyForTokenCount.push({ role: 'tool', content: toolText }) + } + } + + const currentTokens = countMessagesTokens(historyForTokenCount, systemPrompt) + + logger.info('Compression', `Token check: ${currentTokens} / ${thresholdTokens} (${contextWindow} window)`, { + aiChatId, + messageCount: messages.length, + hasSummary: !!summary, + }) + + if (currentTokens < thresholdTokens) { + return { compressed: false, reason: 'skipped_below_threshold', tokensBefore: currentTokens } + } + + const bufferTokenBudget = Math.floor(contextWindow * (config.bufferSizePercent / 100)) + const { bufferMessages, messagesToCompress } = splitMessagesForCompression(messages, bufferTokenBudget) + + const MIN_MESSAGES_TO_COMPRESS = 3 + if (messagesToCompress.length < MIN_MESSAGES_TO_COMPRESS) { + return { compressed: false, reason: 'skipped_below_threshold', tokensBefore: currentTokens } + } + + const isProgressive = !!summary + const compressInput = buildCompressionInput(messagesToCompress, summary) + const targetTokens = Math.min(Math.floor(contextWindow * 0.1), 16384) + + const template = isProgressive ? PROGRESSIVE_COMPRESSION_PROMPT : INITIAL_COMPRESSION_PROMPT + const prompt = template.replace('{maxTokens}', String(targetTokens)).replace('{messages}', compressInput) + + let summaryText = await llmAdapter.compress(prompt, targetTokens) + + if (!summaryText) { + logger.warn('Compression', 'LLM compression failed, falling back to truncation') + summaryText = forceTruncate(compressInput, targetTokens) + } + + const bufferBoundary = + bufferMessages.length > 0 + ? bufferMessages[0].timestamp + : messagesToCompress[messagesToCompress.length - 1]!.timestamp + 1 + + convManager.addSummaryMessage(aiChatId, summaryText, { + bufferBoundaryTimestamp: bufferBoundary, + compressedMessageCount: messagesToCompress.length, + }) + + const afterTokenCount: Array<{ role: string; content: string }> = [{ role: 'assistant', content: summaryText }] + for (const m of bufferMessages) { + afterTokenCount.push({ role: m.role, content: m.content }) + for (const toolText of replayedToolResultTexts(m.contentBlocks)) { + afterTokenCount.push({ role: 'tool', content: toolText }) + } + } + const tokensAfter = countMessagesTokens(afterTokenCount, systemPrompt) + + if (tokensAfter >= thresholdTokens) { + logger.warn( + 'Compression', + `Thrashing detected: ${tokensAfter} tokens after compression still >= ${thresholdTokens}` + ) + return { + compressed: true, + reason: 'thrashing', + tokensBefore: currentTokens, + tokensAfter, + summaryContent: summaryText, + } + } + + logger.info('Compression', `Compressed: ${currentTokens} → ${tokensAfter} tokens`) + return { + compressed: true, + reason: 'success', + tokensBefore: currentTokens, + tokensAfter, + summaryContent: summaryText, + } + } catch (error) { + logger.error('Compression', 'Compression failed', { error: String(error) }) + return { compressed: false, reason: 'error', error: String(error) } + } +} + +export async function manualCompress( + aiChatId: string, + config: CompressionConfig, + systemPrompt: string, + llmAdapter: CompressionLlmAdapter, + convManager: AIChatManager, + logger?: CompressionLogger +): Promise { + const messageCount = convManager.getMessageCountAfterSummary(aiChatId) + if (messageCount < 5) { + return { compressed: false, reason: 'skipped_idempotent' } + } + + const overrideConfig = { ...config, enabled: true, tokenThresholdPercent: 0 } + return checkAndCompress(aiChatId, overrideConfig, systemPrompt, llmAdapter, convManager, logger) +} + +// ==================== Internal Helpers ==================== + +/** Tool result texts that history replay will inject back into the LLM context. */ +function replayedToolResultTexts(blocks?: ContentBlock[]): string[] { + if (!blocks) return [] + return blocks.filter(isReplayableToolBlock).map((block) => truncateToolResultText(block.tool.result)) +} + +function countMessageTokensWithTools(msg: CompressibleMessage): number { + let tokens = countTokens(msg.content) + 4 + for (const toolText of replayedToolResultTexts(msg.contentBlocks)) { + tokens += countTokens(toolText) + 4 + } + return tokens +} + +function splitMessagesForCompression( + messages: T[], + bufferTokenBudget: number +): { + bufferMessages: T[] + messagesToCompress: T[] +} { + let bufferTokens = 0 + let splitIndex = messages.length + + for (let i = messages.length - 1; i >= 0; i--) { + const msgTokens = countMessageTokensWithTools(messages[i]) + if (bufferTokens + msgTokens > bufferTokenBudget) { + splitIndex = i + 1 + break + } + bufferTokens += msgTokens + if (i === 0) { + splitIndex = 0 + } + } + + return { + bufferMessages: messages.slice(splitIndex), + messagesToCompress: messages.slice(0, splitIndex), + } +} + +function buildCompressionInput( + messagesToCompress: Array<{ role: string; content: string; contentBlocks?: ContentBlock[] }>, + existingSummary: { content: string } | null +): string { + const parts: string[] = [] + + if (existingSummary) { + parts.push(`[PREVIOUS SUMMARY — MUST PRESERVE]\n${existingSummary.content}\n`) + parts.push(`[NEW MESSAGES — SUMMARIZE AND MERGE]`) + } + + for (const msg of messagesToCompress) { + const roleLabel = msg.role === 'user' ? 'User' : 'Assistant' + parts.push(`${roleLabel}: ${msg.content}`) + for (const block of (msg.contentBlocks ?? []).filter(isReplayableToolBlock)) { + parts.push(`[Tool result: ${block.tool.name}]\n${truncateToolResultText(block.tool.result)}`) + } + } + + return parts.join('\n\n') +} + +function forceTruncate(input: string, targetTokens: number): string { + const lines = input.split('\n') + const result: string[] = [] + let tokens = 0 + for (const line of lines) { + const lineTokens = countTokens(line) + if (tokens + lineTokens > targetTokens) break + result.push(line) + tokens += lineTokens + } + return result.join('\n') || input.slice(0, targetTokens * 3) +} diff --git a/packages/node-runtime/src/ai/compression/index.ts b/packages/node-runtime/src/ai/compression/index.ts new file mode 100644 index 000000000..af265ae68 --- /dev/null +++ b/packages/node-runtime/src/ai/compression/index.ts @@ -0,0 +1,7 @@ +/** + * 上下文压缩模块(平台无关) + */ + +export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './types' +export { checkAndCompress, manualCompress } from './compressor' +export { createCompressionLlmAdapter, type CreateCompressionLlmAdapterOptions } from './adapter-factory' diff --git a/packages/node-runtime/src/ai/compression/types.ts b/packages/node-runtime/src/ai/compression/types.ts new file mode 100644 index 000000000..c4f70117c --- /dev/null +++ b/packages/node-runtime/src/ai/compression/types.ts @@ -0,0 +1,44 @@ +export interface CompressionConfig { + enabled: boolean + /** 触发压缩的 token 阈值百分比(相对于 context window),默认 75 */ + tokenThresholdPercent: number + /** 保留最近消息的缓冲区大小(相对于 context window 的百分比),默认 20 */ + bufferSizePercent: number + /** 单次工具返回的最大上下文占比(相对于 context window 的百分比),默认 35 */ + maxToolResultPercent?: number +} + +export interface CompressionResult { + compressed: boolean + reason: + | 'skipped_disabled' + | 'skipped_below_threshold' + | 'skipped_idempotent' + | 'success' + | 'fallback_truncated' + | 'thrashing' + | 'error' + tokensBefore?: number + tokensAfter?: number + summaryContent?: string + error?: string +} + +export interface CompressionLogger { + info(category: string, message: string, extra?: Record): void + warn(category: string, message: string, extra?: Record): void + error(category: string, message: string, extra?: Record): void +} + +/** + * 抽象 LLM 压缩调用接口。 + * 平台侧提供具体实现(使用 pi-ai 的 completeSimple 等)。 + */ +export interface CompressionLlmAdapter { + /** + * 调用 LLM 进行压缩,返回压缩后的文本,失败返回 null。 + */ + compress(prompt: string, maxTokens: number): Promise + /** 解析模型的 context window 大小 */ + contextWindow: number +} diff --git a/packages/node-runtime/src/ai/custom-store.ts b/packages/node-runtime/src/ai/custom-store.ts new file mode 100644 index 000000000..8d14d04b1 --- /dev/null +++ b/packages/node-runtime/src/ai/custom-store.ts @@ -0,0 +1,125 @@ +/** + * Custom provider/model persistence — platform-agnostic. + * Uses ConfigStorage abstraction so both CLI and Electron share the same logic. + */ + +import type { ConfigStorage } from './llm-config-store' +import type { ProviderDefinition, ModelDefinition } from '@openchatlab/core' + +function generateCustomId(): string { + return `custom:${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +export class CustomProviderStore { + constructor(private storage: ConfigStorage) {} + + getAll(): ProviderDefinition[] { + return this.storage.readJson('custom-providers') ?? [] + } + + add(input: { + name: string + kind?: string + defaultBaseUrl: string + supportsCustomModels?: boolean + modelIds?: string[] + website?: string + consoleUrl?: string + }): ProviderDefinition { + const providers = this.getAll() + const newProvider: ProviderDefinition = { + id: generateCustomId(), + name: input.name, + kind: (input.kind || 'openai-compatible') as ProviderDefinition['kind'], + defaultBaseUrl: input.defaultBaseUrl, + authMode: 'api-key', + supportsCustomModels: input.supportsCustomModels ?? true, + modelIds: input.modelIds ?? [], + builtin: false, + enabledByDefault: false, + website: input.website, + consoleUrl: input.consoleUrl, + } + providers.push(newProvider) + this.storage.writeJson('custom-providers', providers) + return newProvider + } + + update(id: string, updates: Partial): { success: boolean; error?: string } { + const providers = this.getAll() + const index = providers.findIndex((p) => p.id === id) + if (index === -1) return { success: false, error: 'Custom provider not found' } + providers[index] = { ...providers[index], ...updates } + this.storage.writeJson('custom-providers', providers) + return { success: true } + } + + delete(id: string): { success: boolean; error?: string } { + const providers = this.getAll() + const index = providers.findIndex((p) => p.id === id) + if (index === -1) return { success: false, error: 'Custom provider not found' } + providers.splice(index, 1) + this.storage.writeJson('custom-providers', providers) + return { success: true } + } +} + +export class CustomModelStore { + constructor(private storage: ConfigStorage) {} + + getAll(): ModelDefinition[] { + return this.storage.readJson('custom-models') ?? [] + } + + add(input: { + id: string + providerId: string + name: string + description?: string + contextWindow?: number + capabilities?: string[] + recommendedFor?: string[] + status?: string + }): { success: boolean; model?: ModelDefinition; error?: string } { + const models = this.getAll() + if (models.find((m) => m.id === input.id && m.providerId === input.providerId)) { + return { + success: false, + error: `Model "${input.id}" already exists under provider "${input.providerId}"`, + } + } + const newModel: ModelDefinition = { + id: input.id, + providerId: input.providerId, + name: input.name, + description: input.description, + contextWindow: input.contextWindow, + capabilities: (input.capabilities ?? ['chat']) as ModelDefinition['capabilities'], + recommendedFor: (input.recommendedFor ?? ['chat']) as ModelDefinition['recommendedFor'], + status: (input.status ?? 'stable') as ModelDefinition['status'], + builtin: false, + editable: true, + } + models.push(newModel) + this.storage.writeJson('custom-models', models) + return { success: true, model: newModel } + } + + update(providerId: string, modelId: string, updates: Partial): { success: boolean; error?: string } { + const models = this.getAll() + const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) + if (index === -1) return { success: false, error: 'Custom model not found' } + models[index] = { ...models[index], ...updates } + this.storage.writeJson('custom-models', models) + return { success: true } + } + + delete(providerId: string, modelId: string): { success: boolean; error?: string } { + const models = this.getAll() + const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) + if (index === -1) return { success: false, error: 'Custom model not found' } + models.splice(index, 1) + this.storage.writeJson('custom-models', models) + return { success: true } + } +} diff --git a/packages/node-runtime/src/ai/error-formatter.ts b/packages/node-runtime/src/ai/error-formatter.ts new file mode 100644 index 000000000..c435092d2 --- /dev/null +++ b/packages/node-runtime/src/ai/error-formatter.ts @@ -0,0 +1,94 @@ +export interface FormatAIErrorOptions { + providerName?: string + rawErrorLabel?: string +} + +/** + * Parse error candidates from an LLM API error, extracting statusCode, message, and retry info. + */ +function parseErrorCandidates(error: unknown): { + rawMessage: string + statusCode: number | undefined + retrySeconds: number | undefined +} { + const candidates: unknown[] = [] + if (error) candidates.push(error) + + const errorObj = error as { lastError?: unknown; errors?: unknown[] } + if (errorObj?.lastError) candidates.push(errorObj.lastError) + if (Array.isArray(errorObj?.errors)) candidates.push(...errorObj.errors) + + let rawMessage = '' + let statusCode: number | undefined + let retrySeconds: number | undefined + + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'object') { + if (!rawMessage && typeof candidate === 'string') rawMessage = candidate + continue + } + + const record = candidate as Record + if (typeof record.statusCode === 'number') statusCode = record.statusCode + if (!rawMessage && typeof record.message === 'string') rawMessage = record.message + + if (!rawMessage && record.data && typeof record.data === 'object') { + const data = record.data as { error?: { message?: string } } + if (data.error?.message) rawMessage = data.error.message + } + + if (record.responseBody && typeof record.responseBody === 'string') { + try { + const parsed = JSON.parse(record.responseBody) as { error?: { message?: string } } + if (!rawMessage && parsed.error?.message) rawMessage = parsed.error.message + } catch { + if (!rawMessage) rawMessage = record.responseBody + } + } + + if (rawMessage) { + const retryMatch = rawMessage.match(/retry in ([0-9.]+)s/i) + if (retryMatch) retrySeconds = Math.ceil(Number(retryMatch[1])) + } + } + + return { rawMessage: rawMessage || String(error), statusCode, retrySeconds } +} + +/** + * Format LLM API errors into user-friendly messages. + * Shared between Electron and Server — provider name and i18n labels are injected via options. + */ +export function formatAIError(error: unknown, options?: FormatAIErrorOptions): string { + const { rawMessage: fallbackMessage, statusCode, retrySeconds } = parseErrorCandidates(error) + const lowerMessage = fallbackMessage.toLowerCase() + const providerName = options?.providerName || 'API' + + let friendlyMessage = '' + + if (statusCode === 429 || lowerMessage.includes('quota') || lowerMessage.includes('resource_exhausted')) { + friendlyMessage = retrySeconds + ? `${providerName} quota exhausted, please retry after ${retrySeconds}s or upgrade your quota.` + : `${providerName} quota exhausted, please retry later or upgrade your quota.` + } else if ( + statusCode === 403 && + (lowerMessage.includes('quota') || lowerMessage.includes('not enough') || lowerMessage.includes('insufficient')) + ) { + friendlyMessage = `${providerName} rejected the request due to insufficient quota or balance.` + } else if (statusCode === 503 || lowerMessage.includes('overloaded') || lowerMessage.includes('unavailable')) { + friendlyMessage = `${providerName} model is overloaded, please retry later.` + } else if (fallbackMessage.length > 300) { + friendlyMessage = `${fallbackMessage.slice(0, 300)}...` + } else { + friendlyMessage = fallbackMessage + } + + const rawErrorLabel = options?.rawErrorLabel || 'Raw error' + const details = [statusCode ? `status=${statusCode}` : null, fallbackMessage].filter(Boolean).join('; ') + + if (friendlyMessage !== fallbackMessage) { + return `${friendlyMessage}\n\n${rawErrorLabel}: ${details}` + } + + return friendlyMessage +} diff --git a/packages/node-runtime/src/ai/i18n/index.ts b/packages/node-runtime/src/ai/i18n/index.ts new file mode 100644 index 000000000..b026a72f5 --- /dev/null +++ b/packages/node-runtime/src/ai/i18n/index.ts @@ -0,0 +1,57 @@ +/** + * Lightweight AI translation module. + * + * Provides a platform-agnostic `TranslateFn` compatible with the shared + * prompt-builder. No i18next dependency — just nested object lookup + * with {{var}} interpolation and locale fallback. + */ + +import type { TranslateFn } from '../agent/prompt-builder' +import zhCN from './locales/zh-CN' +import enUS from './locales/en-US' +import jaJP from './locales/ja-JP' +import zhTW from './locales/zh-TW' + +export type { TranslateFn } + +const localeMap: Record> = { + 'zh-CN': zhCN, + 'en-US': enUS, + 'ja-JP': jaJP, + 'zh-TW': zhTW, +} + +function getNestedValue(obj: Record, path: string): unknown { + let current: unknown = obj + for (const segment of path.split('.')) { + if (current == null || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return current +} + +function interpolate(template: string, vars: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + const val = vars[key] + return val != null ? String(val) : '' + }) +} + +/** + * Create a translation function for the given locale. + * Supports `lng` option override (i18next-compatible) and {{var}} interpolation. + */ +export function createAiTranslate(defaultLocale: string = 'zh-CN'): TranslateFn { + return (key: string, options?: Record): string => { + const lng = (options?.lng as string) || defaultLocale + const locale = localeMap[lng] ? lng : 'en-US' + const value = getNestedValue(localeMap[locale], key) ?? getNestedValue(localeMap['en-US'], key) + + if (typeof value !== 'string') return key + if (!options) return value + return interpolate(value, options) + } +} + +/** All AI locale data (for consumers like Electron that compose with their own keys) */ +export const aiLocales = { 'zh-CN': zhCN, 'en-US': enUS, 'ja-JP': jaJP, 'zh-TW': zhTW } diff --git a/packages/node-runtime/src/ai/i18n/locales/en-US.ts b/packages/node-runtime/src/ai/i18n/locales/en-US.ts new file mode 100644 index 000000000..faf57b384 --- /dev/null +++ b/packages/node-runtime/src/ai/i18n/locales/en-US.ts @@ -0,0 +1,369 @@ +/** + * AI shared translations — English + */ +export default { + ai: { + tools: { + search_messages: { + desc: 'Search chat records by keywords. This is the primary evidence tool for factual questions about keywords, member messages, or whether a topic appeared. Can specify time range and sender to filter messages. Supports minute-level time queries.', + params: { + keywords: + 'List of search keywords, using OR logic to match messages containing any keyword. Pass an empty array [] to filter by sender only', + sender_id: + 'Sender member ID, used to filter messages from a specific member. Can be obtained via the get_members tool', + limit: 'Message count limit, default 1000, max 50000', + year: 'Filter messages by year, e.g. 2024', + month: 'Filter messages by month (1-12), use with year', + day: 'Filter messages by day (1-31), use with year and month', + hour: 'Filter messages by hour (0-23), use with year, month, and day', + start_time: + 'Start time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 14:00". Overrides year/month/day/hour when specified', + end_time: + 'End time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 18:30". Overrides year/month/day/hour when specified', + }, + }, + deep_search_messages: { + desc: 'Exact substring match search for chat records. Slower but never misses any message containing the keyword. Use when search_messages results are insufficient, when verifying whether a phrase really exists, or when searching partial words or single characters.', + params: { + keywords: 'List of search keywords, using substring match (LIKE). Returns messages matching any keyword', + sender_id: 'Sender member ID for filtering messages from a specific member', + limit: 'Message count limit, default 1000, max 50000', + year: 'Filter messages by year, e.g. 2024', + month: 'Filter messages by month (1-12), use with year', + day: 'Filter messages by day (1-31), use with year and month', + hour: 'Filter messages by hour (0-23), use with year, month, and day', + start_time: 'Start time, format "YYYY-MM-DD HH:mm". Overrides year/month/day/hour when specified', + end_time: 'End time, format "YYYY-MM-DD HH:mm". Overrides year/month/day/hour when specified', + }, + }, + get_recent_messages: { + desc: 'Get chat messages within a specified time period. This is the preferred evidence tool for overview questions like "what has everyone been chatting about recently", "what was discussed in month X", and latest content after new records were appended. Supports minute-level time queries.', + params: { + limit: 'Message count limit, default 100 (saves tokens, can be increased as needed)', + year: 'Filter messages by year, e.g. 2024', + month: 'Filter messages by month (1-12), use with year', + day: 'Filter messages by day (1-31), use with year and month', + hour: 'Filter messages by hour (0-23), use with year, month, and day', + start_time: + 'Start time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 14:00". Overrides year/month/day/hour when specified', + end_time: + 'End time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 18:30". Overrides year/month/day/hour when specified', + }, + }, + get_chat_overview: { + desc: 'Get basic overview of the chat: name, platform, type, total messages, total members, time range, and top active members. Use this before analysis or after appended records to confirm the current database range.', + params: { + top_n: 'Return top N active members, default 10', + }, + }, + get_member_stats: { + desc: 'Get member activity statistics. Suitable for questions like "who is the most active" or "who sends the most messages".', + params: { + top_n: 'Return top N members, default 10', + }, + }, + get_time_stats: { + desc: 'Get time distribution statistics of chat activity. Suitable for questions like "when is the group most active" or "what time do people usually chat". The returned `data` array can be passed directly as the `rows` parameter of render_chart for visualization.', + params: { + type: 'Statistics type: hourly (by hour), weekday (by day of week), daily (by date)', + }, + }, + get_members: { + desc: 'Get group member list, including basic info, aliases, and message statistics. Suitable for queries like "who is in the group", "what is someone\'s alias", or "whose ID is xxx".', + params: { + search: 'Optional search keyword to filter by member nickname, alias, or platform ID', + limit: 'Member count limit, returns all by default', + }, + }, + get_member_name_history: { + desc: 'Get member name change history. Suitable for questions like "what was someone\'s previous name", "name changes", or "former names". Requires member ID from get_members tool first.', + params: { + member_id: 'Member database ID, can be obtained via get_members tool', + }, + }, + get_conversation_between: { + desc: 'Get conversation records between two group members. Suitable for questions like "what did A and B talk about" or "view the conversation between two people". Requires member IDs from get_members first. Supports minute-level time queries.', + params: { + member_id_1: 'Database ID of the first member', + member_id_2: 'Database ID of the second member', + limit: 'Message count limit, default 100', + start_time: + 'Start time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 14:00". Overrides year/month/day/hour when specified', + end_time: + 'End time, format "YYYY-MM-DD HH:mm", e.g. "2024-03-15 18:30". Overrides year/month/day/hour when specified', + }, + }, + get_message_context: { + desc: 'Get surrounding context messages for a given message ID. Use it to verify facts around specific quoted messages, such as what was discussed before and after a message or whether a phrase has context. Supports single or batch message IDs.', + params: { + message_ids: + 'List of message IDs to query context for. Can be single or multiple IDs. Message IDs can be obtained from search_messages and other tool results', + context_size: 'Context size, i.e. how many messages before and after to retrieve, default 20', + }, + }, + get_segment_messages: { + desc: 'Get the complete message list for a specific segment. Used to get the full original context after finding a relevant segment summary via get_segment_summaries. Returns all messages and participant information.', + params: { + segment_id: 'Segment ID, can be obtained from get_segment_summaries results', + limit: 'Message count limit, default 1000. Can be limited for very long segments to save tokens', + }, + }, + get_segment_summaries: { + desc: `Get segment summary list to quickly understand discussion topics in chat history. + +Use cases: +1. Understand what topics have been discussed recently +2. Search for discussed topics by keyword +3. Overview questions like "has the group discussed travel" + +Returned summaries are brief descriptions of each segment, helping quickly locate segments of interest. Use get_segment_messages for details.`, + params: { + keywords: 'Keyword list to search within summaries (OR logic)', + limit: 'Session count limit, default 20', + year: 'Filter segments by year', + month: 'Filter segments by month (1-12)', + day: 'Filter segments by day (1-31)', + start_time: 'Start time, format "YYYY-MM-DD HH:mm"', + end_time: 'End time, format "YYYY-MM-DD HH:mm"', + }, + }, + // ===== SQL Analysis Tools ===== + message_type_breakdown: { + desc: 'Break down message types over the last N days (text, image, voice, emoji, etc.). Useful for understanding communication preferences.', + params: { days: 'Number of recent days to analyze' }, + rowTemplate: '{type_name}: {msg_count} messages ({percentage}%)', + summaryTemplate: 'Message type distribution ({rowCount} types):', + fallback: 'No messages found in this time range', + }, + peak_chat_hours_by_member: { + desc: "Analyze a specific member's hourly message distribution over the last N days to find their most active hours. Requires member_id from get_members.", + params: { + member_id: 'Member ID (from get_members)', + days: 'Number of recent days to analyze', + }, + rowTemplate: '{hour}:00 — {msg_count} messages', + summaryTemplate: 'Message volume by hour ({rowCount} active hours):', + fallback: 'This member has no messages in the specified time range', + }, + member_activity_trend: { + desc: "View a specific member's daily message count trend over the last N days. Useful for observing whether someone is becoming more or less active. Requires member_id from get_members.", + params: { + member_id: 'Member ID (from get_members)', + days: 'Number of recent days to view', + }, + rowTemplate: '{day}: {msg_count} messages', + summaryTemplate: 'This member was active on {rowCount} days:', + fallback: 'This member has no messages in the specified time range', + }, + silent_members: { + desc: 'Detect "silent members" who haven\'t sent messages for more than N days. Useful for identifying at-risk users in community management.', + params: { days: 'Days of silence to qualify' }, + rowTemplate: '{name} — silent for {silent_days} days', + summaryTemplate: 'Found {rowCount} silent members:', + fallback: 'No members found who have been silent for that long. Community engagement is healthy!', + }, + reply_interaction_ranking: { + desc: 'Analyze reply interaction rankings in the group — who replies to whom the most. Useful for discovering core interaction relationships and key opinion leaders.', + params: { + days: 'Number of recent days to analyze', + limit: 'Number of top interaction pairs to return', + }, + rowTemplate: '{replier_name} → {original_name}: {reply_count} replies', + summaryTemplate: 'Top {rowCount} reply interactions:', + fallback: 'No reply interactions found in this time range', + }, + mutual_interaction_pairs: { + desc: 'Find the most frequently interacting member pairs, based on bidirectional message timing (if one person speaks and another responds within 5 minutes, it counts as an interaction). Useful for discovering close friendships.', + params: { + days: 'Number of recent days to analyze', + limit: 'Number of top pairs to return', + }, + rowTemplate: '{member_a} ↔ {member_b}: {interaction_count} interactions', + summaryTemplate: 'Top {rowCount} most interactive pairs:', + fallback: 'No significant interaction patterns detected in this time range', + }, + member_message_length_stats: { + desc: 'Analyze average message length per member (text messages only). Longer messages often indicate more thoughtful communication. Useful for finding deep communicators.', + params: { + days: 'Number of recent days to analyze', + top_n: 'Number of top members to return', + }, + rowTemplate: '{name} — avg {avg_length} chars/msg ({msg_count} msgs, max {max_length} chars)', + summaryTemplate: 'Message length Top {rowCount} (longer = more thoughtful):', + fallback: 'Not enough text message data in this time range', + }, + unanswered_messages: { + desc: 'Find messages in the last N days that may not have been replied to — potential unresolved customer issues. Only counts text messages over 10 characters (filters out short greetings).', + params: { + days: 'Number of recent days to search', + limit: 'Maximum number of results', + }, + rowTemplate: '[{send_time}] {sender_name}: {content_preview}', + summaryTemplate: 'Found {rowCount} potentially unanswered messages:', + fallback: 'All messages have been replied to in this time range. Great service quality!', + }, + daily_active_members: { + desc: 'Count daily unique active members (DAU) and message volume to observe community vitality trends. Useful for "how is the group activity trending" or "how many people are chatting recently".', + params: { days: 'Number of recent days to analyze' }, + rowTemplate: '{day}: {active_members} active, {msg_count} messages', + summaryTemplate: 'Daily active members trend for {rowCount} days:', + fallback: 'No messages in this time range', + }, + conversation_initiator_stats: { + desc: 'Count how many times each member initiated a conversation (was the first sender in a segment). Requires segment index to be generated.', + params: { + days: 'Number of recent days to analyze', + limit: 'Number of top members to return', + }, + rowTemplate: '{name}: initiated {initiated_count} topics', + summaryTemplate: 'Topic initiator Top {rowCount}:', + fallback: 'No segment records in this time range. Session index may need to be generated first.', + }, + activity_heatmap: { + desc: 'Return a weekday × hour message count matrix for generating activity heatmaps. weekday: 0=Sun, 1=Mon, ..., 6=Sat.', + params: { days: 'Number of recent days to analyze' }, + rowTemplate: 'Weekday {weekday} {hour}:00 — {msg_count} messages', + summaryTemplate: 'Activity heatmap data ({rowCount} time slots with messages):', + fallback: 'No messages in this time range', + }, + response_time_analysis: { + desc: 'Analyze response times between messages, showing median and average reply speed per member. Useful for "how quickly do people reply" or "who replies the fastest".', + params: { + days: 'Number of recent days to analyze', + top_n: 'Number of top members to return', + }, + }, + keyword_frequency: { + desc: 'Segment text messages and rank high-frequency keywords. Supports Chinese, English, and Japanese. Useful for "what do people talk about most" or "what are the hot keywords".', + params: { + days: 'Number of recent days to analyze', + top_n: 'Number of top keywords to return', + }, + }, + get_schema: { + desc: 'Inspect the chat database schema. Use it before charting or custom SQL when table or field names are uncertain.', + params: {}, + }, + render_chart: { + desc: 'Generate a native ChatLab chart from ChartSpec v1. Supports bar, line, pie, and heatmap. Provide either `rows` (pre-fetched data array from a tool result, e.g. the `data` field from get_time_stats) or `sql` (read-only SELECT) — prefer `rows` when data is already available; use `sql` only when high-level tools cannot satisfy the need. Do not output HTML, JavaScript, SVG, ECharts options, or rendering code.', + params: { + rows: 'Pre-fetched data array from a high-level tool result (e.g. the `data` field from get_time_stats). Prefer this over sql when data is already available. Mutually exclusive with sql.', + sql: 'Read-only SELECT or WITH SELECT SQL. Use only when high-level tools cannot satisfy the need. Must return fields referenced by ChartSpec encoding.', + params: 'Named SQL parameters. Use an empty object when no parameters are needed.', + chartSpec: 'ChartSpec v1 with version, type, title, and encoding.', + maxRows: 'Maximum chart query rows. Default 1000.', + }, + }, + }, + + // ===== AI Agent system prompts ===== + agent: { + answerWithoutTools: 'Please answer based on the information already retrieved, do not call any more tools.', + toolError: 'Error: {{error}}', + currentDateIs: 'Current date is', + chatContext: { + private: 'conversation', + group: 'group chat', + }, + ownerNote: `Current user identity: +- The user's identity in this {{chatContext}} is "{{displayName}}" (platformId: {{platformId}}) +- When the user refers to "I" or "my", it refers to "{{displayName}}" +- When querying "my" messages, use the sender_id parameter to filter for this member +`, + memberNotePrivate: `Member query strategy: +- Private chats only have two participants, so the member list can be directly obtained +- When the user refers to "the other party" or "he/she", get the other participant's information via get_members +`, + memberNoteGroup: `Member query strategy: +- When the user refers to specific group members (e.g., "what did John say", "Mary's messages"), first call get_members to get the member list +- Group members have three names: accountName (original nickname), groupNickname (group nickname), aliases (user-defined aliases) +- The search parameter of get_members can be used for fuzzy searching these three names +- Once a member is found, use their id field as the sender_id parameter for search_messages to retrieve their messages +`, + mentionedMembersNote: + 'Members explicitly @-selected by the user in this round (member_id can be used directly without another search):', + timeParamsIntro: 'Time parameters: use start_time/end_time to specify the range, format "YYYY-MM-DD HH:mm"', + defaultYearNote: 'When no time range is specified, queries default to all data. Current year is {{year}}.', + dataSnapshotNote: + 'Current chat database snapshot: {{name}} ({{platform}}), {{totalMessages}} total messages, {{totalMembers}} members, time range {{firstMessageDate}} ~ {{lastMessageDate}}. This snapshot only helps judge data coverage; concrete chat facts still require tool retrieval from the current database.', + dataSnapshotContext: `Current chat database startup context: +- name: {{name}} +- platform: {{platform}} +- type: {{type}} +- total_messages: {{totalMessages}} +- total_members: {{totalMembers}} +- first_message_ts: {{firstMessageTs}} +- first_message_time: {{firstMessageTime}} +- last_message_ts: {{lastMessageTs}} +- last_message_time: {{lastMessageTime}} (coverage end of imported data — does not indicate whether the group/chat is currently active) +- segment_summaries_available: {{segmentSummaryCount}} + +{{memberHintTitle}} +{{memberHintLines}} + +Usage rules: +{{usageRules}}`, + dataSnapshotMemberHintsAll: 'Active member lookup hints (all members):', + dataSnapshotMemberHintsTop: 'Active member lookup hints (top 10 by historical total messages):', + dataSnapshotMemberHintsUnavailable: 'Active member lookup hints:', + dataSnapshotMemberHintsEmpty: 'No member hints available.', + dataSnapshotUsageRules: `- member_id is a tool lookup hint; display_name is only for human recognition and may not be unique. +- Do not proactively expose member_id or the startup context in the final answer unless the user explicitly asks for technical details. +- Active member ranking only reflects historical total message volume, not recent activity; it is also not evidence for influence, relationships, or recent trends. +- Interpret relative time expressions using the real current date, not the database's last message time. +- "recent year" / "past year" means one year back from the real current date to today; "last year" means the previous calendar year. +- Database time bounds are only for explaining coverage, not for redefining the user's requested time range. +- When using default recent-day tools, first choose a range that intersects database time bounds instead of probing an empty real-current-date window. +- Do not call tools only to rediscover min/max timestamp; concrete chat facts, statistics, and conclusions still require tool evidence. +- last_message_time is the coverage end of the imported data, not the real-world last message time of the group/chat; the user may simply not have imported newer records yet. Do not infer that the group has been inactive for a certain period, and do not suggest the user "revive" or "wake up" the group.`, + evidencePolicy: `Evidence policy: +- AI conversation history, prior AI replies, and compressed summaries are only for understanding the user's intent; they are not evidence for chat-record facts. +- Whenever the user asks about chat content, recent topics, what someone said, rankings/statistics, whether a topic appeared, or asks for exact quotes, first call an appropriate data tool to retrieve the current database. +- If new chat records were appended, the current database may differ from old window history; prefer get_chat_overview or get_recent_messages/search_messages to get fresh evidence. +- Only messages, statistics, or database overviews returned by tools may be used as factual basis. If tools do not return enough evidence, say the data is insufficient and do not fabricate nonexistent chat records.`, + currentTask: 'Current Task', + skillPriorityNote: + 'Note: When executing this task, prioritize the output format requirements below. This can override your usual response style.', + responseInstruction: + "Based on the user's question, select appropriate tools to retrieve data, then provide an answer based on the data.", + fallbackRoleDefinition: { + group: `You are a professional group chat analysis assistant. +Your task is to help users understand and analyze their group chat data. + +## Response Requirements +1. Answer based on data returned by tools, do not fabricate information +2. If data is insufficient to answer, please state so +3. Keep answers concise and clear, use Markdown format`, + private: `You are a professional private chat analysis assistant. +Your task is to help users understand and analyze their private chat data. + +## Response Requirements +1. Answer based on data returned by tools, do not fabricate information +2. If data is insufficient to answer, please state so +3. Keep answers concise and clear, use Markdown format`, + }, + }, + }, + + llm: { + notConfigured: 'LLM service not configured. Please set up an API Key in settings first.', + maxConfigs: 'Maximum of {{count}} configurations allowed', + configNotFound: 'Configuration not found', + noActiveConfig: 'No active configuration', + callFailed: 'LLM call failed. Please check your model configuration.', + genericProviderName: 'API provider', + rawErrorLabel: 'Raw error', + }, + + summary: { + segmentNotFound: 'Session not found or database could not be opened', + tooFewMessages: 'Message count less than {{count}}, no need to generate summary', + tooFewValidMessages: 'Valid message count less than {{count}}, no need to generate summary', + segmentNotExist: 'Session not found', + messagesTooFew: 'Too few messages', + validMessagesTooFew: 'Too few valid messages', + systemPromptDirect: 'You are a conversation summarization expert. Summarize conversations concisely.', + systemPromptMerge: + 'You are a conversation summarization expert skilled at merging multiple summaries into a coherent overview.', + }, +} diff --git a/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts b/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts new file mode 100644 index 000000000..0b2fa91d6 --- /dev/null +++ b/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts @@ -0,0 +1,367 @@ +/** + * AI shared translations — Japanese + */ +export default { + ai: { + tools: { + search_messages: { + desc: 'キーワードでチャット履歴を検索する。キーワード、メンバーの発言、特定トピックが出たかどうかなどの事実質問に対する主要な証拠取得ツール。時間範囲や送信者でメッセージをフィルタリングできる。分単位の精度で時間クエリをサポートする。', + params: { + keywords: + '検索キーワードリスト。OR ロジックでいずれかのキーワードを含むメッセージにマッチする。送信者のみでフィルタリングする場合は空配列 [] を渡す', + sender_id: + '送信者のメンバー ID。特定メンバーの送信メッセージをフィルタリングする。get_members ツールでメンバー ID を取得できる', + limit: '返却メッセージ数の上限。デフォルト 1000、最大 50000', + year: '指定年のメッセージをフィルタリング(例:2024)', + month: '指定月のメッセージをフィルタリング(1-12)。year と併用する必要がある', + day: '指定日のメッセージをフィルタリング(1-31)。year と month と併用する必要がある', + hour: '指定時間のメッセージをフィルタリング(0-23)。year、month、day と併用する必要がある', + start_time: + '開始時刻。形式 "YYYY-MM-DD HH:mm"(例:"2024-03-15 14:00")。指定すると year/month/day/hour パラメータを上書きする', + end_time: + '終了時刻。形式 "YYYY-MM-DD HH:mm"(例:"2024-03-15 18:30")。指定すると year/month/day/hour パラメータを上書きする', + }, + }, + deep_search_messages: { + desc: '完全部分文字列マッチでチャット履歴を検索する。速度は遅いが、キーワードを含むメッセージを漏らさない。search_messages の結果が不十分な場合、ある文言が実在するか確認する場合、または部分的な単語・単一文字を検索する場合に使用する。', + params: { + keywords: + '検索キーワードリスト。部分文字列マッチ(LIKE)を使用し、いずれかのキーワードにマッチしたメッセージを返す', + sender_id: '送信者のメンバー ID。特定メンバーの送信メッセージをフィルタリングする', + limit: '返却メッセージ数の上限。デフォルト 1000、最大 50000', + year: '指定年のメッセージをフィルタリング(例:2024)', + month: '指定月のメッセージをフィルタリング(1-12)。year と併用する必要がある', + day: '指定日のメッセージをフィルタリング(1-31)。year と month と併用する必要がある', + hour: '指定時間のメッセージをフィルタリング(0-23)。year、month、day と併用する必要がある', + start_time: '開始時刻。形式 "YYYY-MM-DD HH:mm"。指定すると year/month/day/hour パラメータを上書きする', + end_time: '終了時刻。形式 "YYYY-MM-DD HH:mm"。指定すると year/month/day/hour パラメータを上書きする', + }, + }, + get_recent_messages: { + desc: '指定期間内のチャットメッセージを取得する。「最近みんな何を話していた?」「X月に何が話題だった?」、新しい記録追加後の最新内容などの概要質問に対する優先証拠ツール。分単位の精度で時間クエリをサポートする。', + params: { + limit: '返却メッセージ数の上限。デフォルト 100(Token を節約したい場合の目安。必要に応じて増やせる)', + year: '指定年のメッセージをフィルタリング(例:2024)', + month: '指定月のメッセージをフィルタリング(1-12)。year と併用する必要がある', + day: '指定日のメッセージをフィルタリング(1-31)。year と month と併用する必要がある', + hour: '指定時間のメッセージをフィルタリング(0-23)。year、month、day と併用する必要がある', + start_time: + '開始時刻。形式 "YYYY-MM-DD HH:mm"(例:"2024-03-15 14:00")。指定すると year/month/day/hour パラメータを上書きする', + end_time: + '終了時刻。形式 "YYYY-MM-DD HH:mm"(例:"2024-03-15 18:30")。指定すると year/month/day/hour パラメータを上書きする', + }, + }, + get_chat_overview: { + desc: 'チャット記録の基本概要を取得する:グループ名、プラットフォーム、タイプ、総メッセージ数、総メンバー数、期間、最もアクティブなメンバーランキング。分析前や新しい記録追加後に、現在のデータベース範囲を確認するのに適している。', + params: { + top_n: '上位 N 名のアクティブメンバーを返却。デフォルト 10', + }, + }, + get_member_stats: { + desc: 'グループメンバーのアクティビティ統計データを取得する。「最もアクティブなのは誰?」「発言数が一番多いのは?」などの質問に適している。', + params: { + top_n: '上位 N 名のメンバーを返却。デフォルト 10', + }, + }, + get_time_stats: { + desc: 'グループチャットの時間分布統計を取得する。「いつが一番アクティブ?」「みんな何時にチャットしている?」などの質問に適している。返される `data` 配列は render_chart の `rows` パラメータとして直接使用できる。', + params: { + type: '統計タイプ:hourly(時間別)、weekday(曜日別)、daily(日別)', + }, + }, + get_members: { + desc: 'グループメンバー一覧を取得する。メンバーの基本情報、別名、メッセージ統計を含む。「グループに誰がいる?」「○○の別名は?」「QQ 番号が xxx の人は?」などの質問に向いている。', + params: { + search: '任意の検索キーワード。メンバーのニックネーム、別名、QQ 番号で絞り込む', + limit: '返却メンバー数の上限。デフォルトは全件返却', + }, + }, + get_member_name_history: { + desc: 'メンバーのニックネーム変更履歴を取得する。「○○の以前の名前は?」「○○のニックネームの変遷」「○○の旧名」などの質問に適している。事前に get_members ツールでメンバー ID を取得する必要がある。', + params: { + member_id: 'メンバーのデータベース ID。get_members ツールで取得できる', + }, + }, + get_conversation_between: { + desc: '2 人のグループメンバー間の会話履歴を取得する。「A と B は何を話していた?」「2 人のやり取りを見たい」などの質問に向いている。事前に get_members でメンバー ID を取得する必要がある。分単位の時間指定にも対応する。', + params: { + member_id_1: '1人目のメンバーのデータベース ID', + member_id_2: '2人目のメンバーのデータベース ID', + limit: '返却メッセージ数の上限。デフォルト 100', + start_time: + '開始時刻。形式 "YYYY-MM-DD HH:mm"(例:"2024-03-15 14:00")。指定すると year/month/day/hour パラメータを上書きする', + end_time: + '終了時刻。形式 "YYYY-MM-DD HH:mm"(例:"2024-03-15 18:30")。指定すると year/month/day/hour パラメータを上書きする', + }, + }, + get_message_context: { + desc: 'メッセージ ID に基づいて前後のコンテキストメッセージを取得する。具体的な引用メッセージ周辺の事実確認、例えば「このメッセージの前後で何を話していた?」「この文言には文脈があるか」を確認する場合に使用する。単一または複数のメッセージ ID をサポートする。', + params: { + message_ids: + 'コンテキストを取得するメッセージ ID リスト。単一 ID または複数 ID が可能。メッセージ ID は search_messages などのツールの返却結果から取得できる', + context_size: 'コンテキストサイズ。前後それぞれ何件のメッセージを取得するか。デフォルト 20', + }, + }, + get_segment_messages: { + desc: '指定セグメントの完全なメッセージリストを取得する。get_segment_summaries で関連セグメントの要約を見つけた後、そのセグメントの完全な原文コンテキストを取得するために使用する。セグメントの全メッセージと参加者情報を返却する。', + params: { + segment_id: 'セグメント ID。get_segment_summaries の返却結果から取得できる', + limit: '返却メッセージ数の上限。デフォルト 1000。非常に長いセグメントでは Token 節約のため件数を制限できる', + }, + }, + get_segment_summaries: { + desc: `セグメント要約リストを取得し、グループチャットの過去の議論トピックをすばやく把握する。 + +適用シーン: +1. グループで最近何が話題になっていたか知りたい +2. キーワードで過去に議論されたトピックを検索 +3. 「グループで旅行について話したことある?」などの概要的な質問 + +返却される要約は各セグメントの短い概要であり、興味のあるセグメントをすばやく特定し、get_segment_messages で詳細を取得できる。`, + params: { + keywords: '要約内で検索するキーワードリスト(OR ロジックマッチ)', + limit: '返却セグメント数の上限。デフォルト 20', + year: '指定年のセグメントをフィルタリング', + month: '指定月のセグメントをフィルタリング(1-12)', + day: '指定日のセグメントをフィルタリング(1-31)', + start_time: '開始時刻。形式 "YYYY-MM-DD HH:mm"', + end_time: '終了時刻。形式 "YYYY-MM-DD HH:mm"', + }, + }, + message_type_breakdown: { + desc: 'メッセージタイプ別に直近 N 日間のメッセージ分布を集計する(テキスト、画像、音声、スタンプなどの件数)。コミュニケーション方法の傾向を把握するのに適している。', + params: { days: '直近何日間のデータを集計するか' }, + rowTemplate: '{type_name}:{msg_count} 件({percentage}%)', + summaryTemplate: 'メッセージタイプ分布(全 {rowCount} 種類):', + fallback: 'この期間にメッセージ記録がありません', + }, + peak_chat_hours_by_member: { + desc: '指定メンバーの直近 N 日間の時間帯別発言数分布を分析し、最もアクティブな時間帯を特定する。事前に get_members で member_id を取得する必要がある。', + params: { + member_id: 'メンバー ID(get_members で取得)', + days: '直近何日間のデータを集計するか', + }, + rowTemplate: '{hour}:00 — {msg_count} 件のメッセージ', + summaryTemplate: '該当メンバーの時間帯別発言数(全 {rowCount} アクティブ時間帯):', + fallback: '指定期間内に該当メンバーの発言記録がありません', + }, + member_activity_trend: { + desc: '指定メンバーの直近 N 日間の日別発言数の変化トレンドを表示する。ある人物がよりアクティブになったか、より静かになったかを観察するのに適している。事前に get_members で member_id を取得する必要がある。', + params: { + member_id: 'メンバー ID(get_members で取得)', + days: '直近何日間のトレンドを表示するか', + }, + rowTemplate: '{day}:{msg_count} 件', + summaryTemplate: '該当メンバーの直近 {rowCount} 日間の発言記録:', + fallback: '指定期間内に該当メンバーの発言記録がありません', + }, + silent_members: { + desc: 'N 日以上発言していない「休眠メンバー」を検出する。コミュニティ運営で離脱リスクのある利用者を見つけるのに向いている。', + params: { days: '何日間未発言でサイレントとみなすか' }, + rowTemplate: '{name} — {silent_days} 日間サイレント', + summaryTemplate: '全 {rowCount} 名の休眠メンバーを検出:', + fallback: '指定日数を超えて未発言のメンバーは見つかりませんでした。コミュニティのアクティビティは良好です!', + }, + reply_interaction_ranking: { + desc: 'グループ内の返信インタラクションランキングを分析し、誰が誰に最も多く返信しているかを特定する。コミュニティのコアインタラクション関係やオピニオンリーダーの発見に適している。', + params: { + days: '直近何日間のデータを集計するか', + limit: '上位何組のインタラクション関係を返却するか', + }, + rowTemplate: '{replier_name} → {original_name}:{reply_count} 回返信', + summaryTemplate: '返信インタラクション Top {rowCount}:', + fallback: 'この期間に返信インタラクション記録がありません', + }, + mutual_interaction_pairs: { + desc: '最も頻繁にインタラクションするメンバーペアを特定する。双方向のメッセージ時間近接度に基づく(一方の発言後5分以内にもう一方も発言した場合を1回のインタラクションとみなす)。親密な友人の組み合わせの発見に適している。', + params: { + days: '直近何日間のデータを集計するか', + limit: '上位何組を返却するか', + }, + rowTemplate: '{member_a} ↔ {member_b}:{interaction_count} 回のインタラクション', + summaryTemplate: '最も頻繁にインタラクションする {rowCount} 組のペア:', + fallback: 'この期間に明らかなインタラクション関係は検出されませんでした', + }, + member_message_length_stats: { + desc: '各メンバーの平均メッセージ長(テキストメッセージのみ)を集計する。長いメッセージは通常、より丁寧なコミュニケーションを意味する。深い交流を行う人物の発見に適している。', + params: { + days: '直近何日間のデータを集計するか', + top_n: '上位何名を返却するか', + }, + rowTemplate: '{name} — 平均 {avg_length} 字/件(全 {msg_count} 件、最長 {max_length} 字)', + summaryTemplate: 'メッセージ長 Top {rowCount}(長い = より丁寧):', + fallback: 'この期間に十分なテキストメッセージデータがありません', + }, + unanswered_messages: { + desc: '直近 N 日間で返信されていないメッセージを検索する。未解決の問題である可能性がある。テキストメッセージかつ10文字以上のもののみ集計する(短い挨拶を除外)。', + params: { + days: '直近何日間のデータを検索するか', + limit: '最大何件返却するか', + }, + rowTemplate: '[{send_time}] {sender_name}:{content_preview}', + summaryTemplate: '全 {rowCount} 件の返信されていない可能性のあるメッセージ:', + fallback: 'この期間のすべてのメッセージに返信がありました。対応品質は良好です!', + }, + daily_active_members: { + desc: '日ごとのユニーク発言者数(DAU)とメッセージ数を集計し、グループの活性度の推移を観察する。「最近グループはどのくらい活発か」「何人が発言しているか」に適している。', + params: { days: '直近何日間のデータを集計するか' }, + rowTemplate: '{day}:{active_members} 人アクティブ、{msg_count} 件メッセージ', + summaryTemplate: '直近 {rowCount} 日間の日別アクティブ人数推移:', + fallback: 'この期間にメッセージの記録がありません', + }, + conversation_initiator_stats: { + desc: '各メンバーが会話を開始した回数(セグメントの最初の発言者)を集計し、誰が最も話題を切り出すかを発見する。セグメントインデックスの生成が必要。', + params: { + days: '直近何日間のデータを集計するか', + limit: '上位何名を返却するか', + }, + rowTemplate: '{name}:{initiated_count} 回話題を開始', + summaryTemplate: '話題開始者 Top {rowCount}:', + fallback: 'この期間にセグメント記録がありません。先にセグメントインデックスを生成する必要があるかもしれません', + }, + activity_heatmap: { + desc: '曜日×時間帯のメッセージ数マトリックスを返却する。活性度ヒートマップの生成に適している。weekday: 0=日曜, 1=月曜, ..., 6=土曜。', + params: { days: '直近何日間のデータを集計するか' }, + rowTemplate: '曜日{weekday} {hour}:00 — {msg_count} 件', + summaryTemplate: '活性度ヒートマップデータ(全 {rowCount} 時間帯にメッセージあり):', + fallback: 'この期間にメッセージの記録がありません', + }, + response_time_analysis: { + desc: 'メッセージ間の応答時間を分析し、メンバーごとの中央値と平均返信速度を集計する。「みんなどのくらいで返信するか」「誰が最も速く返信するか」に適している。', + params: { + days: '直近何日間のデータを集計するか', + top_n: '上位何名を返却するか', + }, + }, + keyword_frequency: { + desc: '指定期間のテキストメッセージを分詞し、高頻度キーワードをランキングする。中国語・英語・日本語の分詞に対応。「みんなが最もよく話す話題は何か」「高頻度キーワードは何か」に適している。', + params: { + days: '直近何日間のデータを集計するか', + top_n: '上位何個のキーワードを返却するか', + }, + }, + get_schema: { + desc: 'チャットデータベースのスキーマを確認します。チャート作成やカスタム SQL の前にテーブル名とフィールド名を確認してください。', + params: {}, + }, + render_chart: { + desc: 'ChartSpec v1 から ChatLab ネイティブチャートを生成します。bar、line、pie、heatmap をサポートします。`rows`(高レベルツールのデータ配列、例:get_time_stats の `data` フィールド)または `sql`(読み取り専用 SELECT)のいずれかを指定します。データがすでに取得済みの場合は `rows` を優先し、高レベルツールで対応できない場合のみ `sql` を使用します。HTML、JavaScript、SVG、ECharts option、描画コードは出力しないでください。', + params: { + rows: '高レベルツールから取得したデータ配列(例:get_time_stats の `data` フィールド)。データが取得済みの場合は sql より優先して使用します。sql とは排他。', + sql: '読み取り専用の SELECT または WITH SELECT SQL。高レベルツールで対応できない場合のみ使用します。ChartSpec encoding で参照するフィールドを返す必要があります。', + params: 'SQL の名前付きパラメータ。不要な場合は空オブジェクトを渡します。', + chartSpec: 'version、type、title、encoding を含む ChartSpec v1。', + maxRows: 'チャートクエリの最大行数。デフォルトは 1000。', + }, + }, + }, + + agent: { + answerWithoutTools: '取得済みの情報に基づいて回答してください。これ以上ツールを呼び出さないでください。', + toolError: 'エラー: {{error}}', + currentDateIs: '現在の日付は', + chatContext: { + private: '会話', + group: 'グループチャット', + }, + ownerNote: `現在のユーザー情報: +- ユーザーの{{chatContext}}における立場は「{{displayName}}」(platformId: {{platformId}}) +- ユーザーが「私」「自分の」と言った場合、「{{displayName}}」を指す +- 「私」の発言を検索する際は、sender_id パラメータで該当メンバーをフィルタリングする +`, + memberNotePrivate: `メンバー検索戦略: +- 個人チャットは2人だけなので、直接メンバー一覧を取得できる +- ユーザーが「相手」「彼/彼女」と言った場合、get_members でもう一方の情報を取得する +`, + memberNoteGroup: `メンバー検索戦略: +- ユーザーが特定のグループメンバーに言及した場合(例:「田中さんは何を言った?」「太郎の発言」など)、まず get_members でメンバー一覧を取得する +- グループメンバーには3種類の名前がある:accountName(元のニックネーム)、groupNickname(グループニックネーム)、aliases(ユーザー定義の別名) +- get_members の search パラメータでこれら3種類の名前をあいまい検索できる +- メンバーを見つけたら、その id フィールドを search_messages の sender_id パラメータとして使用して発言を取得する +`, + mentionedMembersNote: + 'このラウンドでユーザーが明示的に @ 選択したメンバー(member_id を再検索なしで直接使えます):', + timeParamsIntro: '時間パラメータ:start_time/end_time で範囲を指定。形式は "YYYY-MM-DD HH:mm"', + defaultYearNote: '時間範囲を指定しない場合は全期間が対象。現在の年は{{year}}年', + dataSnapshotNote: + '現在のチャットデータベースのスナップショット:{{name}}({{platform}})、総メッセージ {{totalMessages}} 件、メンバー {{totalMembers}} 人、期間 {{firstMessageDate}} ~ {{lastMessageDate}}。このスナップショットはデータ範囲の判断にのみ使い、具体的なチャット事実の回答には必ず現在のデータベースをツールで検索する。', + dataSnapshotContext: `現在のチャットデータベース起動コンテキスト: +- name: {{name}} +- platform: {{platform}} +- type: {{type}} +- total_messages: {{totalMessages}} +- total_members: {{totalMembers}} +- first_message_ts: {{firstMessageTs}} +- first_message_time: {{firstMessageTime}} +- last_message_ts: {{lastMessageTs}} +- last_message_time: {{lastMessageTime}} (インポート済みデータのカバレッジ終端時刻。グループ/会話が現在もアクティブかどうかは示しません) +- segment_summaries_available: {{segmentSummaryCount}} + +{{memberHintTitle}} +{{memberHintLines}} + +使用ルール: +{{usageRules}}`, + dataSnapshotMemberHintsAll: 'アクティブメンバー検索ヒント(全メンバー):', + dataSnapshotMemberHintsTop: 'アクティブメンバー検索ヒント(過去の総メッセージ数 Top 10):', + dataSnapshotMemberHintsUnavailable: 'アクティブメンバー検索ヒント:', + dataSnapshotMemberHintsEmpty: '利用可能なメンバーヒントはありません。', + dataSnapshotUsageRules: `- member_id はツール検索用のヒントです。display_name は人間が識別するためだけのもので、一意とは限りません。 +- ユーザーが技術的詳細を明示的に求めない限り、最終回答で member_id や起動コンテキスト自体を積極的に開示しないでください。 +- アクティブメンバー順位は過去の総メッセージ量だけを示し、最近の活発さを示すものではありません。また、影響力、関係性、最近の傾向の結論証拠にもなりません。 +- 相対的な時間表現は、データベースの最終メッセージ時刻ではなく、実際の現在日付を基準に解釈してください。 +- 「最近一年/過去一年」は実際の現在日付から今日までの1年間を指し、「去年」は前の暦年を指します。 +- データベースの時間境界はカバレッジ説明にのみ使い、ユーザーが求めた時間範囲を再定義するために使わないでください。 +- デフォルトの「直近 N 日」ツールを使う場合は、空の実際現在日付ウィンドウを試すのではなく、データベース時間境界と交差する範囲を先に選んでください。 +- min/max timestamp を再発見するためだけにツールを呼ばないでください。ただし、具体的なチャット事実、統計、結論には引き続きツール証拠が必要です。 +- last_message_time はインポート済みデータのカバレッジ終端時刻であり、グループ/会話の現実世界における最終発言時刻ではありません。ユーザーがまだ新しい記録をインポートしていないだけかもしれません。この時刻からグループが「どれだけ活動していない」と推測したり、グループを「活性化」するよう提案したりしないでください。`, + evidencePolicy: `証拠ポリシー: +- AI 会話履歴、過去の AI 回答、圧縮要約はユーザー意図の理解にのみ使用し、チャット記録の事実証拠として扱わない。 +- ユーザーがチャット内容、最近の話題、誰かの発言、ランキング/統計、ある話題が出たかどうか、または原文引用を求めた場合は、必ず先に適切なデータツールで現在のデータベースを検索する。 +- 新しいチャット記録が追加されている場合、現在のデータベースは古いウィンドウ履歴と異なる可能性がある。最新の証拠取得には get_chat_overview または get_recent_messages/search_messages を優先する。 +- 事実根拠として使えるのは、ツールが返したメッセージ、統計、またはデータベース概要のみ。ツールが十分な証拠を返さない場合は、データ不足と明示し、存在しないチャット記録を作らない。`, + responseInstruction: 'ユーザーの質問に応じて適切なツールを選択してデータを取得し、データに基づいて回答する。', + fallbackRoleDefinition: { + group: `あなたはプロフェッショナルだがカジュアルなスタイルのグループチャット履歴分析アシスタントです。 +ユーザーのグループチャット履歴データの理解と分析を支援し、適度にネットスラングや顔文字を使って雰囲気を和らげますが、結論の正確性に影響しないようにします。 + +## 回答要件 +1. ツールから返却されたデータに基づいて回答し、情報を捏造しない +2. データが不十分で質問に答えられない場合は、その旨を説明する +3. 回答は簡潔明瞭に、Markdown 形式を使用する +4. 適度にネットスラングや顔文字を加えてよい(強度は控えめに) +5. ネタは事実の正確さと結論の明確さに影響を与えてはならず、低俗または不快な表現は避ける`, + private: `あなたはプロフェッショナルだがカジュアルなスタイルの個人チャット履歴分析アシスタントです。 +ユーザーの個人チャット履歴データの理解と分析を支援し、適度にネットスラングや顔文字を使って雰囲気を和らげますが、結論の正確性に影響しないようにします。 + +## 回答要件 +1. ツールから返却されたデータに基づいて回答し、情報を捏造しない +2. データが不十分で質問に答えられない場合は、その旨を説明する +3. 回答は簡潔明瞭に、Markdown 形式を使用する +4. 適度にネットスラングや顔文字を加えてよい(強度は控えめに) +5. ネタは事実の正確さと結論の明確さに影響を与えてはならず、低俗または不快な表現は避ける`, + }, + }, + }, + + llm: { + notConfigured: 'LLM サービスが未設定です。先に設定で API Key を設定してください', + maxConfigs: '設定は最大 {{count}} 個まで追加できます', + configNotFound: '設定が存在しません', + noActiveConfig: 'アクティブな設定がありません', + callFailed: 'LLM 呼び出しに失敗しました。モデル設定を確認してください。', + genericProviderName: 'API プロバイダー', + rawErrorLabel: '元のエラー', + }, + + summary: { + segmentNotFound: 'セグメントが存在しないか、データベースを開けませんでした', + tooFewMessages: 'メッセージ数が{{count}}件未満のため、要約の生成は不要です', + tooFewValidMessages: '有効なメッセージ数が{{count}}件未満のため、要約の生成は不要です', + segmentNotExist: 'セグメントが存在しません', + messagesTooFew: 'メッセージが少なすぎます', + validMessagesTooFew: '有効なメッセージが少なすぎます', + systemPromptDirect: 'あなたは会話要約の専門家であり、簡潔な言葉で会話内容を要約することに長けている。', + systemPromptMerge: 'あなたは会話要約の専門家であり、複数の要約を一つのまとまった概要に統合することに長けている。', + }, +} diff --git a/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts b/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts new file mode 100644 index 000000000..53eb5782a --- /dev/null +++ b/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts @@ -0,0 +1,359 @@ +/** + * AI shared translations — Simplified Chinese + */ +export default { + ai: { + tools: { + search_messages: { + desc: '根据关键词搜索聊天记录,是回答关键词、成员发言、是否出现过某话题等事实问题的主要证据工具。可以指定时间范围和发送者来筛选消息。支持精确到分钟级别的时间查询。', + params: { + keywords: '搜索关键词列表,会用 OR 逻辑匹配包含任一关键词的消息。如果只需要按发送者筛选,可以传空数组 []', + sender_id: '发送者的成员 ID,用于筛选特定成员发送的消息。可以通过 get_members 工具获取成员 ID', + limit: '返回消息数量限制,默认 1000,最大 50000', + year: '指定年份筛选消息,如 2024', + month: '指定月份筛选消息(1-12),需配合 year 使用', + day: '指定日期筛选消息(1-31),需配合 year 和 month 使用', + hour: '指定小时筛选消息(0-23),需配合 year、month、day 使用', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后覆盖 year/month/day/hour', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后覆盖 year/month/day/hour', + }, + }, + deep_search_messages: { + desc: '精确子串匹配搜索聊天记录,速度较慢但不会遗漏任何包含关键词的消息。当 search_messages 结果不足、需要验证某段话是否真实存在、或搜索部分词/单个字符时使用。', + params: { + keywords: '搜索关键词列表,使用子串匹配(LIKE),任一关键词匹配即返回', + sender_id: '发送者的成员 ID,用于筛选特定成员发送的消息', + limit: '返回消息数量限制,默认 1000,最大 50000', + year: '指定年份筛选消息,如 2024', + month: '指定月份筛选消息(1-12),需配合 year 使用', + day: '指定日期筛选消息(1-31),需配合 year 和 month 使用', + hour: '指定小时筛选消息(0-23),需配合 year、month、day 使用', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后覆盖 year/month/day/hour', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后覆盖 year/month/day/hour', + }, + }, + get_recent_messages: { + desc: '获取指定时间段内的聊天消息,是回答"最近大家聊了什么"、"X月聊了什么"、追加新记录后最新内容等概览性问题的首选证据工具。支持精确到分钟级别的时间查询。', + params: { + limit: '返回消息数量限制,默认 100(节省 token,可根据需要增加)', + year: '指定年份筛选消息,如 2024', + month: '指定月份筛选消息(1-12),需配合 year 使用', + day: '指定日期筛选消息(1-31),需配合 year 和 month 使用', + hour: '指定小时筛选消息(0-23),需配合 year、month、day 使用', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后覆盖 year/month/day/hour', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后覆盖 year/month/day/hour', + }, + }, + get_chat_overview: { + desc: '获取聊天记录的基本概览信息,包括群名/平台/类型/总消息数/总成员数/时间跨度/最活跃成员排名。适合在分析前或追加新记录后确认当前数据库范围。', + params: { + top_n: '返回前 N 名活跃成员,默认 10', + }, + }, + get_member_stats: { + desc: '获取群成员的活跃度统计数据。适用于回答"谁最活跃"、"发言最多的是谁"等问题。', + params: { + top_n: '返回前 N 名成员,默认 10', + }, + }, + get_time_stats: { + desc: '获取群聊的时间分布统计。适用于回答"什么时候最活跃"、"大家一般几点聊天"等问题。返回的 `data` 数组可直接作为 `render_chart` 的 `rows` 参数用于绘图。', + params: { + type: '统计类型:hourly(按小时)、weekday(按星期)、daily(按日期)', + }, + }, + get_members: { + desc: '获取群成员列表,包括成员的基本信息、别名和消息统计。适用于查询"群里有哪些人"、"某人的别名是什么"、"谁的QQ号是xxx"等问题。', + params: { + search: '可选的搜索关键词,用于筛选成员昵称、别名或QQ号', + limit: '返回成员数量限制,默认返回全部', + }, + }, + get_member_name_history: { + desc: '获取成员的昵称变更历史记录。适用于回答"某人以前叫什么名字"、"某人的昵称变化"、"某人曾用名"等问题。需要先通过 get_members 工具获取成员 ID。', + params: { + member_id: '成员的数据库 ID,可以通过 get_members 工具获取', + }, + }, + get_conversation_between: { + desc: '获取两个群成员之间的对话记录。适用于回答"A和B之间聊了什么"、"查看两人的对话"等问题。需要先通过 get_members 获取成员 ID。支持精确到分钟级别的时间查询。', + params: { + member_id_1: '第一个成员的数据库 ID', + member_id_2: '第二个成员的数据库 ID', + limit: '返回消息数量限制,默认 100', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定后覆盖 year/month/day/hour', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定后覆盖 year/month/day/hour', + }, + }, + get_message_context: { + desc: '根据消息 ID 获取前后的上下文消息。适用于引用具体消息前后进行事实验证,比如"这条消息前后在聊什么"、"确认这段话是否有上下文"等。支持单个或批量消息 ID。', + params: { + message_ids: + '要查询上下文的消息 ID 列表,可以是单个 ID 或多个 ID。消息 ID 可以从 search_messages 等工具的返回结果中获取', + context_size: '上下文大小,即获取前后各多少条消息,默认 20', + }, + }, + get_segment_messages: { + desc: '获取指定段落的完整消息列表。用于在 get_segment_summaries 找到相关段落摘要后,获取该段落的完整原文上下文。返回段落的所有消息及参与者信息。', + params: { + segment_id: '段落 ID,可以从 get_segment_summaries 的返回结果中获取', + limit: '返回消息数量限制,默认 1000。对于超长段落可以限制返回数量以节省 token', + }, + }, + get_segment_summaries: { + desc: `获取段落摘要列表,快速了解群聊历史讨论的主题。 + +适用场景: +1. 了解群里最近在聊什么话题 +2. 按关键词搜索讨论过的话题 +3. 概览性问题如"群里有没有讨论过旅游" + +返回的摘要是对每个段落的简短总结,可以帮助快速定位感兴趣的段落,然后用 get_segment_messages 获取详情。`, + params: { + keywords: '在摘要中搜索的关键词列表(OR 逻辑匹配)', + limit: '返回段落数量限制,默认 20', + year: '指定年份筛选段落', + month: '指定月份筛选段落(1-12)', + day: '指定日期筛选段落(1-31)', + start_time: '开始时间,格式 "YYYY-MM-DD HH:mm"', + end_time: '结束时间,格式 "YYYY-MM-DD HH:mm"', + }, + }, + message_type_breakdown: { + desc: '按消息类型统计近 N 天的消息分布(文本、图片、语音、表情等各有多少条)。适用于了解沟通方式偏好。', + params: { days: '统计最近多少天的数据' }, + rowTemplate: '{type_name}:{msg_count} 条(占 {percentage}%)', + summaryTemplate: '消息类型分布(共 {rowCount} 种类型):', + fallback: '该时间范围内没有消息记录', + }, + peak_chat_hours_by_member: { + desc: '分析指定成员近 N 天的小时分布,找出其最活跃的时间段。需要先获取 member_id。', + params: { + member_id: '成员 ID(通过 get_members 获取)', + days: '统计最近多少天的数据', + }, + rowTemplate: '{hour}:00 — {msg_count} 条消息', + summaryTemplate: '该成员各时间段发言量(共 {rowCount} 个活跃时段):', + fallback: '指定时间范围内该成员无发言记录', + }, + member_activity_trend: { + desc: '查看指定成员近 N 天每天的发言量趋势,观察其活跃度变化。需要先获取 member_id。', + params: { + member_id: '成员 ID(通过 get_members 获取)', + days: '查看最近多少天', + }, + rowTemplate: '{day}:{msg_count} 条消息', + summaryTemplate: '该成员近 {rowCount} 天有发言记录:', + fallback: '指定时间范围内该成员无发言记录', + }, + silent_members: { + desc: '检测超过 N 天未发言的"沉默成员",适用于社群运营中识别流失风险用户。', + params: { days: '多少天未发言算沉默' }, + rowTemplate: '{name} — 已沉默 {silent_days} 天', + summaryTemplate: '共发现 {rowCount} 位沉默成员:', + fallback: '没有找到超过指定天数未发言的成员,社群活跃度良好!', + }, + reply_interaction_ranking: { + desc: '分析群内回复互动排名——谁回复谁最多。适用于发现核心互动关系和意见领袖。', + params: { + days: '统计最近多少天的数据', + limit: '返回前多少对互动关系', + }, + rowTemplate: '{replier_name} → {original_name}:回复 {reply_count} 次', + summaryTemplate: '回复互动 Top {rowCount}:', + fallback: '该时间范围内没有回复互动记录', + }, + mutual_interaction_pairs: { + desc: '发现互动最频繁的成员组合,基于双向消息时间相邻度(一方发言后5分钟内另一方也发言则记一次互动)。适用于发现亲密好友组合。', + params: { + days: '统计最近多少天的数据', + limit: '返回前多少对', + }, + rowTemplate: '{member_a} ↔ {member_b}:{interaction_count} 次互动', + summaryTemplate: '互动最频繁的 {rowCount} 对成员:', + fallback: '该时间范围内没有明显的互动关系', + }, + member_message_length_stats: { + desc: '分析各成员的平均消息长度(仅统计文本消息),消息越长通常代表交流越深入。适用于发现深度交流者。', + params: { + days: '统计最近多少天的数据', + top_n: '返回前多少名', + }, + rowTemplate: '{name} — 平均 {avg_length} 字/条(共 {msg_count} 条,最长 {max_length} 字)', + summaryTemplate: '消息长度 Top {rowCount}(越长越深入):', + fallback: '该时间范围内没有足够的文本消息数据', + }, + unanswered_messages: { + desc: '查找近 N 天内未被回复的消息,这些可能是未解决的客户问题。仅统计文本消息且内容超过 10 字的(过滤简短寒暄)。', + params: { + days: '查找最近多少天的数据', + limit: '最多返回多少条', + }, + rowTemplate: '[{send_time}] {sender_name}:{content_preview}', + summaryTemplate: '共发现 {rowCount} 条可能未被回复的消息:', + fallback: '该时间范围内所有消息都已得到回复,服务质量很好!', + }, + daily_active_members: { + desc: '统计每日独立发言人数(DAU)和消息量,用于观察群活力变化趋势。适用于"群活跃度趋势怎么样"、"最近有多少人在说话"。', + params: { days: '统计最近多少天的数据' }, + rowTemplate: '{day}:{active_members} 人活跃,{msg_count} 条消息', + summaryTemplate: '近 {rowCount} 天的每日活跃人数趋势:', + fallback: '该时间范围内没有消息记录', + }, + conversation_initiator_stats: { + desc: '统计每个成员发起段落(作为段落首条消息的发送者)的次数,找出谁最常开启话题。需要已生成段落索引。', + params: { + days: '统计最近多少天的数据', + limit: '返回前多少名', + }, + rowTemplate: '{name}:发起 {initiated_count} 次话题', + summaryTemplate: '话题发起者 Top {rowCount}:', + fallback: '该时间范围内没有段落记录,可能需要先生成段落索引', + }, + activity_heatmap: { + desc: '返回 星期×小时 的消息数矩阵,适合生成活跃度热力图。weekday: 0=周日, 1=周一, ..., 6=周六。', + params: { days: '统计最近多少天的数据' }, + rowTemplate: '星期{weekday} {hour}:00 — {msg_count} 条', + summaryTemplate: '活跃度热力图数据(共 {rowCount} 个时段有消息):', + fallback: '该时间范围内没有消息记录', + }, + response_time_analysis: { + desc: '分析消息之间的响应时间,按成员维度统计中位数和平均回复速度。适用于"大家平均多久回复消息"、"谁回复最快"。', + params: { + days: '统计最近多少天的数据', + top_n: '返回前多少名', + }, + }, + keyword_frequency: { + desc: '对指定时间段的文本消息进行分词,统计高频关键词排行。支持中英日文分词。适用于"大家最常说什么"、"高频关键词是什么"。', + params: { + days: '统计最近多少天的数据', + top_n: '返回前多少个关键词', + }, + }, + get_schema: { + desc: '查看聊天数据库表结构。绘图或自定义 SQL 前应先用它确认表名和字段名。', + params: {}, + }, + render_chart: { + desc: '根据 ChartSpec v1 生成 ChatLab 原生图表。支持 bar、line、pie、heatmap。提供 `rows`(来自高层工具的数据数组,如 get_time_stats 的 `data` 字段)或 `sql`(只读 SELECT)二选一;有现成数据时优先用 `rows`,只有高层工具无法满足时才写 `sql`。禁止输出 HTML、JavaScript、SVG、ECharts option 或渲染代码。', + params: { + rows: '来自高层工具返回的数据数组(如 get_time_stats 的 `data` 字段)。有现成数据时优先使用,与 sql 二选一。', + sql: '只读 SELECT 或 WITH SELECT SQL。仅在高层工具无法满足需求时使用,必须返回 ChartSpec encoding 中引用的字段。', + params: 'SQL 命名参数对象;不需要参数时传空对象。', + chartSpec: 'ChartSpec v1,包含 version、type、title、encoding。', + maxRows: '图表查询最大行数,默认 1000。', + }, + }, + }, + + agent: { + answerWithoutTools: '请根据已获取的信息给出回答,不要再调用工具。', + toolError: '错误: {{error}}', + currentDateIs: '当前日期是', + chatContext: { + private: '对话', + group: '群聊', + }, + ownerNote: `当前用户身份: +- 用户在{{chatContext}}中的身份是「{{displayName}}」(platformId: {{platformId}}) +- 当用户提到"我"、"我的"时,指的就是「{{displayName}}」 +- 查询"我"的发言时,使用 sender_id 参数筛选该成员 +`, + memberNotePrivate: `成员查询策略: +- 私聊只有两个人,可以直接获取成员列表 +- 当用户提到"对方"、"他/她"时,通过 get_members 获取另一方信息 +`, + memberNoteGroup: `成员查询策略: +- 当用户提到特定群成员(如"张三说过什么"、"小明的发言"等)时,应先调用 get_members 获取成员列表 +- 群成员有三种名称:accountName(原始昵称)、groupNickname(群昵称)、aliases(用户自定义别名) +- 通过 get_members 的 search 参数可以模糊搜索这三种名称 +- 找到成员后,使用其 id 字段作为 search_messages 的 sender_id 参数来获取该成员的发言 +`, + mentionedMembersNote: '本轮用户显式 @ 的成员(可直接使用 member_id,无需再次搜索):', + timeParamsIntro: '时间参数:使用 start_time/end_time 指定时间范围,格式 "YYYY-MM-DD HH:mm"', + defaultYearNote: '未指定时间范围时默认查询全部。当前年份为{{year}}年', + dataSnapshotNote: + '当前聊天数据库快照:{{name}}({{platform}}),总消息 {{totalMessages}} 条,成员 {{totalMembers}} 人,时间范围 {{firstMessageDate}} ~ {{lastMessageDate}}。该快照只用于判断数据范围;回答具体聊天事实仍必须调用工具检索当前数据库。', + dataSnapshotContext: `当前聊天数据库启动上下文: +- name: {{name}} +- platform: {{platform}} +- type: {{type}} +- total_messages: {{totalMessages}} +- total_members: {{totalMembers}} +- first_message_ts: {{firstMessageTs}} +- first_message_time: {{firstMessageTime}} +- last_message_ts: {{lastMessageTs}} +- last_message_time: {{lastMessageTime}} (数据库中已导入消息的截止时间,不代表群组/对话当前是否活跃) +- segment_summaries_available: {{segmentSummaryCount}} + +{{memberHintTitle}} +{{memberHintLines}} + +使用规则: +{{usageRules}}`, + dataSnapshotMemberHintsAll: '活跃成员查询提示(全部成员):', + dataSnapshotMemberHintsTop: '活跃成员查询提示(按历史总消息量 Top 10):', + dataSnapshotMemberHintsUnavailable: '活跃成员查询提示:', + dataSnapshotMemberHintsEmpty: '无可用成员提示。', + dataSnapshotUsageRules: `- member_id 是工具查询提示;display_name 仅用于人类识别,可能不唯一。 +- 不要在最终回答中主动暴露 member_id 或启动上下文本身,除非用户明确要求技术细节。 +- 活跃成员排行只代表历史总消息量,不代表最近活跃情况;也不足以作为影响力、关系或近期趋势结论的证据。 +- 相对时间表达以真实当前日期为基准,而不是数据库最后消息时间。 +- “最近一年/过去一年”表示从真实当前日期回推一年到今天;“去年”表示上一自然年。 +- 数据库时间边界只用于说明覆盖范围,不用于重定义用户要求的时间范围。 +- 使用默认“近 N 天”的工具时,先选择与数据库时间边界有交集的范围,不要先探测真实当前日期窗口导致空结果。 +- 不要只为了重新发现 min/max timestamp 调用工具;但回答具体聊天事实、统计和结论仍必须调用工具获取证据。 +- last_message_time 是数据库中已导入消息的截止时间,不是群组/对话在现实中最后一次发言的时间;用户可能只是还没有导入更新的记录。不要据此推断群组"多久没动静",更不要主动建议用户去"唤醒"或"激活"群组。`, + evidencePolicy: `证据策略: +- AI 对话历史、历史 AI 回复和压缩摘要只用于理解用户意图,不能作为聊天记录事实证据。 +- 只要用户询问聊天记录内容、最近聊什么、某人说过什么、统计排行、是否出现过某话题或要求引用原话,必须先调用合适的数据工具检索当前数据库。 +- 如果已追加新聊天记录,当前数据库可能不同于旧窗口历史;优先使用 get_chat_overview 或 get_recent_messages/search_messages 获取最新证据。 +- 只有工具返回的消息、统计或数据库概览可以作为事实依据;工具没有返回足够证据时,明确说明数据不足,不要编造不存在的聊天记录。`, + currentTask: '当前任务', + skillPriorityNote: '注意:在执行此任务时,请优先遵循以下任务的输出格式要求,这可以覆盖你的常规回复习惯。', + responseInstruction: '根据用户的问题,选择合适的工具获取数据,然后基于数据给出回答。', + fallbackRoleDefinition: { + group: `你是一个专业但风格轻松的群聊记录分析助手。 +你的任务是帮助用户理解和分析他们的群聊记录数据,同时可以适度使用 B 站/网络热梗和表情/颜文字活跃气氛,但不影响结论的准确性。 + +## 回答要求 +1. 基于工具返回的数据回答,不要编造信息 +2. 如果数据不足以回答问题,请说明 +3. 回答要简洁明了,使用 Markdown 格式 +4. 可以适度加入 B 站/网络热梗、表情/颜文字(强度适中) +5. 玩梗不得影响事实准确与结论清晰,避免低俗或冒犯性表达`, + private: `你是一个专业但风格轻松的私聊记录分析助手。 +你的任务是帮助用户理解和分析他们的私聊记录数据,同时可以适度使用 B 站/网络热梗和表情/颜文字活跃气氛,但不影响结论的准确性。 + +## 回答要求 +1. 基于工具返回的数据回答,不要编造信息 +2. 如果数据不足以回答问题,请说明 +3. 回答要简洁明了,使用 Markdown 格式 +4. 可以适度加入 B 站/网络热梗、表情/颜文字(强度适中) +5. 玩梗不得影响事实准确与结论清晰,避免低俗或冒犯性表达`, + }, + }, + }, + + llm: { + notConfigured: 'LLM 服务未配置,请先在设置中配置 API Key', + maxConfigs: '最多只能添加 {{count}} 个配置', + configNotFound: '配置不存在', + noActiveConfig: '没有激活的配置', + callFailed: 'LLM 调用失败,请检查模型配置是否正确', + genericProviderName: 'API 服务', + rawErrorLabel: '原始错误', + }, + + summary: { + segmentNotFound: '段落不存在或数据库打开失败', + tooFewMessages: '消息数量少于{{count}}条,无需生成摘要', + tooFewValidMessages: '有效消息数量少于{{count}}条,无需生成摘要', + segmentNotExist: '段落不存在', + messagesTooFew: '消息太少', + validMessagesTooFew: '有效消息太少', + systemPromptDirect: '你是一个对话摘要专家,擅长用简洁的语言总结对话内容。', + systemPromptMerge: '你是一个对话摘要专家,擅长将多个摘要合并成一个连贯的总结。', + }, +} diff --git a/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts b/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts new file mode 100644 index 000000000..48b460de9 --- /dev/null +++ b/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts @@ -0,0 +1,357 @@ +/** + * AI shared translations — Traditional Chinese + */ +export default { + ai: { + tools: { + search_messages: { + desc: '根據關鍵詞搜尋聊天紀錄,是回答關鍵詞、成員發言、是否出現過某話題等事實問題的主要證據工具。可以指定時間範圍和傳送者來篩選訊息。支援精確到分鐘級別的時間查詢。', + params: { + keywords: '搜尋關鍵詞清單,會用 OR 邏輯匹配包含任一關鍵詞的訊息。如果只需要按傳送者篩選,可以傳空陣列 []', + sender_id: '傳送者的成員 ID,用於篩選特定成員傳送的訊息。可以透過 get_members 工具取得成員 ID', + limit: '回傳訊息數量限制,預設 1000,最大 50000', + year: '篩選指定年份的訊息,如 2024', + month: '篩選指定月份的訊息(1-12),需要配合 year 使用', + day: '篩選指定日期的訊息(1-31),需要配合 year 和 month 使用', + hour: '篩選指定小時的訊息(0-23),需要配合 year、month 和 day 使用', + start_time: '開始時間,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定後會覆蓋 year/month/day/hour 參數', + end_time: '結束時間,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定後會覆蓋 year/month/day/hour 參數', + }, + }, + deep_search_messages: { + desc: '精確子串匹配搜尋聊天紀錄,速度較慢但不會遺漏任何包含關鍵詞的訊息。當 search_messages 結果不足、需要驗證某段話是否真實存在、或搜尋部分詞/單個字元時使用。', + params: { + keywords: '搜尋關鍵詞清單,使用子串匹配(LIKE),任一關鍵詞匹配即回傳', + sender_id: '傳送者的成員 ID,用於篩選特定成員傳送的訊息', + limit: '回傳訊息數量限制,預設 1000,最大 50000', + year: '篩選指定年份的訊息,如 2024', + month: '篩選指定月份的訊息(1-12),需要配合 year 使用', + day: '篩選指定日期的訊息(1-31),需要配合 year 和 month 使用', + hour: '篩選指定小時的訊息(0-23),需要配合 year、month 和 day 使用', + start_time: '開始時間,格式 "YYYY-MM-DD HH:mm"。指定後會覆蓋 year/month/day/hour 參數', + end_time: '結束時間,格式 "YYYY-MM-DD HH:mm"。指定後會覆蓋 year/month/day/hour 參數', + }, + }, + get_recent_messages: { + desc: '取得指定時間段內的聊天訊息,是回答「最近大家聊了什麼」、「X月聊了什麼」、追加新紀錄後最新內容等概覽性問題的首選證據工具。支援精確到分鐘級別的時間查詢。', + params: { + limit: '回傳訊息數量限制,預設 100(可節省 Token,必要時再增加)', + year: '篩選指定年份的訊息,如 2024', + month: '篩選指定月份的訊息(1-12),需要配合 year 使用', + day: '篩選指定日期的訊息(1-31),需要配合 year 和 month 使用', + hour: '篩選指定小時的訊息(0-23),需要配合 year、month 和 day 使用', + start_time: '開始時間,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定後會覆蓋 year/month/day/hour 參數', + end_time: '結束時間,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定後會覆蓋 year/month/day/hour 參數', + }, + }, + get_chat_overview: { + desc: '取得聊天記錄的基本概覽資訊,包括群名/平台/類型/總訊息數/總成員數/時間跨度/最活躍成員排名。適合在分析前或追加新紀錄後確認目前資料庫範圍。', + params: { + top_n: '回傳前 N 名活躍成員,預設 10', + }, + }, + get_member_stats: { + desc: '取得群成員的活躍度統計資料。適用於回答「誰最活躍」、「發言最多的是誰」等問題。', + params: { + top_n: '回傳前 N 名成員,預設 10', + }, + }, + get_time_stats: { + desc: '取得群聊的時間分佈統計。適用於回答「什麼時候最活躍」、「大家一般幾點聊天」等問題。返回的 `data` 陣列可直接作為 `render_chart` 的 `rows` 參數用於繪圖。', + params: { + type: '統計類型:hourly(按小時)、weekday(按星期)、daily(按日期)', + }, + }, + get_members: { + desc: '取得群成員清單,包含基本資料、別名與訊息統計。適用於查詢「群裡有哪些人」、「某人的別名是什麼」、「誰的 QQ 號是 xxx」等問題。', + params: { + search: '可選的搜尋關鍵詞,用於篩選成員暱稱、別名或 QQ 號', + limit: '回傳成員數量限制,預設回傳全部', + }, + }, + get_member_name_history: { + desc: '取得成員的暱稱變更歷史紀錄。適用於回答「某人以前叫什麼名字」、「某人的暱稱變化」、「某人曾用名」等問題。需要先透過 get_members 工具取得成員 ID。', + params: { + member_id: '成員的資料庫 ID,可以透過 get_members 工具取得', + }, + }, + get_conversation_between: { + desc: '取得兩位群成員之間的對話紀錄。適用於回答「A 和 B 之間聊了什麼」、「檢視兩人的對話」等問題。需要先透過 get_members 取得成員 ID。支援精確到分鐘級別的時間查詢。', + params: { + member_id_1: '第一個成員的資料庫 ID', + member_id_2: '第二個成員的資料庫 ID', + limit: '回傳訊息數量限制,預設 100', + start_time: '開始時間,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 14:00"。指定後會覆蓋 year/month/day/hour 參數', + end_time: '結束時間,格式 "YYYY-MM-DD HH:mm",如 "2024-03-15 18:30"。指定後會覆蓋 year/month/day/hour 參數', + }, + }, + get_message_context: { + desc: '根據訊息 ID 取得前後的上下文訊息。適用於引用具體訊息前後進行事實驗證,例如「這則訊息前後在聊什麼」、「確認這段話是否有上下文」等。支援單筆或批次訊息 ID。', + params: { + message_ids: + '要查詢上下文的訊息 ID 清單,可以是單個 ID 或多個 ID。訊息 ID 可以從 search_messages 等工具的回傳結果中取得', + context_size: '上下文大小,即取得前後各多少條訊息,預設 20', + }, + }, + get_segment_messages: { + desc: '取得指定段落的完整訊息清單。用於在 get_segment_summaries 找到相關段落摘要後,取得該段落的完整原文上下文。回傳段落的所有訊息及參與者資訊。', + params: { + segment_id: '段落 ID,可以從 get_segment_summaries 的回傳結果中取得', + limit: '回傳訊息數量限制,預設 1000。對於超長段落可以限制回傳數量以節省 token', + }, + }, + get_segment_summaries: { + desc: `取得段落摘要清單,快速了解群聊歷史討論的主題。 + +適用場景: +1. 了解群裡最近在聊什麼話題 +2. 按關鍵詞搜尋討論過的話題 +3. 概覽性問題如「群裡有沒有討論過旅遊」 + +回傳的摘要是對每個會話的簡短總結,可以幫助快速定位感興趣的會話,然後用 get_segment_messages 取得詳情。`, + params: { + keywords: '在摘要中搜尋的關鍵詞清單(OR 邏輯匹配)', + limit: '回傳會話數量限制,預設 20', + year: '篩選指定年份的會話', + month: '篩選指定月份的會話(1-12)', + day: '篩選指定日期的會話(1-31)', + start_time: '開始時間,格式 "YYYY-MM-DD HH:mm"', + end_time: '結束時間,格式 "YYYY-MM-DD HH:mm"', + }, + }, + message_type_breakdown: { + desc: '按訊息類型統計近 N 天的訊息分佈(文字、圖片、語音、表情等各有多少條)。適用於了解溝通方式偏好。', + params: { days: '統計最近多少天的資料' }, + rowTemplate: '{type_name}:{msg_count} 條(佔 {percentage}%)', + summaryTemplate: '訊息類型分佈(共 {rowCount} 種類型):', + fallback: '該時間範圍內沒有訊息紀錄', + }, + peak_chat_hours_by_member: { + desc: '分析指定成員在近 N 天內每小時的發言量分佈,找出其最活躍的時段。需要先透過 get_members 取得 member_id。', + params: { + member_id: '成員 ID(透過 get_members 取得)', + days: '統計最近多少天的資料', + }, + rowTemplate: '{hour}:00 — {msg_count} 條訊息', + summaryTemplate: '該成員各時段發言量(共 {rowCount} 個活躍時段):', + fallback: '該成員在指定時間範圍內沒有發言紀錄', + }, + member_activity_trend: { + desc: '查看指定成員近 N 天的每日發言數量變化趨勢。適用於觀察某人是否變得更活躍或更沉默。需要先透過 get_members 取得 member_id。', + params: { + member_id: '成員 ID(透過 get_members 取得)', + days: '查看最近多少天的趨勢', + }, + rowTemplate: '{day}:{msg_count} 條', + summaryTemplate: '該成員近 {rowCount} 天有發言紀錄:', + fallback: '該成員在指定時間範圍內沒有發言紀錄', + }, + silent_members: { + desc: '偵測超過 N 天未發言的「沉默成員」。適用於社群營運中發現流失風險使用者。', + params: { days: '多少天未發言算沉默' }, + rowTemplate: '{name} — 已沉默 {silent_days} 天', + summaryTemplate: '共發現 {rowCount} 位沉默成員:', + fallback: '沒有發現超過指定天數未發言的成員,社群活躍度良好!', + }, + reply_interaction_ranking: { + desc: '分析群內的回覆互動關係排行,找出誰回覆誰最多。適用於發現社群中的核心互動關係和意見領袖。', + params: { + days: '統計最近多少天的資料', + limit: '回傳前多少對互動關係', + }, + rowTemplate: '{replier_name} → {original_name}:{reply_count} 次回覆', + summaryTemplate: '回覆互動 Top {rowCount}:', + fallback: '該時間範圍內沒有回覆互動紀錄', + }, + mutual_interaction_pairs: { + desc: '找出互動最頻繁的成員對,基於雙向訊息時間接近度(一方發言後 5 分鐘內另一方也發言即視為一次互動)。適用於發現關係親密的好友組合。', + params: { + days: '統計最近多少天的資料', + limit: '回傳前多少對', + }, + rowTemplate: '{member_a} ↔ {member_b}:{interaction_count} 次互動', + summaryTemplate: '互動最頻繁的 {rowCount} 對好友:', + fallback: '該時間範圍內沒有偵測到明顯的互動關係', + }, + member_message_length_stats: { + desc: '統計各成員的平均訊息長度(僅文字訊息),長訊息通常意味著更用心的交流。適用於發現深度交流者。', + params: { + days: '統計最近多少天的資料', + top_n: '回傳前多少名', + }, + rowTemplate: '{name} — 平均 {avg_length} 字/條(共 {msg_count} 條,最長 {max_length} 字)', + summaryTemplate: '訊息長度 Top {rowCount}(更長 = 更用心):', + fallback: '該時間範圍內沒有足夠的文字訊息資料', + }, + unanswered_messages: { + desc: '查找近 N 天內未被回覆的訊息,這些可能是未解決的客戶問題。僅統計文字訊息且內容超過 10 字的(過濾簡短寒暄)。', + params: { + days: '查找最近多少天的資料', + limit: '最多回傳多少條', + }, + rowTemplate: '[{send_time}] {sender_name}:{content_preview}', + summaryTemplate: '共發現 {rowCount} 條可能未被回覆的訊息:', + fallback: '該時間範圍內所有訊息都已得到回覆,服務品質很好!', + }, + daily_active_members: { + desc: '統計每日獨立發言人數(DAU)和訊息量,用於觀察群活力變化趨勢。適用於「群活躍度趨勢怎麼樣」、「最近有多少人在說話」。', + params: { days: '統計最近多少天的資料' }, + rowTemplate: '{day}:{active_members} 人活躍,{msg_count} 條訊息', + summaryTemplate: '近 {rowCount} 天的每日活躍人數趨勢:', + fallback: '該時間範圍內沒有訊息紀錄', + }, + conversation_initiator_stats: { + desc: '統計每個成員發起會話(作為會話首條訊息的發送者)的次數,找出誰最常開啟話題。需要已產生會話索引。', + params: { + days: '統計最近多少天的資料', + limit: '回傳前多少名', + }, + rowTemplate: '{name}:發起 {initiated_count} 次話題', + summaryTemplate: '話題發起者 Top {rowCount}:', + fallback: '該時間範圍內沒有會話紀錄,可能需要先產生會話索引', + }, + activity_heatmap: { + desc: '回傳 星期×小時 的訊息數矩陣,適合產生活躍度熱力圖。weekday: 0=週日, 1=週一, ..., 6=週六。', + params: { days: '統計最近多少天的資料' }, + rowTemplate: '星期{weekday} {hour}:00 — {msg_count} 條', + summaryTemplate: '活躍度熱力圖資料(共 {rowCount} 個時段有訊息):', + fallback: '該時間範圍內沒有訊息紀錄', + }, + response_time_analysis: { + desc: '分析訊息之間的回應時間,按成員維度統計中位數和平均回覆速度。適用於「大家平均多久回覆訊息」、「誰回覆最快」。', + params: { + days: '統計最近多少天的資料', + top_n: '回傳前多少名', + }, + }, + keyword_frequency: { + desc: '對指定時間段的文字訊息進行分詞,統計高頻關鍵詞排行。支援中英日文分詞。適用於「大家最常說什麼」、「高頻關鍵詞是什麼」。', + params: { + days: '統計最近多少天的資料', + top_n: '回傳前多少個關鍵詞', + }, + }, + get_schema: { + desc: '查看聊天資料庫表結構。繪圖或自訂 SQL 前應先用它確認表名和欄位名。', + params: {}, + }, + render_chart: { + desc: '根據 ChartSpec v1 生成 ChatLab 原生圖表。支援 bar、line、pie、heatmap。提供 `rows`(來自高層工具的資料陣列,如 get_time_stats 的 `data` 欄位)或 `sql`(唯讀 SELECT)二擇一;有現成資料時優先用 `rows`,僅在高層工具無法滿足需求時才寫 `sql`。禁止輸出 HTML、JavaScript、SVG、ECharts option 或渲染程式碼。', + params: { + rows: '來自高層工具返回的資料陣列(如 get_time_stats 的 `data` 欄位)。有現成資料時優先使用,與 sql 二擇一。', + sql: '唯讀 SELECT 或 WITH SELECT SQL。僅在高層工具無法滿足需求時使用,必須返回 ChartSpec encoding 中引用的欄位。', + params: 'SQL 命名參數物件;不需要參數時傳空物件。', + chartSpec: 'ChartSpec v1,包含 version、type、title、encoding。', + maxRows: '圖表查詢最大行數,預設 1000。', + }, + }, + }, + + agent: { + answerWithoutTools: '請根據已取得的資訊給出回答,不要再呼叫工具。', + toolError: '錯誤: {{error}}', + currentDateIs: '目前日期是', + chatContext: { + private: '對話', + group: '群聊', + }, + ownerNote: `目前使用者身份: +- 使用者在{{chatContext}}中的身份是「{{displayName}}」(platformId: {{platformId}}) +- 當使用者提到「我」、「我的」時,指的就是「{{displayName}}」 +- 查詢「我」的發言時,使用 sender_id 參數篩選該成員 +`, + memberNotePrivate: `成員查詢策略: +- 私聊只有兩個人,可以直接取得成員清單 +- 當使用者提到「對方」、「他/她」時,透過 get_members 取得另一方資訊 +`, + memberNoteGroup: `成員查詢策略: +- 當使用者提到特定群成員(如「張三說過什麼」、「小明的發言」等)時,應先呼叫 get_members 取得成員清單 +- 群成員有三種名稱:accountName(原始暱稱)、groupNickname(群暱稱)、aliases(使用者自訂別名) +- 透過 get_members 的 search 參數可以模糊搜尋這三種名稱 +- 找到成員後,使用其 id 欄位作為 search_messages 的 sender_id 參數來取得該成員的發言 +`, + mentionedMembersNote: '本輪使用者顯式 @ 的成員(可直接使用 member_id,無需再次搜尋):', + timeParamsIntro: '時間參數:使用 start_time/end_time 指定時間範圍,格式 "YYYY-MM-DD HH:mm"', + defaultYearNote: '未指定時間範圍時預設查詢全部。當前年份為{{year}}年', + dataSnapshotNote: + '目前聊天資料庫快照:{{name}}({{platform}}),總訊息 {{totalMessages}} 條,成員 {{totalMembers}} 人,時間範圍 {{firstMessageDate}} ~ {{lastMessageDate}}。該快照只用於判斷資料範圍;回答具體聊天事實仍必須呼叫工具檢索目前資料庫。', + dataSnapshotContext: `目前聊天資料庫啟動上下文: +- name: {{name}} +- platform: {{platform}} +- type: {{type}} +- total_messages: {{totalMessages}} +- total_members: {{totalMembers}} +- first_message_ts: {{firstMessageTs}} +- first_message_time: {{firstMessageTime}} +- last_message_ts: {{lastMessageTs}} +- last_message_time: {{lastMessageTime}} (資料庫中已匯入訊息的截止時間,不代表群組/對話當前是否活躍) +- segment_summaries_available: {{segmentSummaryCount}} + +{{memberHintTitle}} +{{memberHintLines}} + +使用規則: +{{usageRules}}`, + dataSnapshotMemberHintsAll: '活躍成員查詢提示(全部成員):', + dataSnapshotMemberHintsTop: '活躍成員查詢提示(按歷史總訊息量 Top 10):', + dataSnapshotMemberHintsUnavailable: '活躍成員查詢提示:', + dataSnapshotMemberHintsEmpty: '無可用成員提示。', + dataSnapshotUsageRules: `- member_id 是工具查詢提示;display_name 僅用於人類識別,可能不唯一。 +- 不要在最終回答中主動暴露 member_id 或啟動上下文本身,除非使用者明確要求技術細節。 +- 活躍成員排行只代表歷史總訊息量,不代表近期活躍情況;也不足以作為影響力、關係或近期趨勢結論的證據。 +- 相對時間表達以真實目前日期為基準,而不是資料庫最後訊息時間。 +-「最近一年/過去一年」表示從真實目前日期回推一年到今天;「去年」表示上一自然年。 +- 資料庫時間邊界只用於說明覆蓋範圍,不用於重定義使用者要求的時間範圍。 +- 使用預設「近 N 天」的工具時,先選擇與資料庫時間邊界有交集的範圍,不要先探測真實目前日期視窗導致空結果。 +- 不要只為了重新發現 min/max timestamp 呼叫工具;但回答具體聊天事實、統計和結論仍必須呼叫工具取得證據。 +- last_message_time 是資料庫中已匯入訊息的截止時間,不是群組/對話在現實中最後一次發言的時間;使用者可能只是尚未匯入更新的紀錄。不要據此推斷群組「多久沒動靜」,更不要主動建議使用者去「喚醒」或「激活」群組。`, + evidencePolicy: `證據策略: +- AI 對話歷史、歷史 AI 回覆和壓縮摘要只用於理解使用者意圖,不能作為聊天紀錄事實證據。 +- 只要使用者詢問聊天紀錄內容、最近聊什麼、某人說過什麼、統計排行、是否出現過某話題或要求引用原話,必須先呼叫合適的資料工具檢索目前資料庫。 +- 如果已追加新聊天紀錄,目前資料庫可能不同於舊視窗歷史;優先使用 get_chat_overview 或 get_recent_messages/search_messages 取得最新證據。 +- 只有工具回傳的訊息、統計或資料庫概覽可以作為事實依據;工具沒有回傳足夠證據時,明確說明資料不足,不要編造不存在的聊天紀錄。`, + responseInstruction: '根據使用者的問題,選擇合適的工具取得資料,然後基於資料給出回答。', + fallbackRoleDefinition: { + group: `你是一個專業但風格輕鬆的群聊紀錄分析助手。 +你的任務是幫助使用者理解和分析他們的群聊紀錄資料,同時可以適度使用網路熱梗和表情/顏文字活躍氣氛,但不影響結論的準確性。 + +## 回答要求 +1. 基於工具回傳的資料回答,不要編造資訊 +2. 如果資料不足以回答問題,請說明 +3. 回答要簡潔明瞭,使用 Markdown 格式 +4. 可以適度加入網路熱梗、表情/顏文字(強度適中) +5. 玩梗不得影響事實準確與結論清晰,避免低俗或冒犯性表達`, + private: `你是一個專業但風格輕鬆的私聊紀錄分析助手。 +你的任務是幫助使用者理解和分析他們的私聊紀錄資料,同時可以適度使用網路熱梗和表情/顏文字活躍氣氛,但不影響結論的準確性。 + +## 回答要求 +1. 基於工具回傳的資料回答,不要編造資訊 +2. 如果資料不足以回答問題,請說明 +3. 回答要簡潔明瞭,使用 Markdown 格式 +4. 可以適度加入網路熱梗、表情/顏文字(強度適中) +5. 玩梗不得影響事實準確與結論清晰,避免低俗或冒犯性表達`, + }, + }, + }, + + llm: { + notConfigured: 'LLM 服務尚未設定,請先在設定中填入 API Key', + maxConfigs: '最多只能新增 {{count}} 組設定', + configNotFound: '找不到設定', + noActiveConfig: '沒有啟用中的設定', + callFailed: 'LLM 呼叫失敗,請檢查模型設定是否正確', + genericProviderName: 'API 服務', + rawErrorLabel: '原始錯誤', + }, + + summary: { + segmentNotFound: '會話不存在或資料庫開啟失敗', + tooFewMessages: '訊息數量少於 {{count}} 條,無需產生摘要', + tooFewValidMessages: '有效訊息數量少於 {{count}} 條,無需產生摘要', + segmentNotExist: '找不到會話', + messagesTooFew: '訊息太少', + validMessagesTooFew: '有效訊息太少', + systemPromptDirect: '你是一個對話摘要專家,擅長用簡潔的語言總結對話內容。', + systemPromptMerge: '你是一個對話摘要專家,擅長將多個摘要合併成一個連貫的總結。', + }, +} diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts new file mode 100644 index 000000000..b0167f60e --- /dev/null +++ b/packages/node-runtime/src/ai/index.ts @@ -0,0 +1,185 @@ +/** + * AI 模块(Node.js 实现) + * + * 助手/技能 MD 文件解析器、共享类型、对话管理、Agent Core。 + */ + +// AI Logger +export { AiLogger, extractErrorInfo, extractErrorStack } from './ai-logger' + +// Error formatting +export { formatAIError } from './error-formatter' +export type { FormatAIErrorOptions } from './error-formatter' + +export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './types' +export { parseAssistantFile, serializeAssistant } from './assistant-parser' + +// Assistant Manager +export { AssistantManager } from './assistant-manager' +export type { + AssistantInitResult, + AssistantSaveResult, + BuiltinAssistantInfo, + AssistantManagerFs, + AssistantManagerDeps, +} from './assistant-manager' +export { parseSkillFile, extractSkillId } from './skill-parser' +export { AIChatManager } from './chats' +export type { AIChat, AIMessage, AIMessageRole, ContentBlock, TokenUsageData, AIChatManagerLogger } from './chats' + +// Tokenizer +export { countTokens, countMessagesTokens, initTokenizer } from './tokenizer' + +// SkillManager (runtime: activate-skill tool builder) +export { SkillManager } from './skill-manager' + +// SkillManagerCore (CRUD, shared) +export { SkillManagerCore } from './skill-manager-core' +export type { + SkillInitResult, + SkillSaveResult as SkillManagerSaveResult, + BuiltinSkillInfo, + SkillManagerFs, + SkillManagerCoreDeps, +} from './skill-manager-core' +export type { SkillManagerLogger } from './skill-manager' +export { createActivateSkillTool } from './activate-skill-tool' +export type { ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResult } from './activate-skill-tool' +export { + CHART_CAPABILITY_ANALYSIS_TOOLS, + CHART_CAPABILITY_CORE_TOOLS, + CHART_CAPABILITY_SKILL_ID, + buildSkillMenuWithBuiltinChart, + getAllowedBuiltinToolsForChartAutoSkill, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, + getChartCapabilitySkill as getBuiltinChartSkill, + getChartPlannerCapabilityForMessage, + getSkillConfigWithBuiltinChart, + resolveChartRuntimeForRequest, + shouldOfferChartCapabilityForAnalyticalMessage, + shouldUseChartCapabilityForMessage, +} from './chart-runtime' +export { CHART_SCHEMA_REQUIRED_MESSAGE, createChartSchemaGateState, wrapWithChartSchemaGate } from './chart-schema-gate' +export type { ChartSchemaGateState } from './chart-schema-gate' + +// Compression +export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './compression' +export { checkAndCompress, manualCompress, createCompressionLlmAdapter } from './compression' +export type { CreateCompressionLlmAdapterOptions } from './compression' + +// Preprocessor +export type { + PreprocessConfig, + PreprocessableMessage, + DesensitizeRule, + DesensitizeRuleGroup, + TruncationStrategy, + PreprocessLogger, +} from './preprocessor' +export { + preprocessMessages, + BUILTIN_DESENSITIZE_RULES, + DESENSITIZE_RULES_SCHEMA_VERSION, + applyDesensitizeRuleOverrides, + getDefaultRulesForLocale, + getRuleGroupsForLocale, + mergeRulesForLocale, + formatMessageCompact, + formatTimeRange, + formatToolResultAsText, + anonymizeMessageNames, + truncateFormattedMessages, + isChineseLocale, + i18nTexts, + t, + applyPreprocessingPipeline, +} from './preprocessor' +export type { PreprocessingPipelineOptions, PreprocessingPipelineResult } from './preprocessor' + +// Agent Core +export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './agent' +export { DEFAULT_MAX_TOOL_ROUNDS, createLlmRouteDecider, decideRequestRoute, runAgentCore } from './agent' +export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './agent' +export { buildPlanGuidance, createAnalysisPlanner, createPlanContentBlock } from './agent' +export { createDataSnapshotFromOverview } from './agent' +export { buildSemanticSearchGuidance } from './agent' +export type { + AnalysisPlanIntent, + AnalysisPlanner, + AnalysisPlanStep, + AnalysisPlanSummary, + PlannerCapabilitySummary, + PlannerInput, + PlanContentBlock, + PlanDraftContentBlock, + ChatOverviewForSnapshot, +} from './agent' + +// Agent Event Handler +export { AgentEventHandler, estimateTokensFromText } from './agent/event-handler' +export type { + TokenUsage, + AgentRuntimeStatus, + AgentStreamChunk, + EventHandlerConfig, + EventHandlerContext, +} from './agent/event-handler' + +// Agent Prompt Builder +export { buildSystemPrompt } from './agent/prompt-builder' +export type { + BuildSystemPromptOptions, + DataSnapshot, + OwnerInfo, + MentionedMember, + SkillContext, + TranslateFn, +} from './agent/prompt-builder' + +// AI i18n (shared translations for agent prompts and tool descriptions) +export { createAiTranslate, aiLocales } from './i18n' + +// Summary generation +export { + generateSessionSummary, + generateSessionSummaries, + checkSessionsCanGenerateSummary, + isValidMessage, + filterValidMessages, + splitIntoSegments, +} from './summary' +export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult, SummaryStrategy } from './summary' + +// LLM Config Store +export { LLMConfigStore, MAX_CONFIG_COUNT } from './llm-config-store' +export type { AIServiceConfig, AIConfigStore, ConfigStorage, LLMConfigStoreDeps } from './llm-config-store' + +// Custom Provider/Model Store +export { CustomProviderStore, CustomModelStore } from './custom-store' + +// LLM Model Builder +export { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from './llm-builder' +export type { PiModelConfig, BuildPiModelOptions } from './llm-builder' + +// Remote LLM API +export { fetchRemoteModels, validateApiKey } from './remote-api' +export type { RemoteModel, FetchRemoteModelsResult, RemoteApiOptions } from './remote-api' + +// Re-exports from @earendil-works/pi-agent-core +export type { AgentTool, AgentToolResult } from '@earendil-works/pi-agent-core' + +// LLM simple streaming +export { runSimpleLlmStream } from './llm-stream' +export type { LlmStreamChunk, RunSimpleLlmStreamOptions } from './llm-stream' + +// Re-exports from @earendil-works/pi-ai +export { Type, completeSimple, streamSimple } from '@earendil-works/pi-ai' +export type { + Model as PiModel, + Api as PiApi, + Message as PiMessage, + Usage as PiUsage, + TextContent as PiTextContent, + AssistantMessage as PiAssistantMessage, +} from '@earendil-works/pi-ai' diff --git a/packages/node-runtime/src/ai/llm-builder.ts b/packages/node-runtime/src/ai/llm-builder.ts new file mode 100644 index 000000000..6e2fdd419 --- /dev/null +++ b/packages/node-runtime/src/ai/llm-builder.ts @@ -0,0 +1,175 @@ +/** + * Shared PiModel builder — translates an AIServiceConfig-like + * object into a PiModel instance for @earendil-works/pi-ai. + * + * Used by both Electron and Server to eliminate duplicated + * URL normalization, apiFormat mapping, and model construction. + */ + +import { + BUILTIN_PROVIDERS, + getBuiltinModelsByProvider, + BUILTIN_MODELS, + type ModelDefinition, + isReasoningModel, + getThinkingCompat, +} from '@openchatlab/core' +import type { Model as PiModel, Api as PiApi } from '@earendil-works/pi-ai' + +export interface PiModelConfig { + provider: string + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string +} + +export interface BuildPiModelOptions { + /** Override model definition lookup (e.g. to include custom models). */ + findModelFn?: (providerId: string, modelId: string) => ModelDefinition | null + /** Extra headers injected into the PiModel (e.g. User-Agent). */ + headers?: Record +} + +/** + * Strip /v1 suffix from Anthropic baseUrl because the SDK + * internally appends /v1/messages. + */ +export function normalizeAnthropicBaseUrl(url: string): string { + return url.replace(/\/v1\/?$/, '') +} + +/** + * Auto-append /v1 to OpenAI-compatible URLs when the path is empty + * (users frequently forget this). + */ +export function normalizeOpenAICompatibleBaseUrl(url: string): string { + if (!url) return url + const trimmed = url.replace(/\/+$/, '') + if (trimmed.endsWith('/v1')) return trimmed + try { + const parsed = new URL(trimmed) + if (parsed.pathname === '/' || parsed.pathname === '') { + return trimmed + '/v1' + } + } catch { + // URL parse failure — return as-is + } + return trimmed +} + +const DEFAULT_CONTEXT_WINDOW = 128000 + +function defaultFindModel(providerId: string, modelId: string): ModelDefinition | null { + const forProvider = getBuiltinModelsByProvider(providerId) + return forProvider.find((m) => m.id === modelId) || BUILTIN_MODELS.find((m) => m.id === modelId) || null +} + +const BUILTIN_PROVIDER_API: Record = { + gemini: 'google-generative-ai', + anthropic: 'anthropic-messages', +} + +/** + * Infer reasoning flag and pi-ai compat fragment for a model. + * + * Uses the catalog's capabilities array as the authoritative source; + * falls back to the thinking-module's heuristic for unlisted custom models. + * The compat fragment is sourced from getThinkingCompat() (thinking.ts) which + * carries supportsReasoningEffort + thinkingLevelMap so pi-ai can inject the + * correct request-body param for each provider family. + */ +interface InferReasoningResult { + reasoning: boolean + compat: PiModel['compat'] + thinkingLevelMap?: PiModel['thinkingLevelMap'] +} + +function inferReasoning(provider: string, modelId: string, modelDef: ModelDefinition | null): InferReasoningResult { + // Builtin catalog models: trust capabilities array exclusively. + // Custom (builtin=false) or unlisted models: also check name heuristic as fallback because + // auto-saved custom model entries carry only capabilities:['chat'] even for reasoning models. + const reasoning = + modelDef?.capabilities.includes('reasoning') || (!modelDef?.builtin && isReasoningModel(provider, modelId)) + + if (!reasoning) return { reasoning: false, compat: undefined } + + const thinkingCompat = getThinkingCompat(provider, modelId) + if (Object.keys(thinkingCompat).length === 0) { + return { reasoning: true, compat: undefined } + } + + // thinkingLevelMap belongs on the Model top-level (for clampThinkingLevel), + // while thinkingFormat/supportsReasoningEffort belong in compat (for the provider). + const { thinkingLevelMap, ...compatFields } = thinkingCompat + return { + reasoning: true, + compat: Object.keys(compatFields).length > 0 ? compatFields : undefined, + thinkingLevelMap: thinkingLevelMap as PiModel['thinkingLevelMap'], + } +} + +export function buildPiModel(config: PiModelConfig, options?: BuildPiModelOptions): PiModel { + const providerDef = BUILTIN_PROVIDERS.find((p) => p.id === config.provider) + const baseUrl = config.baseUrl || providerDef?.defaultBaseUrl || '' + const modelId = config.model || '' + + const findModel = options?.findModelFn ?? defaultFindModel + const modelDef = findModel(config.provider, modelId) + const contextWindow = modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW + + const apiFormat: PiApi = (config.apiFormat as PiApi) || BUILTIN_PROVIDER_API[config.provider] || 'openai-completions' + + if (apiFormat === 'google-generative-ai') { + return { + id: modelId, + name: modelId, + api: 'google-generative-ai', + provider: 'google', + baseUrl, + reasoning: isReasoningModel(config.provider, modelId), + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: config.maxTokens ?? 8192, + } + } + + if (apiFormat === 'anthropic-messages') { + return { + id: modelId, + name: modelId, + api: 'anthropic-messages', + provider: 'anthropic', + baseUrl: normalizeAnthropicBaseUrl(baseUrl), + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: config.maxTokens ?? 8192, + } + } + + const resolvedBaseUrl = + config.provider === 'openai-compatible' && (apiFormat === 'openai-completions' || apiFormat === 'openai-responses') + ? normalizeOpenAICompatibleBaseUrl(baseUrl) + : baseUrl + + const { reasoning, compat, thinkingLevelMap } = inferReasoning(config.provider, modelId, modelDef) + + return { + id: modelId, + name: modelId, + api: apiFormat, + provider: config.provider, + baseUrl: resolvedBaseUrl, + headers: options?.headers, + reasoning, + thinkingLevelMap, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: config.maxTokens ?? 4096, + compat, + } +} diff --git a/packages/node-runtime/src/ai/llm-config-store.ts b/packages/node-runtime/src/ai/llm-config-store.ts new file mode 100644 index 000000000..c8174fb6b --- /dev/null +++ b/packages/node-runtime/src/ai/llm-config-store.ts @@ -0,0 +1,314 @@ +/** + * LLM configuration CRUD store — platform-agnostic. + * Uses ConfigStorage abstraction for persistence and dependency injection + * for i18n, UUID generation, and auth profile management. + */ + +import type { ModelSlot } from '@openchatlab/core' + +// ==================== Types ==================== + +export interface AIServiceConfig { + id: string + name: string + provider: string + apiKey: string + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string + customModels?: Array<{ id: string; name: string }> + createdAt: number + updatedAt: number +} + +export interface AIConfigStore { + configs: AIServiceConfig[] + defaultAssistant: ModelSlot | null + fastModel: ModelSlot | null +} + +export const MAX_CONFIG_COUNT = 99 + +// ==================== Storage abstraction ==================== + +export interface ConfigStorage { + readJson(key: string): T | null + writeJson(key: string, data: T): void +} + +// ==================== Dependencies ==================== + +export interface LLMConfigStoreDeps { + t?: (key: string, options?: Record) => string + generateId?: () => string + /** Returns the auth profile name so LLMConfigStore can persist it on the config */ + onApiKeyCreated?: (config: AIServiceConfig, apiKey: string) => string | void + /** Called after a config is removed; use to clean up the corresponding auth profile */ + onApiKeyDeleted?: (config: AIServiceConfig) => void + resolveApiKey?: (provider: string, authProfile?: string) => string | undefined + onStoreLoaded?: (configs: AIServiceConfig[]) => void +} + +// ==================== Config Store ==================== + +function defaultT(key: string, options?: Record): string { + if (key === 'llm.maxConfigs' && options?.count) return `Maximum ${options.count} configurations allowed` + if (key === 'llm.configNotFound') return 'Configuration not found' + return key +} + +function defaultGenerateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +function resolveSlot(slot: ModelSlot | null | undefined, configs: AIServiceConfig[]): ModelSlot | null { + if (slot && configs.some((c) => c.id === slot.configId)) return slot + const fallback = configs[0] + return fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null +} + +function getAuthProfile(config: AIServiceConfig): string | undefined { + return (config as unknown as Record).authProfile as string | undefined +} + +function isAuthProfileUsed(configs: AIServiceConfig[], authProfile: string, profileProvider?: string): boolean { + return configs.some((config) => { + const configAuthProfile = getAuthProfile(config) + if (configAuthProfile === authProfile) return true + return !configAuthProfile && profileProvider !== undefined && config.provider === profileProvider + }) +} + +export class LLMConfigStore { + private storage: ConfigStorage + private t: (key: string, options?: Record) => string + private generateId: () => string + private onApiKeyCreated?: (config: AIServiceConfig, apiKey: string) => string | void + private onApiKeyDeleted?: (config: AIServiceConfig) => void + private resolveApiKey?: (provider: string, authProfile?: string) => string | undefined + private onStoreLoaded?: (configs: AIServiceConfig[]) => void + + constructor(storage: ConfigStorage, deps: LLMConfigStoreDeps = {}) { + this.storage = storage + this.t = deps.t || defaultT + this.generateId = deps.generateId || defaultGenerateId + this.onApiKeyCreated = deps.onApiKeyCreated + this.onApiKeyDeleted = deps.onApiKeyDeleted + this.resolveApiKey = deps.resolveApiKey + this.onStoreLoaded = deps.onStoreLoaded + } + + loadStore(): AIConfigStore { + const store = this.storage.readJson('llm-config') + if (!store) { + return { configs: [], defaultAssistant: null, fastModel: null } + } + + this.onStoreLoaded?.(store.configs) + + const resolvedConfigs = this.resolveApiKey + ? store.configs.map((config) => { + const profileKey = this.resolveApiKey!( + config.provider, + (config as unknown as Record).authProfile as string | undefined + ) + return { ...config, apiKey: profileKey || config.apiKey || '' } + }) + : store.configs + + return { + ...store, + configs: resolvedConfigs, + defaultAssistant: resolveSlot(store.defaultAssistant, resolvedConfigs), + fastModel: resolveSlot(store.fastModel, resolvedConfigs), + } + } + + saveStore(store: AIConfigStore): void { + this.storage.writeJson('llm-config', { + ...store, + configs: store.configs.map((config) => ({ + ...config, + apiKey: '', + })), + }) + } + + getAllConfigs(): AIServiceConfig[] { + return this.loadStore().configs + } + + getDefaultAssistantSlot(): ModelSlot | null { + const store = this.loadStore() + return resolveSlot(store.defaultAssistant, store.configs) + } + + getDefaultAssistantConfig(): AIServiceConfig | null { + const store = this.loadStore() + const slot = resolveSlot(store.defaultAssistant, store.configs) + if (!slot) return null + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) return null + return { ...config, model: slot.modelId || config.model } + } + + getFastModelSlot(): ModelSlot | null { + const store = this.loadStore() + if (store.fastModel === null) return null + return resolveSlot(store.fastModel, store.configs) + } + + getFastModelConfig(): AIServiceConfig | null { + const store = this.loadStore() + if (store.fastModel === null) return this.getDefaultAssistantConfig() + + const slot = resolveSlot(store.fastModel, store.configs) + if (slot) { + const config = store.configs.find((c) => c.id === slot.configId) + if (config) return { ...config, model: slot.modelId || config.model } + } + return this.getDefaultAssistantConfig() + } + + getConfigById(id: string): AIServiceConfig | null { + const store = this.loadStore() + return store.configs.find((c) => c.id === id) || null + } + + addConfig(config: Omit): { + success: boolean + config?: AIServiceConfig + error?: string + } { + const store = this.loadStore() + + if (store.configs.length >= MAX_CONFIG_COUNT) { + return { success: false, error: this.t('llm.maxConfigs', { count: MAX_CONFIG_COUNT }) } + } + + const now = Date.now() + const newConfig: AIServiceConfig = { + ...config, + id: this.generateId(), + createdAt: now, + updatedAt: now, + } + + store.configs.push(newConfig) + + if (store.configs.length === 1) { + store.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' } + } + + if (newConfig.apiKey && this.onApiKeyCreated) { + const profileName = this.onApiKeyCreated(newConfig, newConfig.apiKey) + if (profileName) { + ;(newConfig as unknown as Record).authProfile = profileName + } + } + + this.saveStore(store) + return { success: true, config: { ...newConfig, apiKey: '' } } + } + + updateConfig( + id: string, + updates: Partial> + ): { success: boolean; error?: string } { + const store = this.loadStore() + const index = store.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: this.t('llm.configNotFound') } + } + + const oldConfig = store.configs[index] + const oldProfileName = (oldConfig as unknown as Record).authProfile as string | undefined + + const updated = { + ...oldConfig, + ...updates, + updatedAt: Date.now(), + } + store.configs[index] = updated + let oldProfileToDelete: AIServiceConfig | null = null + + if (updates.apiKey && this.onApiKeyCreated) { + const profileName = this.onApiKeyCreated(updated, updates.apiKey) + if (profileName) { + ;(store.configs[index] as unknown as Record).authProfile = profileName + if ( + oldProfileName && + oldProfileName !== profileName && + !isAuthProfileUsed(store.configs, oldProfileName, oldConfig.provider) + ) { + oldProfileToDelete = oldConfig + } + } + } + + this.saveStore(store) + if (oldProfileToDelete) this.onApiKeyDeleted?.(oldProfileToDelete) + return { success: true } + } + + deleteConfig(id: string): { success: boolean; error?: string } { + const store = this.loadStore() + const index = store.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: this.t('llm.configNotFound') } + } + + const deleted = store.configs[index] + store.configs.splice(index, 1) + + const fallback = store.configs[0] + if (store.defaultAssistant?.configId === id) { + store.defaultAssistant = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null + } + if (store.fastModel?.configId === id) { + store.fastModel = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null + } + + this.saveStore(store) + const deletedProfileName = getAuthProfile(deleted) + if (!deletedProfileName || !isAuthProfileUsed(store.configs, deletedProfileName, deleted.provider)) { + this.onApiKeyDeleted?.(deleted) + } + return { success: true } + } + + setDefaultAssistantModel(configId: string, modelId: string): { success: boolean; error?: string } { + const store = this.loadStore() + const config = store.configs.find((c) => c.id === configId) + + if (!config) { + return { success: false, error: this.t('llm.configNotFound') } + } + + store.defaultAssistant = { configId, modelId } + this.saveStore(store) + return { success: true } + } + + setFastModel(slot: ModelSlot | null): { success: boolean; error?: string } { + const store = this.loadStore() + + if (slot !== null) { + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) { + return { success: false, error: this.t('llm.configNotFound') } + } + } + + store.fastModel = slot + this.saveStore(store) + return { success: true } + } + + hasActiveConfig(): boolean { + return this.getDefaultAssistantConfig() !== null + } +} diff --git a/packages/node-runtime/src/ai/llm-stream.ts b/packages/node-runtime/src/ai/llm-stream.ts new file mode 100644 index 000000000..f4a479d33 --- /dev/null +++ b/packages/node-runtime/src/ai/llm-stream.ts @@ -0,0 +1,119 @@ +/** + * Shared LLM simple streaming — used by both Electron and Server. + * + * Wraps pi-ai's streamSimple with message preprocessing, + * event mapping, and error handling. + */ + +import { streamSimple } from '@earendil-works/pi-ai' +import type { Model as PiModel, Api as PiApi, Message as PiMessage } from '@earendil-works/pi-ai' + +export interface LlmStreamChunk { + content: string + isFinished: boolean + finishReason?: 'stop' | 'length' | 'error' + error?: string + /** When present, this chunk carries thinking/reasoning content rather than final text. */ + thinking?: string + /** Signals the end of a thinking block (thinking is empty string, thinkingDone is true). */ + thinkingDone?: boolean +} + +export interface RunSimpleLlmStreamOptions { + messages: Array<{ role: string; content: string }> + apiKey: string + piModel: PiModel + temperature?: number + maxTokens?: number + onChunk: (chunk: LlmStreamChunk) => void + abortSignal?: AbortSignal +} + +function toPiMessages(messages: Array<{ role: string; content: string }>, timestamp: number): PiMessage[] { + return messages.map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + timestamp, + })) as unknown as PiMessage[] +} + +/** + * Run a simple LLM streaming call. + * Separates system message, streams text deltas via onChunk callback. + */ +export async function runSimpleLlmStream(options: RunSimpleLlmStreamOptions): Promise { + const { messages, apiKey, piModel, temperature, maxTokens, onChunk, abortSignal } = options + + const systemMsg = messages.find((m) => m.role === 'system') + const nonSystemMsgs = messages.filter((m) => m.role !== 'system') + const now = Date.now() + + const eventStream = streamSimple( + piModel, + { + systemPrompt: systemMsg?.content, + messages: toPiMessages(nonSystemMsgs, now), + }, + { apiKey, temperature, maxTokens, signal: abortSignal } + ) + + let hasTerminalChunk = false + + try { + for await (const event of eventStream) { + if (abortSignal?.aborted) { + if (!hasTerminalChunk) { + hasTerminalChunk = true + onChunk({ content: '', isFinished: true, finishReason: 'stop' }) + } + return + } + + if (event.type === 'thinking_start') { + onChunk({ content: '', isFinished: false, thinking: '' }) + continue + } + + if (event.type === 'thinking_delta') { + onChunk({ content: '', isFinished: false, thinking: event.delta }) + continue + } + + if (event.type === 'thinking_end') { + onChunk({ content: '', isFinished: false, thinking: '', thinkingDone: true }) + continue + } + + if (event.type === 'text_delta') { + onChunk({ content: event.delta, isFinished: false }) + continue + } + + if (event.type === 'done') { + hasTerminalChunk = true + onChunk({ content: '', isFinished: true, finishReason: event.reason === 'length' ? 'length' : 'stop' }) + return + } + + if (event.type === 'error') { + hasTerminalChunk = true + const errorMsg = + event.error?.content + ?.filter((c) => c.type === 'text') + .map((c) => ('text' in c ? c.text : '')) + .join('') || 'Unknown LLM error' + onChunk({ content: '', isFinished: true, finishReason: 'error', error: errorMsg }) + return + } + } + + if (!hasTerminalChunk) { + onChunk({ content: '', isFinished: true, finishReason: 'stop' }) + } + } catch (error) { + if (!hasTerminalChunk) { + const msg = error instanceof Error ? error.message : String(error) + onChunk({ content: '', isFinished: true, finishReason: 'error', error: msg }) + } + } +} diff --git a/packages/node-runtime/src/ai/preprocessor/builtin-rules.test.ts b/packages/node-runtime/src/ai/preprocessor/builtin-rules.test.ts new file mode 100644 index 000000000..d869a3b62 --- /dev/null +++ b/packages/node-runtime/src/ai/preprocessor/builtin-rules.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { applyDesensitizeRuleOverrides, getDefaultRulesForLocale, getRuleGroupsForLocale } from './builtin-rules' + +test('groups built-in desensitize rules by global groups and current locale', () => { + const groups = getRuleGroupsForLocale('zh-CN') + const groupIds = groups.map((group) => group.id) + + assert.deepEqual(groupIds, ['credentials', 'global_contact', 'global_financial', 'global_network', 'region_cn']) + assert.deepEqual( + groups.find((group) => group.id === 'region_cn')?.rules.map((rule) => rule.id), + ['cn_phone', 'cn_id_card', 'cn_bank_card', 'cn_landline'] + ) + assert.equal( + groups.some((group) => group.id === 'region_us'), + false + ) +}) + +test('enables all visible built-in desensitize rules by default', () => { + const rules = getDefaultRulesForLocale('zh-CN') + + assert.ok(rules.length > 0) + assert.equal( + rules.every((rule) => rule.enabled), + true + ) +}) + +test('applies explicit built-in rule overrides without storing rule bodies', () => { + const rules = applyDesensitizeRuleOverrides(getDefaultRulesForLocale('zh-CN'), { + api_key_prefix: false, + cn_bank_card: true, + }) + + assert.equal(rules.find((rule) => rule.id === 'api_key_prefix')?.enabled, false) + assert.equal(rules.find((rule) => rule.id === 'cn_bank_card')?.enabled, true) +}) diff --git a/packages/node-runtime/src/ai/preprocessor/builtin-rules.ts b/packages/node-runtime/src/ai/preprocessor/builtin-rules.ts new file mode 100644 index 000000000..c6aa3a0dd --- /dev/null +++ b/packages/node-runtime/src/ai/preprocessor/builtin-rules.ts @@ -0,0 +1,318 @@ +/** + * 内置脱敏规则库 + * 按 locale 分组,支持通用规则和地区特定规则 + */ +import type { DesensitizeRule } from './types' + +export const DESENSITIZE_RULES_SCHEMA_VERSION = 2 + +export interface DesensitizeRuleGroup { + id: string + label: string + description: string + locales: string[] + order: number + rules: DesensitizeRule[] +} + +const GROUP_DEFS: Array> = [ + { + id: 'credentials', + label: 'desensitize.groups.credentials', + description: 'desensitize.groups.credentials_desc', + locales: [], + order: 10, + }, + { + id: 'global_contact', + label: 'desensitize.groups.global_contact', + description: 'desensitize.groups.global_contact_desc', + locales: [], + order: 20, + }, + { + id: 'global_financial', + label: 'desensitize.groups.global_financial', + description: 'desensitize.groups.global_financial_desc', + locales: [], + order: 30, + }, + { + id: 'global_network', + label: 'desensitize.groups.global_network', + description: 'desensitize.groups.global_network_desc', + locales: [], + order: 40, + }, + { + id: 'region_cn', + label: 'desensitize.groups.region_cn', + description: 'desensitize.groups.region_cn_desc', + locales: ['zh-CN'], + order: 50, + }, + { + id: 'region_us', + label: 'desensitize.groups.region_us', + description: 'desensitize.groups.region_us_desc', + locales: ['en-US'], + order: 50, + }, + { + id: 'region_jp', + label: 'desensitize.groups.region_jp', + description: 'desensitize.groups.region_jp_desc', + locales: ['ja-JP'], + order: 50, + }, + { + id: 'region_kr', + label: 'desensitize.groups.region_kr', + description: 'desensitize.groups.region_kr_desc', + locales: ['ko-KR'], + order: 50, + }, +] + +export const BUILTIN_DESENSITIZE_RULES: DesensitizeRule[] = [ + // ==================== 中国 (zh-CN) ==================== + { + id: 'cn_phone', + label: 'desensitize.rules.cn_phone', + pattern: '(?"]+', + replacement: '[URL]', + enabled: true, + builtin: true, + locales: [], + group: 'global_network', + }, +] + +/** + * 获取指定 locale 的默认规则(当前 locale 特定 + 通用规则) + */ +export function getDefaultRulesForLocale(locale: string): DesensitizeRule[] { + return BUILTIN_DESENSITIZE_RULES.filter((rule) => rule.locales.length === 0 || rule.locales.includes(locale)).map( + (rule) => ({ ...rule }) + ) +} + +export function applyDesensitizeRuleOverrides( + rules: DesensitizeRule[], + overrides: Record = {} +): DesensitizeRule[] { + return rules.map((rule) => ({ + ...rule, + enabled: overrides[rule.id] ?? rule.enabled, + })) +} + +export function getRuleGroupsForLocale( + locale: string, + overrides: Record = {} +): DesensitizeRuleGroup[] { + const rules = applyDesensitizeRuleOverrides(getDefaultRulesForLocale(locale), overrides) + const rulesByGroup = new Map() + for (const rule of rules) { + if (!rule.group) continue + const groupRules = rulesByGroup.get(rule.group) ?? [] + groupRules.push(rule) + rulesByGroup.set(rule.group, groupRules) + } + + return GROUP_DEFS.filter((group) => { + if (group.locales.length > 0 && !group.locales.includes(locale)) return false + return (rulesByGroup.get(group.id)?.length ?? 0) > 0 + }) + .map((group) => ({ + ...group, + rules: rulesByGroup.get(group.id) ?? [], + })) + .sort((a, b) => a.order - b.order) +} + +/** + * 合并新 locale 的规则到现有规则列表 + */ +export function mergeRulesForLocale( + existing: DesensitizeRule[], + locale: string, + overrides: Record = {} +): DesensitizeRule[] { + const customRules = existing.filter((rule) => !rule.builtin) + const builtinRules = applyDesensitizeRuleOverrides(getDefaultRulesForLocale(locale), overrides) + + return [...builtinRules, ...customRules] +} diff --git a/packages/node-runtime/src/ai/preprocessor/format.test.ts b/packages/node-runtime/src/ai/preprocessor/format.test.ts new file mode 100644 index 000000000..03b9838ce --- /dev/null +++ b/packages/node-runtime/src/ai/preprocessor/format.test.ts @@ -0,0 +1,57 @@ +/** + * Tests for tool result text formatting. + * + * Regression for: rawMessages object arrays leaking into the LLM-facing + * text as "rawMessages: [object Object], [object Object], ..." — wasting + * context tokens on every message-retrieval tool call and polluting the + * tool result text persisted for history replay. + * + * Run: npx tsx --test packages/node-runtime/src/ai/preprocessor/format.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { formatToolResultAsText } from './format' +import { applyPreprocessingPipeline } from './preprocessing-pipeline' +import type { PreprocessableMessage } from './types' + +const rawMessages: PreprocessableMessage[] = [ + { id: 1, senderName: 'Alice', content: 'hello world', timestamp: 1710000000 }, + { id: 2, senderName: 'Bob', content: 'hi there', timestamp: 1710000060 }, +] + +describe('formatToolResultAsText', () => { + it('skips rawMessages and renders scalar metadata plus formatted messages', () => { + const text = formatToolResultAsText({ + total: 100, + timeRange: '全部时间', + rawMessages, + messages: ['2024/03/09 16:40 Alice: hello world', '2024/03/09 16:41 Bob: hi there'], + }) + + assert.ok(!text.includes('[object Object]')) + assert.ok(!text.includes('rawMessages')) + assert.ok(text.includes('total: 100')) + assert.ok(text.includes('timeRange: 全部时间')) + assert.ok(text.includes('Alice: hello world')) + }) + + it('still renders scalar arrays inline', () => { + const text = formatToolResultAsText({ keywords: ['生日', '聚餐'] }) + assert.equal(text, 'keywords: 生日, 聚餐') + }) +}) + +describe('applyPreprocessingPipeline', () => { + it('produces clean text even when extraDetails mirrors rawMessages', () => { + const result = applyPreprocessingPipeline({ + rawMessages, + extraDetails: { total: 2, timeRange: '全部时间', rawMessages }, + }) + + assert.ok(!result.text.includes('[object Object]')) + assert.ok(result.text.includes('total: 2')) + assert.ok(result.text.includes('hello world')) + assert.ok(result.text.includes('hi there')) + }) +}) diff --git a/packages/node-runtime/src/ai/preprocessor/format.ts b/packages/node-runtime/src/ai/preprocessor/format.ts new file mode 100644 index 000000000..656704b02 --- /dev/null +++ b/packages/node-runtime/src/ai/preprocessor/format.ts @@ -0,0 +1,205 @@ +/** + * 工具结果格式化 & i18n 辅助(平台无关) + */ + +export function isChineseLocale(locale?: string): boolean { + return locale?.startsWith('zh') ?? false +} + +export const i18nTexts = { + allTime: { zh: '全部时间', en: 'All time' }, + noContent: { zh: '[无内容]', en: '[No content]' }, + memberNotFound: { zh: '未找到该成员', en: 'Member not found' }, + untilNow: { zh: '至今', en: 'Present' }, + noChangeRecord: { zh: '无变更记录', en: 'No change record' }, + noConversation: { zh: '未找到这两人之间的对话', en: 'No conversation found between these two members' }, + noMessageContext: { zh: '未找到指定的消息或上下文', en: 'Message or context not found' }, + messages: { zh: '条', en: '' }, + alias: { zh: '别名', en: 'Alias' }, + weekdays: { + zh: ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'], + en: ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + }, + dailySummary: { + zh: (days: number, total: number, avg: number) => `最近${days}天共${total}条,日均${avg}条`, + en: (days: number, total: number, avg: number) => `Last ${days} days: ${total} messages, avg ${avg}/day`, + }, +} + +type TextEntryKey = Exclude + +export function t(key: TextEntryKey, locale?: string): string | string[] { + const text = i18nTexts[key] + if (typeof text === 'object' && 'zh' in text && 'en' in text) { + return isChineseLocale(locale) ? text.zh : text.en + } + return '' +} + +const MAX_MESSAGE_CONTENT_LENGTH = 200 + +/** + * 格式化消息为简洁文本格式 + * 输出格式: "2025/3/3 07:25:04 张三: 消息内容" + */ +export function formatMessageCompact( + msg: { + id?: number + senderName: string + content: string | null + timestamp: number + }, + locale?: string +): string { + const localeStr = isChineseLocale(locale) ? 'zh-CN' : 'en-US' + const time = new Date(msg.timestamp * 1000).toLocaleString(localeStr) + let content = msg.content || (t('noContent', locale) as string) + + if (content.length > MAX_MESSAGE_CONTENT_LENGTH) { + content = content.slice(0, MAX_MESSAGE_CONTENT_LENGTH) + '...' + } + + return `${time} ${msg.senderName}: ${content}` +} + +/** + * 格式化时间范围用于返回结果 + */ +export function formatTimeRange( + timeFilter?: { startTs: number; endTs: number }, + locale?: string +): string | { start: string; end: string } { + if (!timeFilter) return t('allTime', locale) as string + const localeStr = isChineseLocale(locale) ? 'zh-CN' : 'en-US' + return { + start: new Date(timeFilter.startTs * 1000).toLocaleString(localeStr), + end: new Date(timeFilter.endTs * 1000).toLocaleString(localeStr), + } +} + +/** + * 将工具返回的结构化数据格式化为 LLM 友好的纯文本 + */ +export function formatToolResultAsText(details: Record): string { + const lines: string[] = [] + const messages = details.messages as string[] | undefined + + for (const [key, value] of Object.entries(details)) { + if (key === 'messages') continue + // raw message objects are already rendered via the formatted messages list; + // joining them here would print "[object Object]" for every entry + if (key === 'rawMessages') continue + if (value === undefined || value === null) continue + + if (typeof value === 'object') { + if ('start' in (value as Record) && 'end' in (value as Record)) { + const range = value as { start: string; end: string } + lines.push(`${key}: ${range.start} ~ ${range.end}`) + } else if (Array.isArray(value)) { + lines.push(`${key}: ${value.join(', ')}`) + } else { + lines.push(`${key}: ${JSON.stringify(value)}`) + } + } else { + lines.push(`${key}: ${value}`) + } + } + + if (messages && messages.length > 0) { + lines.push('') + let lastDate = '' + for (const msg of messages) { + const spaceIdx = msg.indexOf(' ') + const secondSpaceIdx = msg.indexOf(' ', spaceIdx + 1) + if (spaceIdx > 0 && secondSpaceIdx > 0) { + const date = msg.slice(0, spaceIdx) + const rest = msg.slice(spaceIdx + 1) + if (date !== lastDate) { + lines.push(`--- ${date} ---`) + lastDate = date + } + lines.push(rest) + } else { + lines.push(msg) + } + } + } + + return lines.join('\n') +} + +/** + * 昵称匿名化:用 U{senderId} 替代真实昵称 + * 就地修改 messages 的 senderName,返回映射表文本行 + */ +export function anonymizeMessageNames( + messages: Array<{ senderId?: number; senderName: string; senderPlatformId?: string }>, + ownerPlatformId?: string +): string { + const nameMap = new Map() + for (const msg of messages) { + if (msg.senderId != null && !nameMap.has(msg.senderId)) { + nameMap.set(msg.senderId, { name: msg.senderName, platformId: msg.senderPlatformId }) + } + } + + if (nameMap.size === 0) return '' + + for (const msg of messages) { + if (msg.senderId != null) { + msg.senderName = `U${msg.senderId}` + } + } + + const entries: string[] = [] + for (const [id, { name, platformId }] of nameMap) { + const isOwner = ownerPlatformId && platformId === ownerPlatformId + entries.push(`U${id}=${name}${isOwner ? '(owner)' : ''}`) + } + + return `[Name Map] ${entries.join(' | ')}` +} + +/** + * Token-aware 截断:在 token 预算内保留尽可能多的消息 + */ +export function truncateFormattedMessages( + formatted: string[], + maxTokens: number, + strategy: 'keep_first' | 'keep_last', + countTokensFn: (text: string) => number +): { messages: string[]; wasTruncated: boolean } { + const budget = maxTokens - 200 + + let totalTokens = 0 + for (const line of formatted) { + totalTokens += countTokensFn(line) + 1 + } + if (totalTokens <= budget) { + return { messages: formatted, wasTruncated: false } + } + + if (strategy === 'keep_first') { + let tokens = 0 + let cutIndex = formatted.length + for (let i = 0; i < formatted.length; i++) { + tokens += countTokensFn(formatted[i]) + 1 + if (tokens > budget) { + cutIndex = i + break + } + } + return { messages: formatted.slice(0, cutIndex), wasTruncated: cutIndex < formatted.length } + } else { + let tokens = 0 + let cutIndex = 0 + for (let i = formatted.length - 1; i >= 0; i--) { + tokens += countTokensFn(formatted[i]) + 1 + if (tokens > budget) { + cutIndex = i + 1 + break + } + } + return { messages: formatted.slice(cutIndex), wasTruncated: cutIndex > 0 } + } +} diff --git a/packages/node-runtime/src/ai/preprocessor/index.ts b/packages/node-runtime/src/ai/preprocessor/index.ts new file mode 100644 index 000000000..c003f9190 --- /dev/null +++ b/packages/node-runtime/src/ai/preprocessor/index.ts @@ -0,0 +1,32 @@ +/** + * 预处理管道(平台无关) + * + * 提供消息预处理、格式化、截断和脱敏功能。 + */ + +export type { PreprocessConfig, PreprocessableMessage, DesensitizeRule, TruncationStrategy } from './types' +export { preprocessMessages } from './pipeline' +export type { PreprocessLogger } from './pipeline' +export { + BUILTIN_DESENSITIZE_RULES, + DESENSITIZE_RULES_SCHEMA_VERSION, + applyDesensitizeRuleOverrides, + getDefaultRulesForLocale, + getRuleGroupsForLocale, + mergeRulesForLocale, +} from './builtin-rules' +export type { DesensitizeRuleGroup } from './builtin-rules' +export { + formatMessageCompact, + formatTimeRange, + formatToolResultAsText, + anonymizeMessageNames, + truncateFormattedMessages, + isChineseLocale, + i18nTexts, + t, +} from './format' + +// Preprocessing pipeline +export type { PreprocessingPipelineOptions, PreprocessingPipelineResult } from './preprocessing-pipeline' +export { applyPreprocessingPipeline } from './preprocessing-pipeline' diff --git a/packages/node-runtime/src/ai/preprocessor/pipeline.ts b/packages/node-runtime/src/ai/preprocessor/pipeline.ts new file mode 100644 index 000000000..ff9c3817f --- /dev/null +++ b/packages/node-runtime/src/ai/preprocessor/pipeline.ts @@ -0,0 +1,260 @@ +/** + * 预处理管道 + * 执行顺序:数据清洗 → 黑名单 → 去噪 → 合并 → 脱敏 + */ + +import type { PreprocessConfig, PreprocessableMessage, DesensitizeRule } from './types' + +const MERGE_WINDOW_DEFAULT = 180 + +export interface PreprocessLogger { + info(category: string, message: string, extra?: Record): void + warn(category: string, message: string, extra?: Record): void +} + +const defaultLogger: PreprocessLogger = { + info: () => {}, + warn: () => {}, +} + +export function preprocessMessages( + messages: T[], + config?: PreprocessConfig, + logger: PreprocessLogger = defaultLogger +): T[] { + if (!config || !hasAnyEnabled(config)) return messages + if (messages.length === 0) return messages + + const inputCount = messages.length + let result: T[] = [...messages] + const applied: string[] = [] + + if (config.dataCleaning !== false) { + const cleaned = applyDataCleaning(result) + if (cleaned.changed > 0) { + result = cleaned.messages + applied.push(`dataCleaning: ${cleaned.changed} messages cleaned`) + } + } + + if (config.blacklistKeywords.length > 0) { + const before = result.length + result = applyBlacklistFilter(result, config.blacklistKeywords) + applied.push(`blacklist: ${before} → ${result.length} (-${before - result.length})`) + } + + if (config.denoise) { + const before = result.length + result = applyDenoise(result) + applied.push(`denoise: ${before} → ${result.length} (-${before - result.length})`) + } + + if (config.mergeConsecutive) { + const before = result.length + result = applyMergeConsecutive(result, config.mergeWindowSeconds ?? MERGE_WINDOW_DEFAULT) + applied.push(`merge: ${before} → ${result.length} (-${before - result.length})`) + } + + if (config.desensitize) { + const enabledRules = (config.desensitizeRules || []).filter((r) => r.enabled) + if (enabledRules.length > 0) { + result = applyDesensitize(result, enabledRules, logger) + applied.push(`desensitize: ${enabledRules.length} rules applied`) + } + } + + logger.info('Preprocess', `Pipeline: ${inputCount} → ${result.length} messages`, { + strategies: applied, + }) + + return result +} + +function hasAnyEnabled(config: PreprocessConfig): boolean { + return ( + config.dataCleaning !== false || + config.mergeConsecutive || + config.blacklistKeywords.length > 0 || + config.denoise || + config.desensitize + ) +} + +// ==================== 策略实现 ==================== + +function applyDataCleaning(messages: T[]): { messages: T[]; changed: number } { + let changed = 0 + const result = messages.map((msg) => { + if (!msg.content) return msg + const cleaned = cleanXmlContent(msg.content) + if (cleaned === msg.content) return msg + changed++ + return { ...msg, content: cleaned } + }) + return { messages: result, changed } +} + +const XML_START = /^<\?xml\s|^]/ + +function cleanXmlContent(content: string): string { + const trimmed = content.trim() + if (!XML_START.test(trimmed)) return content + + const title = extractXmlTag(trimmed, 'title') + const des = extractXmlTag(trimmed, 'des') + + if (title) { + return des ? `[分享] ${title} - ${des}` : `[分享] ${title}` + } + return '[应用消息]' +} + +function extractXmlTag(xml: string, tag: string): string | null { + const openTag = `<${tag}>` + const closeTag = `` + const start = xml.indexOf(openTag) + if (start === -1) return null + const contentStart = start + openTag.length + const end = xml.indexOf(closeTag, contentStart) + if (end === -1) return null + const value = xml.slice(contentStart, end).trim() + return value.length > 0 ? value : null +} + +function applyBlacklistFilter(messages: T[], keywords: string[]): T[] { + if (keywords.length === 0) return messages + const lowerKeywords = keywords.map((k) => k.toLowerCase()) + return messages.filter((msg) => { + if (!msg.content) return true + const lower = msg.content.toLowerCase() + return !lowerKeywords.some((kw) => lower.includes(kw)) + }) +} + +function applyDenoise(messages: T[]): T[] { + return messages.filter((msg) => { + if (!msg.content) return false + if (msg.replyToMessageId) return true + const content = msg.content.trim() + if (content.length === 0) return false + if (content.length < 2) return false + if (SYSTEM_PLACEHOLDERS.has(content)) return false + if (isPureEmoji(content)) return false + return true + }) +} + +const SYSTEM_PLACEHOLDERS = new Set([ + '[图片]', + '[视频]', + '[语音]', + '[文件]', + '[动画表情]', + '[表情]', + '[链接]', + '[位置]', + '[名片]', + '[红包]', + '[转账]', + '[音乐]', + '[Image]', + '[Video]', + '[Voice]', + '[File]', + '[Sticker]', + '[Link]', +]) + +function isPureEmoji(str: string): boolean { + const stripped = str + .replace(/\p{Emoji_Presentation}/gu, '') + .replace(/\p{Extended_Pictographic}/gu, '') + .replace(/\u200d/g, '') + .replace(/\ufe0f/g, '') + .replace(/\u20e3/g, '') + .replace(/\s/g, '') + return stripped.length === 0 +} + +function applyMergeConsecutive(messages: T[], windowSeconds: number): T[] { + if (messages.length <= 1) return messages + + const merged: T[] = [] + let current: T | null = null + + for (const msg of messages) { + if (!current) { + current = { ...msg } + continue + } + + const sameSender = isSameSender(current, msg) + const withinWindow = Math.abs(msg.timestamp - current.timestamp) <= windowSeconds + + if (sameSender && withinWindow) { + current = { + ...current, + content: [current.content, msg.content].filter(Boolean).join('\n'), + } + } else { + merged.push(current) + current = { ...msg } + } + } + + if (current) merged.push(current) + return merged +} + +function isSameSender(a: PreprocessableMessage, b: PreprocessableMessage): boolean { + if (a.senderPlatformId && b.senderPlatformId) { + return a.senderPlatformId === b.senderPlatformId + } + return a.senderName === b.senderName +} + +function applyDesensitize( + messages: T[], + rules: DesensitizeRule[], + logger: PreprocessLogger +): T[] { + const compiledRules = compileRules(rules, logger) + if (compiledRules.length === 0) return messages + + return messages.map((msg) => { + if (!msg.content) return msg + let content = msg.content + for (const { regex, replacement } of compiledRules) { + regex.lastIndex = 0 + content = content.replace(regex, replacement) + } + if (content === msg.content) return msg + return { ...msg, content } + }) +} + +const regexCache = new Map() + +function compileRules( + rules: DesensitizeRule[], + logger: PreprocessLogger +): Array<{ regex: RegExp; replacement: string }> { + const result: Array<{ regex: RegExp; replacement: string }> = [] + for (const rule of rules) { + let regex = regexCache.get(rule.pattern) + if (regex === undefined) { + try { + regex = new RegExp(rule.pattern, 'g') + regexCache.set(rule.pattern, regex) + } catch { + logger.warn('Preprocess', `Invalid regex in desensitize rule "${rule.id}": ${rule.pattern}`) + regexCache.set(rule.pattern, null) + continue + } + } + if (regex) { + result.push({ regex, replacement: rule.replacement }) + } + } + return result +} diff --git a/packages/node-runtime/src/ai/preprocessor/preprocessing-pipeline.ts b/packages/node-runtime/src/ai/preprocessor/preprocessing-pipeline.ts new file mode 100644 index 000000000..d9fb5d27a --- /dev/null +++ b/packages/node-runtime/src/ai/preprocessor/preprocessing-pipeline.ts @@ -0,0 +1,85 @@ +/** + * 预处理管道(统一实现) + * + * Electron 端和 Server 端共用的消息预处理管道核心逻辑: + * rawMessages → preprocess → [anonymize] → format → truncate → text + */ + +import type { PreprocessConfig, PreprocessableMessage, TruncationStrategy } from './types' +import { preprocessMessages, type PreprocessLogger } from './pipeline' +import { + formatMessageCompact, + anonymizeMessageNames, + formatToolResultAsText, + truncateFormattedMessages, +} from './format' +import { countTokens } from '../tokenizer' + +export interface PreprocessingPipelineOptions { + rawMessages: PreprocessableMessage[] + preprocessConfig?: PreprocessConfig + locale?: string + anonymizeNames?: boolean + ownerPlatformId?: string + maxToolResultTokens?: number + truncationStrategy?: TruncationStrategy + /** rawMessages 之外的附加元数据(如 total、timeRange),会合并到输出 details */ + extraDetails?: Record + /** 可选的日志记录器,Electron 端注入 aiLogger 以记录管道统计信息 */ + logger?: PreprocessLogger +} + +export interface PreprocessingPipelineResult { + text: string + details: Record +} + +export function applyPreprocessingPipeline(options: PreprocessingPipelineOptions): PreprocessingPipelineResult { + const { + rawMessages, + preprocessConfig, + locale, + anonymizeNames = false, + ownerPlatformId, + maxToolResultTokens, + truncationStrategy = 'keep_last', + extraDetails = {}, + logger, + } = options + + const processed = preprocessMessages(rawMessages, preprocessConfig, logger) + + let nameMapLine = '' + if (anonymizeNames) { + nameMapLine = anonymizeMessageNames(processed, ownerPlatformId) + } + + let formatted = processed.map((m) => formatMessageCompact(m, locale)) + + let wasTruncated = false + const originalCount = formatted.length + + if (maxToolResultTokens && maxToolResultTokens > 0) { + const truncResult = truncateFormattedMessages(formatted, maxToolResultTokens, truncationStrategy, countTokens) + if (truncResult.wasTruncated) { + formatted = truncResult.messages + wasTruncated = true + } + } + + const finalDetails: Record = { ...extraDetails, messages: formatted, returned: formatted.length } + + let textContent = formatToolResultAsText(finalDetails) + + if (wasTruncated) { + const strategyDesc = truncationStrategy === 'keep_first' ? 'most relevant' : 'most recent' + const notice = `⚠️ Results truncated: ${originalCount} messages found, showing ${formatted.length} ${strategyDesc} due to context limit. Use a narrower time range or more specific keywords for more precise results.` + textContent = notice + '\n' + textContent + } + + if (nameMapLine) { + textContent = nameMapLine + '\n' + textContent + } + + return { text: textContent, details: finalDetails } +} diff --git a/packages/node-runtime/src/ai/preprocessor/types.ts b/packages/node-runtime/src/ai/preprocessor/types.ts new file mode 100644 index 000000000..2e70a7d06 --- /dev/null +++ b/packages/node-runtime/src/ai/preprocessor/types.ts @@ -0,0 +1,56 @@ +/** 单条脱敏规则 */ +export interface DesensitizeRule { + /** 唯一标识(预置规则用固定 id,自定义规则用 uuid) */ + id: string + /** 显示名称 */ + label: string + /** 正则表达式字符串(运行时 new RegExp(pattern, 'g')) */ + pattern: string + /** 替换文本 */ + replacement: string + /** 是否启用 */ + enabled: boolean + /** 是否为预置规则(预置规则不可删除,仅可启用/禁用) */ + builtin: boolean + /** 适用的 locale 列表(空数组表示通用) */ + locales: string[] + /** 预置规则所属分组 */ + group?: string +} + +/** 预处理配置 */ +export interface PreprocessConfig { + /** 数据清洗:清理 XML 卡片消息等非纯文本内容(默认开启) */ + dataCleaning: boolean + /** 合并连续发言(同发送者 + 时间间隔 < mergeWindowSeconds) */ + mergeConsecutive: boolean + /** 合并窗口(秒),默认 180 */ + mergeWindowSeconds?: number + /** 自定义黑名单关键词,包含任一关键词的消息将被整条过滤 */ + blacklistKeywords: string[] + /** 智能去噪(过滤纯语气词、纯表情、系统占位符) */ + denoise: boolean + /** 数据脱敏总开关 */ + desensitize: boolean + /** 脱敏规则存储格式版本 */ + desensitizeRulesSchemaVersion?: number + /** 内置脱敏规则显式覆盖表 */ + desensitizeBuiltinRuleOverrides?: Record + /** 脱敏规则列表(预置 + 自定义,按优先级排序) */ + desensitizeRules: DesensitizeRule[] + /** 昵称匿名化:用 U{id} 替代真实昵称,减少 AI 幻觉 */ + anonymizeNames: boolean +} + +/** 预处理管道可接受的消息结构 */ +export interface PreprocessableMessage { + id?: number + senderId?: number + senderName: string + senderPlatformId?: string + content: string | null + timestamp: number + replyToMessageId?: string | null +} + +export type TruncationStrategy = 'keep_first' | 'keep_last' diff --git a/packages/node-runtime/src/ai/remote-api.ts b/packages/node-runtime/src/ai/remote-api.ts new file mode 100644 index 000000000..b6a7e03db --- /dev/null +++ b/packages/node-runtime/src/ai/remote-api.ts @@ -0,0 +1,173 @@ +/** + * Remote LLM API operations (platform-agnostic). + * + * - fetchRemoteModels: list available models from a provider's API + * - validateApiKey: send a minimal completion request to verify key validity + */ + +import { BUILTIN_PROVIDERS } from '@openchatlab/core' +import { completeSimple } from '@earendil-works/pi-ai' +import { buildPiModel, normalizeOpenAICompatibleBaseUrl, type PiModelConfig } from './llm-builder' +import type { Model as PiModel, Api as PiApi } from '@earendil-works/pi-ai' + +export interface RemoteModel { + id: string + name: string + ownedBy?: string + contextWindow?: number +} + +export interface FetchRemoteModelsResult { + success: boolean + models?: RemoteModel[] + error?: string +} + +export interface RemoteApiOptions { + /** Extra headers for outgoing requests (e.g. User-Agent). */ + headers?: Record + /** Optional logger. */ + onLog?: (level: 'info' | 'error', tag: string, message: string, data?: unknown) => void +} + +export async function fetchRemoteModels( + provider: string, + apiKey: string, + baseUrl?: string, + apiFormat?: string, + options?: RemoteApiOptions +): Promise { + const effectiveApiFormat = apiFormat || 'openai-completions' + + if (effectiveApiFormat === 'anthropic-messages') { + return { success: false, error: 'Anthropic does not support model listing via API' } + } + + const providerDef = BUILTIN_PROVIDERS.find((p) => p.id === provider) + const rawBaseUrl = baseUrl || providerDef?.defaultBaseUrl || '' + if (!rawBaseUrl) { + return { success: false, error: 'No base URL provided' } + } + + const abortController = new AbortController() + const timeout = setTimeout(() => abortController.abort(), 15000) + + try { + let url: string + const headers: Record = { ...options?.headers } + + if (effectiveApiFormat === 'google-generative-ai') { + const trimmed = rawBaseUrl.replace(/\/+$/, '').replace(/\/v1(beta)?$/, '') + url = `${trimmed}/v1beta/models?key=${apiKey}` + } else { + const resolved = normalizeOpenAICompatibleBaseUrl(rawBaseUrl) + url = `${resolved}/models` + headers['Authorization'] = `Bearer ${apiKey}` + } + + options?.onLog?.('info', 'LLM', 'Fetching remote models', { + url: url.replace(/key=[^&]+/, 'key=***'), + provider, + }) + + const response = await fetch(url, { + method: 'GET', + headers, + signal: abortController.signal, + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + return { success: false, error: `HTTP ${response.status}: ${body.slice(0, 200)}` } + } + + const json = await response.json() + + let models: RemoteModel[] + + if (effectiveApiFormat === 'google-generative-ai') { + const geminiModels = (json.models || []) as Array<{ + name?: string + displayName?: string + inputTokenLimit?: number + }> + models = geminiModels.map((m) => { + const id = (m.name || '').replace(/^models\//, '') + return { + id, + name: m.displayName || id, + ownedBy: 'google', + contextWindow: m.inputTokenLimit || undefined, + } + }) + } else { + const data = (json.data || []) as Array<{ + id?: string + owned_by?: string + context_length?: number + }> + models = data + .filter((m) => m.id) + .map((m) => ({ + id: m.id!, + name: m.id!, + ownedBy: m.owned_by, + contextWindow: m.context_length || undefined, + })) + } + + options?.onLog?.('info', 'LLM', `Fetched ${models.length} remote models`, { provider }) + return { success: true, models } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('aborted') || message.includes('AbortError')) { + return { success: false, error: 'Request timed out (15s)' } + } + return { success: false, error: message } + } finally { + clearTimeout(timeout) + } +} + +export async function validateApiKey( + provider: string, + apiKey: string, + baseUrl?: string, + model?: string, + apiFormat?: string, + options?: RemoteApiOptions +): Promise<{ success: boolean; error?: string }> { + const providerDef = BUILTIN_PROVIDERS.find((p) => p.id === provider) + const defaultModel = providerDef?.modelIds?.[0] + + const config: PiModelConfig = { + provider, + model: model || defaultModel, + baseUrl, + apiFormat, + } + const piModel: PiModel = buildPiModel(config, { headers: options?.headers }) + + const abortController = new AbortController() + const timeout = setTimeout(() => abortController.abort(), 15000) + + try { + const result = await completeSimple( + piModel, + { messages: [{ role: 'user', content: 'Hi', timestamp: Date.now() }] as any }, + { apiKey, maxTokens: 1, signal: abortController.signal } + ) + if (result.stopReason === 'error' || result.stopReason === 'aborted') { + return { success: false, error: result.errorMessage || 'Connection failed' } + } + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('aborted') || message.includes('AbortError')) { + return { success: false, error: 'Request timed out (15s)' } + } + return { success: false, error: message } + } finally { + clearTimeout(timeout) + } +} diff --git a/packages/node-runtime/src/ai/skill-manager-core.ts b/packages/node-runtime/src/ai/skill-manager-core.ts new file mode 100644 index 000000000..753f82a13 --- /dev/null +++ b/packages/node-runtime/src/ai/skill-manager-core.ts @@ -0,0 +1,336 @@ +/** + * Platform-agnostic skill manager (core logic). + * Same strategy as AssistantManager: abstract FS and builtin resources via DI. + * + * Note: Named "skill-manager-core" to avoid conflict with the existing + * Electron-specific SkillManager class in skill-manager.ts. + */ + +import { parseSkillFile } from './skill-parser' +import { buildSkillMenuText, formatSkillMenuLine, MAX_SKILL_MENU_ITEMS } from './skill-menu' +import type { SkillDef, SkillSummary } from './types' + +// ==================== Result types ==================== + +export interface SkillInitResult { + total: number +} + +export interface SkillSaveResult { + success: boolean + error?: string +} + +export interface BuiltinSkillInfo { + id: string + name: string + description: string + tags: string[] + chatScope: 'all' | 'group' | 'private' + tools: string[] + imported: boolean + hasUpdate: boolean +} + +// ==================== Dependency abstraction ==================== + +export interface SkillManagerFs { + ensureDir(dir: string): void + listFiles(dir: string, ext: string): string[] + readFile(filePath: string): string + writeFile(filePath: string, content: string): void + deleteFile(filePath: string): void + fileExists(filePath: string): boolean + joinPath(...parts: string[]): string +} + +export interface SkillManagerCoreDeps { + fs: SkillManagerFs + skillsDir: string + builtinRawSkills?: Array<{ id: string; content: string }> + contentHash?: (content: string) => string + logger?: { + info: (category: string, message: string, data?: unknown) => void + warn: (category: string, message: string, data?: unknown) => void + error: (category: string, message: string, data?: unknown) => void + } +} + +// ==================== Internal helpers ==================== + +function toSummary(def: SkillDef): SkillSummary { + return { + id: def.id, + name: def.name, + description: def.description, + tags: def.tags, + chatScope: def.chatScope, + tools: def.tools, + builtinId: def.builtinId, + } +} + +function injectBuiltinId(rawMd: string, builtinId: string): string { + if (rawMd.includes('builtinId:')) return rawMd + const endOfFrontmatter = rawMd.indexOf('\n---', 3) + if (endOfFrontmatter === -1) return rawMd + return rawMd.slice(0, endOfFrontmatter) + `\nbuiltinId: ${builtinId}` + rawMd.slice(endOfFrontmatter) +} + +function stripBuiltinId(content: string): string { + return content.replace(/\nbuiltinId:.*\n/g, '\n') +} + +function simpleHash(content: string): string { + let hash = 0 + for (let i = 0; i < content.length; i++) { + hash = ((hash << 5) - hash + content.charCodeAt(i)) | 0 + } + return hash.toString(36) +} + +// ==================== Manager ==================== + +export class SkillManagerCore { + private deps: SkillManagerCoreDeps + private builtinDefs = new Map() + private builtinRawMap = new Map() + private cache = new Map() + private initialized = false + private hashFn: (content: string) => string + + constructor(deps: SkillManagerCoreDeps) { + this.deps = deps + this.hashFn = deps.contentHash || simpleHash + this.initBuiltinCache() + } + + private initBuiltinCache(): void { + if (!this.deps.builtinRawSkills) return + for (const { id, content } of this.deps.builtinRawSkills) { + this.builtinRawMap.set(id, content) + const def = parseSkillFile(content, `${id}.md`) + if (def) this.builtinDefs.set(id, def) + } + } + + private ensureInitialized(): void { + if (!this.initialized) this.init() + } + + // ==================== Init ==================== + + init(): SkillInitResult { + const { fs, skillsDir } = this.deps + fs.ensureDir(skillsDir) + this.loadAll() + + this.initialized = true + this.deps.logger?.info('SkillManager', 'Initialized', { total: this.cache.size }) + + return { total: this.cache.size } + } + + private loadAll(): void { + const { fs, skillsDir } = this.deps + this.cache.clear() + + const files = fs.listFiles(skillsDir, '.md') + for (const file of files) { + try { + const filePath = fs.joinPath(skillsDir, file) + const content = fs.readFile(filePath) + const def = parseSkillFile(content, filePath) + if (def) { + this.cache.set(def.id, def) + } else { + this.deps.logger?.warn('SkillManager', `Failed to parse: ${file}`) + } + } catch (error) { + this.deps.logger?.warn('SkillManager', `Failed to load: ${file}`, { error: String(error) }) + } + } + } + + // ==================== Query ==================== + + getAllSkills(): SkillSummary[] { + this.ensureInitialized() + return Array.from(this.cache.values()).map(toSummary) + } + + getSkillConfig(id: string): SkillDef | null { + this.ensureInitialized() + return this.cache.get(id) ?? null + } + + // ==================== Builtin catalog ==================== + + getBuiltinCatalog(): BuiltinSkillInfo[] { + this.ensureInitialized() + + return Array.from(this.builtinDefs.entries()).map(([builtinId, builtin]) => { + const userSkill = this.findByBuiltinId(builtinId) + const imported = !!userSkill + const hasUpdate = imported ? this.hasBuiltinUpdate(builtinId, userSkill!) : false + + return { + id: builtinId, + name: builtin.name, + description: builtin.description, + tags: builtin.tags, + chatScope: builtin.chatScope, + tools: builtin.tools, + imported, + hasUpdate, + } + }) + } + + // ==================== Import ==================== + + importSkill(builtinId: string): SkillSaveResult & { id?: string } { + this.ensureInitialized() + + const rawContent = this.builtinRawMap.get(builtinId) + if (!rawContent) return { success: false, error: `Builtin skill not found: ${builtinId}` } + + const existing = this.findByBuiltinId(builtinId) + if (existing) return { success: false, error: `Skill already imported: ${builtinId}` } + + const contentWithId = injectBuiltinId(rawContent, builtinId) + const def = parseSkillFile(contentWithId, `${builtinId}.md`) + if (!def) return { success: false, error: `Failed to parse builtin skill: ${builtinId}` } + def.builtinId = builtinId + + const result = this.saveToDisk(def.id, contentWithId, def) + return { ...result, id: result.success ? def.id : undefined } + } + + reimportSkill(id: string): SkillSaveResult { + this.ensureInitialized() + + const existing = this.cache.get(id) + if (!existing) return { success: false, error: `Skill not found: ${id}` } + if (!existing.builtinId) return { success: false, error: 'Only imported builtin skills can be reimported' } + + const rawContent = this.builtinRawMap.get(existing.builtinId) + if (!rawContent) return { success: false, error: `Builtin template not found: ${existing.builtinId}` } + + const contentWithId = injectBuiltinId(rawContent, existing.builtinId) + const def = parseSkillFile(contentWithId, `${id}.md`) + if (!def) return { success: false, error: `Failed to parse builtin skill: ${existing.builtinId}` } + def.builtinId = existing.builtinId + + return this.saveToDisk(id, contentWithId, def) + } + + importSkillFromMd(rawMd: string): SkillSaveResult & { id?: string } { + this.ensureInitialized() + + const def = parseSkillFile(rawMd, 'cloud_import.md') + if (!def) return { success: false, error: 'Failed to parse skill markdown' } + + if (this.cache.has(def.id)) return { success: false, error: `Skill already exists: ${def.id}` } + + const result = this.saveToDisk(def.id, rawMd, def) + return { ...result, id: result.success ? def.id : undefined } + } + + // ==================== Mutate ==================== + + updateSkill(id: string, rawMd: string): SkillSaveResult { + this.ensureInitialized() + + const existing = this.cache.get(id) + if (!existing) return { success: false, error: `Skill not found: ${id}` } + + const def = parseSkillFile(rawMd, `${id}.md`) + if (!def) return { success: false, error: 'Failed to parse skill content' } + + def.id = id + if (existing.builtinId) def.builtinId = existing.builtinId + + return this.saveToDisk(id, rawMd, def) + } + + createSkill(rawMd: string): SkillSaveResult & { id?: string } { + this.ensureInitialized() + + const def = parseSkillFile(rawMd, 'new_skill.md') + if (!def) return { success: false, error: 'Failed to parse skill content' } + + if (this.cache.has(def.id)) { + def.id = `${def.id}_${Date.now().toString(36)}` + } + + const result = this.saveToDisk(def.id, rawMd, def) + return { ...result, id: result.success ? def.id : undefined } + } + + deleteSkill(id: string): SkillSaveResult { + this.ensureInitialized() + + const existing = this.cache.get(id) + if (!existing) return { success: false, error: `Skill not found: ${id}` } + + try { + const filePath = this.deps.fs.joinPath(this.deps.skillsDir, `${id}.md`) + if (this.deps.fs.fileExists(filePath)) this.deps.fs.deleteFile(filePath) + this.cache.delete(id) + return { success: true } + } catch (error) { + return { success: false, error: String(error) } + } + } + + // ==================== AI Skill Menu ==================== + + getSkillMenu(chatType: 'group' | 'private', allowedTools?: string[]): string | null { + this.ensureInitialized() + + const compatible = Array.from(this.cache.values()).filter((skill) => { + if (skill.chatScope !== 'all' && skill.chatScope !== chatType) return false + if (skill.tools.length > 0 && allowedTools !== undefined) { + if (!skill.tools.every((t) => allowedTools.includes(t))) return false + } + return true + }) + + if (compatible.length === 0) return null + + const items = compatible.slice(0, MAX_SKILL_MENU_ITEMS) + return buildSkillMenuText(items.map(formatSkillMenuLine)) + } + + // ==================== Internal ==================== + + private findByBuiltinId(builtinId: string): SkillDef | undefined { + return Array.from(this.cache.values()).find((s) => s.builtinId === builtinId) + } + + private saveToDisk(id: string, rawMd: string, def: SkillDef): SkillSaveResult { + try { + const filePath = this.deps.fs.joinPath(this.deps.skillsDir, `${id}.md`) + this.deps.fs.writeFile(filePath, rawMd) + this.cache.set(id, def) + return { success: true } + } catch (error) { + this.deps.logger?.error('SkillManager', `Failed to save: ${id}`, { error: String(error) }) + return { success: false, error: String(error) } + } + } + + private hasBuiltinUpdate(builtinId: string, userSkill: SkillDef): boolean { + const rawContent = this.builtinRawMap.get(builtinId) + if (!rawContent) return false + + try { + const userFilePath = this.deps.fs.joinPath(this.deps.skillsDir, `${userSkill.id}.md`) + const userContent = this.deps.fs.readFile(userFilePath) + return this.hashFn(rawContent) !== this.hashFn(stripBuiltinId(userContent)) + } catch { + return false + } + } +} diff --git a/packages/node-runtime/src/ai/skill-manager.ts b/packages/node-runtime/src/ai/skill-manager.ts new file mode 100644 index 000000000..383e5b609 --- /dev/null +++ b/packages/node-runtime/src/ai/skill-manager.ts @@ -0,0 +1,95 @@ +/** + * 技能管理器(平台无关) + * + * 从 aiDataDir/skills/*.md 加载技能定义, + * 提供查询和 AI 自选菜单构建。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import { SkillManagerCore, type SkillInitResult, type SkillManagerFs } from './skill-manager-core' +import type { SkillDef, SkillSummary } from './types' + +const SKILLS_DIR_NAME = 'skills' + +export interface SkillManagerLogger { + info(message: string, extra?: Record): void + warn(message: string, extra?: Record): void +} + +const defaultLogger: SkillManagerLogger = { + info: () => {}, + warn: () => {}, +} + +const nodeFs: SkillManagerFs = { + ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }) + }, + listFiles(dir, ext) { + if (!fs.existsSync(dir)) return [] + return fs.readdirSync(dir).filter((file) => file.endsWith(ext)) + }, + readFile(filePath) { + return fs.readFileSync(filePath, 'utf-8') + }, + writeFile(filePath, content) { + fs.writeFileSync(filePath, content, 'utf-8') + }, + deleteFile(filePath) { + fs.unlinkSync(filePath) + }, + fileExists(filePath) { + return fs.existsSync(filePath) + }, + joinPath(...parts) { + return path.join(...parts) + }, +} + +export class SkillManager { + private core: SkillManagerCore + private logger: SkillManagerLogger + + constructor(aiDataDir: string, logger?: SkillManagerLogger) { + this.logger = logger ?? defaultLogger + this.core = new SkillManagerCore({ + fs: nodeFs, + skillsDir: path.join(aiDataDir, SKILLS_DIR_NAME), + builtinRawSkills: [], + logger: { + info: () => {}, + warn: (_category, message, data) => this.logger.warn(message, toLoggerExtra(data)), + error: (_category, message, data) => this.logger.warn(message, toLoggerExtra(data)), + }, + }) + } + + init(): SkillInitResult { + const result = this.core.init() + this.logger.info(`SkillManager initialized: ${result.total} skills`) + return result + } + + getSkillConfig(id: string): SkillDef | null { + return this.core.getSkillConfig(id) + } + + getAllSkills(): SkillSummary[] { + return this.core.getAllSkills() + } + + /** + * 构建 AI 自选技能菜单文本 + * 只包含与当前 chatType + 助手工具权限兼容的技能 + */ + getSkillMenu(chatType: 'group' | 'private', allowedTools?: string[]): string | null { + return this.core.getSkillMenu(chatType, allowedTools) + } +} + +function toLoggerExtra(data: unknown): Record | undefined { + if (data === undefined) return undefined + if (data && typeof data === 'object' && !Array.isArray(data)) return data as Record + return { data } +} diff --git a/packages/node-runtime/src/ai/skill-menu.ts b/packages/node-runtime/src/ai/skill-menu.ts new file mode 100644 index 000000000..5440db0be --- /dev/null +++ b/packages/node-runtime/src/ai/skill-menu.ts @@ -0,0 +1,48 @@ +export const MAX_SKILL_MENU_ITEMS = 15 + +const SKILL_MENU_HEADER = `## 可用技能 +以下是你可以使用的分析技能。当你判断用户的问题适合使用某个技能时, +请调用 activate_skill 工具激活它,然后按照返回的指导完成任务。` + +const SKILL_MENU_CLOSING = '如果用户的问题不需要使用技能,直接回答即可。' +const SKILL_MENU_CLOSING_BLOCK = `\n\n${SKILL_MENU_CLOSING}` + +export interface SkillMenuItem { + id: string + name: string + description: string + guidance?: string +} + +export function formatSkillMenuLine(item: SkillMenuItem): string { + const guidanceSuffix = item.guidance ? `. ${item.guidance}` : '' + return `- ${item.id}: ${item.name} — ${item.description}${guidanceSuffix}` +} + +export function buildSkillMenuText(lines: readonly string[]): string | null { + const visibleLines = normalizeSkillMenuLines(lines) + if (visibleLines.length === 0) return null + + return `${SKILL_MENU_HEADER} + +${visibleLines.join('\n')} + +${SKILL_MENU_CLOSING}` +} + +export function appendSkillMenuLines(baseMenu: string | null | undefined, lines: readonly string[]): string | null { + const visibleLines = normalizeSkillMenuLines(lines) + if (visibleLines.length === 0) return baseMenu ?? null + if (!baseMenu) return buildSkillMenuText(visibleLines) + + const insertion = `\n${visibleLines.join('\n')}` + if (!baseMenu.includes(SKILL_MENU_CLOSING_BLOCK)) { + return `${baseMenu}${insertion}` + } + + return baseMenu.replace(SKILL_MENU_CLOSING_BLOCK, `${insertion}${SKILL_MENU_CLOSING_BLOCK}`) +} + +function normalizeSkillMenuLines(lines: readonly string[]): string[] { + return lines.map((line) => line.trim()).filter(Boolean) +} diff --git a/packages/node-runtime/src/ai/skill-parser.ts b/packages/node-runtime/src/ai/skill-parser.ts new file mode 100644 index 000000000..eb91ad4e4 --- /dev/null +++ b/packages/node-runtime/src/ai/skill-parser.ts @@ -0,0 +1,54 @@ +/** + * 技能 MD 文件解析器(平台无关,Node.js 实现) + */ + +import * as path from 'path' +import matter from 'gray-matter' +import type { SkillDef } from './types' + +export function parseSkillFile(content: string, filePath: string): SkillDef | null { + try { + const { data: fm, content: prompt } = matter(content) + + const id = fm.id ?? path.basename(filePath, '.md') + const name = fm.name + if (!name) return null + + return { + id, + name, + description: fm.description ?? '', + tags: parseTags(fm.tags), + chatScope: validateChatScope(fm.chatScope), + tools: Array.isArray(fm.tools) ? fm.tools : [], + prompt: prompt.trim(), + } + } catch { + return null + } +} + +export function extractSkillId(content: string, filePath: string): string | null { + try { + const { data: fm } = matter(content) + return fm.id ?? path.basename(filePath, '.md') + } catch { + return null + } +} + +function parseTags(raw: unknown): string[] { + if (typeof raw === 'string') { + return raw + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + } + if (Array.isArray(raw)) return raw.map(String).filter(Boolean) + return [] +} + +function validateChatScope(raw: unknown): 'all' | 'group' | 'private' { + if (raw === 'group' || raw === 'private') return raw + return 'all' +} diff --git a/packages/node-runtime/src/ai/summary/__tests__/summary.test.ts b/packages/node-runtime/src/ai/summary/__tests__/summary.test.ts new file mode 100644 index 000000000..03d5aee94 --- /dev/null +++ b/packages/node-runtime/src/ai/summary/__tests__/summary.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for shared summary pipeline. + * + * Run: npx tsx --test packages/node-runtime/src/ai/summary/__tests__/summary.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + isValidMessage, + filterValidMessages, + splitIntoSegments, + generateSessionSummary, + checkSessionsCanGenerateSummary, +} from '../index' +import type { SummaryDeps, SummaryMessage } from '../index' + +describe('isValidMessage', () => { + it('rejects empty content', () => { + assert.equal(isValidMessage(''), false) + assert.equal(isValidMessage(' '), false) + }) + + it('rejects meaningless short replies', () => { + assert.equal(isValidMessage('嗯'), false) + assert.equal(isValidMessage('ok'), false) + assert.equal(isValidMessage('lol'), false) + }) + + it('accepts meaningful short replies', () => { + assert.equal(isValidMessage('好的'), true) + assert.equal(isValidMessage('可以'), true) + }) + + it('rejects placeholders', () => { + assert.equal(isValidMessage('[图片]'), false) + assert.equal(isValidMessage('[image]'), false) + assert.equal(isValidMessage('[sticker]'), false) + }) + + it('accepts normal text', () => { + assert.equal(isValidMessage('今天天气真好'), true) + assert.equal(isValidMessage('Hello, how are you?'), true) + }) + + it('rejects system messages', () => { + assert.equal(isValidMessage('张三邀请李四加入了群聊'), false) + assert.equal(isValidMessage('Alice invited Bob to the group'), false) + }) +}) + +describe('filterValidMessages', () => { + it('filters out invalid messages', () => { + const messages: SummaryMessage[] = [ + { senderName: 'A', content: '你好世界' }, + { senderName: 'B', content: '[图片]' }, + { senderName: 'C', content: null }, + { senderName: 'D', content: '好的,我知道了' }, + ] + const result = filterValidMessages(messages) + assert.equal(result.length, 2) + assert.equal(result[0].senderName, 'A') + assert.equal(result[1].senderName, 'D') + }) +}) + +describe('splitIntoSegments', () => { + it('splits messages by character limit', () => { + const messages = Array.from({ length: 10 }, () => ({ + senderName: 'User', + content: 'A'.repeat(100), + })) + const segments = splitIntoSegments(messages, 350) + assert.ok(segments.length >= 3) + for (const seg of segments) { + assert.ok(seg.length > 0) + } + }) + + it('returns single segment for short input', () => { + const messages = [{ senderName: 'A', content: 'short' }] + const segments = splitIntoSegments(messages, 1000) + assert.equal(segments.length, 1) + }) +}) + +describe('generateSessionSummary', () => { + function mockDeps( + messages: SummaryMessage[] | null, + existingSummary?: string + ): SummaryDeps & { getSavedSummary: () => string } { + const state = { saved: '' } + return { + loadMessages: () => messages, + saveSummary: (_id, s) => { + state.saved = s + }, + getSummary: () => existingSummary ?? null, + llmComplete: async (_sys, _usr) => 'Mock summary result', + t: (key) => `[${key}]`, + getSavedSummary: () => state.saved, + } + } + + it('returns existing summary when not forcing regeneration', async () => { + const deps = mockDeps(null, 'Existing summary') + const result = await generateSessionSummary(deps, 1) + assert.equal(result.success, true) + assert.equal(result.summary, 'Existing summary') + }) + + it('regenerates when forceRegenerate is true', async () => { + const msgs: SummaryMessage[] = Array.from({ length: 5 }, (_, i) => ({ + senderName: `User${i}`, + content: `Message content number ${i} with enough text`, + })) + const deps = mockDeps(msgs, 'Old summary') + const result = await generateSessionSummary(deps, 1, { forceRegenerate: true }) + assert.equal(result.success, true) + assert.equal(result.summary, 'Mock summary result') + }) + + it('returns error when too few messages', async () => { + const deps = mockDeps([{ senderName: 'A', content: 'hi' }]) + const result = await generateSessionSummary(deps, 1) + assert.equal(result.success, false) + }) + + it('returns error when session not found', async () => { + const deps = mockDeps(null) + const result = await generateSessionSummary(deps, 1) + assert.equal(result.success, false) + }) +}) + +describe('checkSessionsCanGenerateSummary', () => { + it('correctly identifies eligible sessions', () => { + const deps = { + loadMessages: (id: number) => { + if (id === 1) return Array.from({ length: 5 }, () => ({ senderName: 'A', content: 'Good content here' })) + if (id === 2) return [{ senderName: 'B', content: '[图片]' }] + return null + }, + t: (key: string) => `[${key}]`, + } + + const results = checkSessionsCanGenerateSummary(deps, [1, 2, 3]) + assert.equal(results.get(1)?.canGenerate, true) + assert.equal(results.get(2)?.canGenerate, false) + assert.equal(results.get(3)?.canGenerate, false) + }) +}) diff --git a/packages/node-runtime/src/ai/summary/index.ts b/packages/node-runtime/src/ai/summary/index.ts new file mode 100644 index 000000000..4e811e213 --- /dev/null +++ b/packages/node-runtime/src/ai/summary/index.ts @@ -0,0 +1,369 @@ +/** + * Session summary generation — shared implementation. + * + * Uses Map-Reduce strategy for long conversations. + * All platform-specific concerns (DB access, LLM invocation, i18n) are injected + * via the SummaryDeps interface. + */ + +// ==================== Types ==================== + +export interface SummaryMessage { + senderName: string + content: string | null +} + +export interface SummaryDeps { + loadMessages: (segmentId: number, limit?: number) => SummaryMessage[] | null + saveSummary: (segmentId: number, summary: string) => void + getSummary: (segmentId: number) => string | null + llmComplete: ( + systemPrompt: string, + userPrompt: string, + options?: { temperature?: number; maxTokens?: number } + ) => Promise + t: (key: string, options?: Record) => string + logger?: { + info: (category: string, message: string, data?: unknown) => void + error: (category: string, message: string, data?: unknown) => void + } +} + +export type SummaryStrategy = 'brief' | 'standard' + +export interface SummaryOptions { + locale?: string + forceRegenerate?: boolean + strategy?: SummaryStrategy +} + +export interface SummaryResult { + success: boolean + summary?: string + error?: string +} + +// ==================== Constants ==================== + +const MIN_MESSAGE_COUNT = 3 +const MAX_CONTENT_PER_CALL = 8000 +const SEGMENT_THRESHOLD = 8000 + +// ==================== Pure algorithms ==================== + +function getSummaryLengthLimit(messageCount: number, strategy: SummaryStrategy = 'standard'): number { + if (strategy === 'brief') { + if (messageCount <= 10) return 50 + if (messageCount <= 30) return 80 + if (messageCount <= 100) return 120 + return 200 + } + // standard: richer summaries covering who, what, and key details + if (messageCount <= 10) return 100 + if (messageCount <= 30) return 200 + if (messageCount <= 100) return 350 + return 500 +} + +const MEANINGFUL_SHORT_ZH = ['好的', '不是', '是的', '可以', '不行', '好吧', '明白', '知道', '同意'] +const MEANINGLESS_SHORT_EN = [ + 'ok', + 'k', + 'yes', + 'no', + 'ya', + 'yep', + 'nope', + 'lol', + 'haha', + 'hehe', + 'hmm', + 'ah', + 'oh', + 'wow', + 'thx', + 'ty', + 'np', + 'gg', + 'brb', + 'idk', +] +const PLACEHOLDERS = [ + '[图片]', + '[语音]', + '[视频]', + '[文件]', + '[表情]', + '[动画表情]', + '[位置]', + '[名片]', + '[红包]', + '[转账]', + '[撤回消息]', + '[image]', + '[voice]', + '[video]', + '[file]', + '[sticker]', + '[animated sticker]', + '[location]', + '[contact]', + '[red packet]', + '[transfer]', + '[recalled message]', + '[photo]', + '[audio]', + '[gif]', +] +const SYSTEM_PATTERNS_ZH = [/^.*邀请.*加入了群聊$/, /^.*退出了群聊$/, /^.*撤回了一条消息$/, /^你撤回了一条消息$/] +const SYSTEM_PATTERNS_EN = [ + /^.*invited.*to the group$/i, + /^.*left the group$/i, + /^.*recalled a message$/i, + /^you recalled a message$/i, + /^.*joined the group$/i, + /^.*has been removed$/i, +] + +export function isValidMessage(content: string): boolean { + const trimmed = content.trim() + if (!trimmed) return false + + if (trimmed.length <= 2 && !MEANINGFUL_SHORT_ZH.includes(trimmed)) return false + + const lower = trimmed.toLowerCase() + if (MEANINGLESS_SHORT_EN.includes(lower)) return false + + if (/^[\p{Emoji}\s[\]()()]+$/u.test(trimmed)) return false + if (PLACEHOLDERS.some((p) => lower === p.toLowerCase())) return false + if (SYSTEM_PATTERNS_ZH.some((p) => p.test(trimmed))) return false + if (SYSTEM_PATTERNS_EN.some((p) => p.test(trimmed))) return false + + return true +} + +export function filterValidMessages(messages: SummaryMessage[]): Array<{ senderName: string; content: string }> { + return messages + .filter((m) => m.content && isValidMessage(m.content)) + .map((m) => ({ senderName: m.senderName, content: m.content!.trim() })) +} + +function formatMessages(messages: Array<{ senderName: string; content: string }>): string { + return messages.map((m) => `${m.senderName}: ${m.content}`).join('\n') +} + +export function splitIntoSegments( + messages: Array<{ senderName: string; content: string }>, + maxCharsPerSegment: number +): Array> { + const segments: Array> = [] + let currentSegment: Array<{ senderName: string; content: string }> = [] + let currentLength = 0 + + for (const msg of messages) { + const msgLength = msg.senderName.length + msg.content.length + 3 + if (currentLength + msgLength > maxCharsPerSegment && currentSegment.length > 0) { + segments.push(currentSegment) + currentSegment = [] + currentLength = 0 + } + currentSegment.push(msg) + currentLength += msgLength + } + if (currentSegment.length > 0) segments.push(currentSegment) + return segments +} + +// ==================== Prompt builders ==================== + +function buildSummaryPrompt(content: string, lengthLimit: number, locale: string, strategy: SummaryStrategy): string { + if (strategy === 'brief') { + if (locale.startsWith('zh')) { + return `请用简洁的语言(${lengthLimit}字以内)总结以下对话的主要内容或话题。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${content}` + } + return `Summarize the following conversation concisely (max ${lengthLimit} characters). Output only the summary, no prefix, explanation, or quotes.\n\n${content}` + } + if (locale.startsWith('zh')) { + return `请总结以下聊天记录(${lengthLimit}字以内),需包含:谁参与了讨论、讨论了什么事情、关键细节和结论。用2-4句话描述,不要罗列每条消息。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${content}` + } + return `Summarize the following chat log (max ${lengthLimit} characters). Include: who participated, what was discussed, key details and conclusions. Use 2-4 sentences, don't list individual messages. Output only the summary, no prefix, explanation, or quotes.\n\n${content}` +} + +function buildSubSummaryPrompt(content: string, locale: string, strategy: SummaryStrategy): string { + const limit = strategy === 'brief' ? 50 : 100 + if (locale.startsWith('zh')) { + return `请用一到两句话(不超过${limit}字)概括以下对话片段的主要内容,包括谁说了什么。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${content}` + } + return `Summarize this conversation segment in 1-2 sentences (max ${limit} characters), including who said what. Output only the summary, no prefix or quotes.\n\n${content}` +} + +function buildMergePrompt( + subSummaries: string[], + lengthLimit: number, + locale: string, + strategy: SummaryStrategy +): string { + const list = subSummaries.map((s, i) => `${i + 1}. ${s}`).join('\n') + if (strategy === 'brief') { + if (locale.startsWith('zh')) { + return `以下是一段对话的多个片段摘要,请将它们合并成一个完整的总结(${lengthLimit}字以内)。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${list}` + } + return `Below are summaries of different parts of a conversation. Merge them into one cohesive summary (max ${lengthLimit} characters). Output only the summary, no prefix or quotes.\n\n${list}` + } + if (locale.startsWith('zh')) { + return `以下是一段对话的多个片段摘要,请将它们合并成一个完整的总结(${lengthLimit}字以内)。保留参与者、事件和关键细节。只输出摘要内容,不要添加任何前缀、解释或引号。\n\n${list}` + } + return `Below are summaries of different parts of a conversation. Merge them into one cohesive summary (max ${lengthLimit} characters). Preserve participants, events, and key details. Output only the summary, no prefix or quotes.\n\n${list}` +} + +// ==================== Post-processing ==================== + +function postProcessSummary(summary: string, lengthLimit: number): string { + let result = summary + if ((result.startsWith('"') && result.endsWith('"')) || (result.startsWith('「') && result.endsWith('」'))) { + result = result.slice(1, -1) + } + const hardLimit = Math.floor(lengthLimit * 1.5) + if (result.length > hardLimit) { + result = result.slice(0, hardLimit - 3) + '...' + } + return result +} + +// ==================== Public API ==================== + +export async function generateSessionSummary( + deps: SummaryDeps, + segmentId: number, + options: SummaryOptions = {} +): Promise { + const { locale = 'zh-CN', forceRegenerate = false, strategy = 'standard' } = options + const log = deps.logger + + try { + if (!forceRegenerate) { + const existing = deps.getSummary(segmentId) + if (existing) return { success: true, summary: existing } + } + + const rawMessages = deps.loadMessages(segmentId) + if (!rawMessages) { + return { success: false, error: deps.t('summary.sessionNotFound') } + } + + if (rawMessages.length < MIN_MESSAGE_COUNT) { + return { success: false, error: deps.t('summary.tooFewMessages', { count: MIN_MESSAGE_COUNT }) } + } + + const validMessages = filterValidMessages(rawMessages) + if (validMessages.length < MIN_MESSAGE_COUNT) { + return { success: false, error: deps.t('summary.tooFewValidMessages', { count: MIN_MESSAGE_COUNT }) } + } + + const lengthLimit = getSummaryLengthLimit(validMessages.length, strategy) + const maxTokens = strategy === 'brief' ? 300 : 600 + const subMaxTokens = strategy === 'brief' ? 100 : 200 + const content = formatMessages(validMessages) + + log?.info( + 'Summary', + `Generating summary: sessionId=${segmentId}, strategy=${strategy}, raw=${rawMessages.length}, valid=${validMessages.length}, chars=${content.length}` + ) + + let summary: string + if (content.length <= SEGMENT_THRESHOLD) { + summary = await deps.llmComplete( + deps.t('summary.systemPromptDirect'), + buildSummaryPrompt(content, lengthLimit, locale, strategy), + { temperature: 0.3, maxTokens } + ) + summary = summary.trim() + } else { + const segments = splitIntoSegments(validMessages, MAX_CONTENT_PER_CALL) + log?.info('Summary', `Long session segmented: ${segments.length} segments`) + + const subSummaries: string[] = [] + for (const segment of segments) { + const segContent = formatMessages(segment) + const sub = await deps.llmComplete( + deps.t('summary.systemPromptDirect'), + buildSubSummaryPrompt(segContent, locale, strategy), + { temperature: 0.3, maxTokens: subMaxTokens } + ) + subSummaries.push(sub.trim()) + } + + if (subSummaries.length === 1) { + summary = subSummaries[0] + } else { + summary = await deps.llmComplete( + deps.t('summary.systemPromptMerge'), + buildMergePrompt(subSummaries, lengthLimit, locale, strategy), + { temperature: 0.3, maxTokens } + ) + summary = summary.trim() + } + } + + summary = postProcessSummary(summary, lengthLimit) + deps.saveSummary(segmentId, summary) + + log?.info('Summary', `Summary generated: "${summary.slice(0, 50)}..."`) + return { success: true, summary } + } catch (error) { + log?.error('Summary', 'Summary generation failed', error) + return { success: false, error: error instanceof Error ? error.message : String(error) } + } +} + +export async function generateSessionSummaries( + deps: SummaryDeps, + segmentIds: number[], + options: SummaryOptions = {}, + onProgress?: (current: number, total: number) => void +): Promise<{ success: number; failed: number; skipped: number }> { + let success = 0 + let failed = 0 + let skipped = 0 + + for (let i = 0; i < segmentIds.length; i++) { + const result = await generateSessionSummary(deps, segmentIds[i], options) + if (result.success) { + success++ + } else if (result.error?.includes('少于') || result.error?.includes('less than') || result.error?.includes('few')) { + skipped++ + } else { + failed++ + } + onProgress?.(i + 1, segmentIds.length) + } + + return { success, failed, skipped } +} + +export function checkSessionsCanGenerateSummary( + deps: Pick, + segmentIds: number[] +): Map { + const results = new Map() + + for (const id of segmentIds) { + const messages = deps.loadMessages(id) + if (!messages) { + results.set(id, { canGenerate: false, reason: deps.t('summary.sessionNotExist') }) + continue + } + if (messages.length < MIN_MESSAGE_COUNT) { + results.set(id, { canGenerate: false, reason: deps.t('summary.messagesTooFew') }) + continue + } + const valid = filterValidMessages(messages) + if (valid.length < MIN_MESSAGE_COUNT) { + results.set(id, { canGenerate: false, reason: deps.t('summary.validMessagesTooFew') }) + continue + } + results.set(id, { canGenerate: true }) + } + + return results +} diff --git a/packages/node-runtime/src/ai/tokenizer.ts b/packages/node-runtime/src/ai/tokenizer.ts new file mode 100644 index 000000000..abc463f80 --- /dev/null +++ b/packages/node-runtime/src/ai/tokenizer.ts @@ -0,0 +1,97 @@ +/** + * Token 计数模块 + * + * 使用 js-tiktoken/lite + cl100k_base 编码进行近似 token 计数。 + * cl100k_base 是 GPT-4 / Claude 系列的近似值,对国内模型有一定误差, + * 因此阈值计算时预留了余量。 + * + * 【打包优化】使用 lite 入口 + 动态 import rank 表: + * - `js-tiktoken/lite` 本身不内联任何 BPE rank 数据(~0KB 额外体积) + * - rank 表通过动态 import 加载,rollup 会将其拆为独立 chunk, + * 不再随主进程启动路径同步加载(原方式会内联 ~1MB base64 数据) + * - 调用 initTokenizer() 完成后,countTokens/countMessagesTokens 使用精确计数; + * 初始化前的调用会降级到轻量字符估算(误差 <10%,适合"近似"场景) + */ + +import { Tiktoken } from 'js-tiktoken/lite' + +let encoder: Tiktoken | null = null +let initPromise: Promise | null = null + +/** + * 异步初始化 tokenizer。 + * 应在 AI agent 启动前 await 此函数,确保压缩/预处理路径使用精确计数。 + * 可安全多次调用(幂等)。 + */ +export async function initTokenizer(): Promise { + if (encoder) return + if (initPromise) return initPromise + + initPromise = (async () => { + const ranks = (await import('js-tiktoken/ranks/cl100k_base')).default + encoder = new Tiktoken(ranks) + })() + + return initPromise +} + +/** + * 轻量字符估算(fallback) + * CJK 字符约 1.6 字符/token,ASCII 约 4 字符/token + */ +function estimateTokens(text: string): number { + if (!text) return 0 + let cjk = 0 + let other = 0 + for (const ch of text) { + const cp = ch.codePointAt(0) ?? 0 + // CJK 统一表意文字 + 扩展区 + 假名 + 谚文 + if ( + (cp >= 0x4e00 && cp <= 0x9fff) || + (cp >= 0x3040 && cp <= 0x30ff) || + (cp >= 0xac00 && cp <= 0xd7af) || + (cp >= 0x3400 && cp <= 0x4dbf) + ) { + cjk++ + } else { + other++ + } + } + return Math.ceil(cjk / 1.6 + other / 4) +} + +/** + * 计算单段文本的 token 数。 + * encoder 已初始化时使用精确计数,否则降级到字符估算。 + */ +export function countTokens(text: string): number { + if (!text) return 0 + if (encoder) { + return encoder.encode(text).length + } + return estimateTokens(text) +} + +/** + * 计算消息列表的总 token 数(含 systemPrompt)。 + * 每条消息额外计 4 tokens 的格式开销(role + 分隔符)。 + */ +export function countMessagesTokens( + messages: Array<{ role: string; content: string }>, + systemPrompt?: string, +): number { + let total = 0 + + if (systemPrompt) { + total += countTokens(systemPrompt) + 4 + } + + for (const msg of messages) { + total += countTokens(msg.content) + 4 + } + + // 回复引导 token + total += 3 + + return total +} diff --git a/packages/node-runtime/src/ai/types.ts b/packages/node-runtime/src/ai/types.ts new file mode 100644 index 000000000..cea406180 --- /dev/null +++ b/packages/node-runtime/src/ai/types.ts @@ -0,0 +1,47 @@ +/** + * AI 模块共享类型(助手、技能的解析结构) + * + * 与 Electron 端 types.ts 的同名接口结构一致(TypeScript 结构类型兼容)。 + */ + +export interface AssistantConfig { + id: string + name: string + systemPrompt: string + presetQuestions: string[] + allowedBuiltinTools?: string[] + builtinId?: string + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +export interface AssistantSummary { + id: string + name: string + systemPrompt: string + presetQuestions: string[] + builtinId?: string + applicableChatTypes?: ('group' | 'private')[] + supportedLocales?: string[] +} + +export interface SkillDef { + id: string + name: string + description: string + tags: string[] + chatScope: 'all' | 'group' | 'private' + prompt: string + tools: string[] + builtinId?: string +} + +export interface SkillSummary { + id: string + name: string + description: string + tags: string[] + chatScope: 'all' | 'group' | 'private' + tools: string[] + builtinId?: string +} diff --git a/packages/node-runtime/src/better-sqlite3-adapter.ts b/packages/node-runtime/src/better-sqlite3-adapter.ts new file mode 100644 index 000000000..3c0dd98dc --- /dev/null +++ b/packages/node-runtime/src/better-sqlite3-adapter.ts @@ -0,0 +1,84 @@ +/** + * DatabaseAdapter 的 better-sqlite3 实现 + * + * 薄包装层:better-sqlite3 的 API 与 DatabaseAdapter 接口天然匹配, + * 本适配器主要做类型桥接,几乎零额外逻辑。 + */ + +import Database from 'better-sqlite3' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '@openchatlab/core' + +class BetterSqlitePreparedStatement implements PreparedStatement { + readonly?: boolean + + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } + + get(...params: unknown[]): Record | undefined { + return this.stmt.get(...params) as Record | undefined + } + + all(...params: unknown[]): Record[] { + return this.stmt.all(...params) as Record[] + } + + run(...params: unknown[]): RunResult { + const result = this.stmt.run(...params) + return { + changes: result.changes, + lastInsertRowid: result.lastInsertRowid, + } + } +} + +/** + * 基于 better-sqlite3 的 DatabaseAdapter 实现 + */ +export class BetterSqliteAdapter implements DatabaseAdapter { + readonly?: boolean + + constructor(private db: Database.Database) { + this.readonly = db.readonly + } + + exec(sql: string): void { + this.db.exec(sql) + } + + prepare(sql: string): PreparedStatement { + return new BetterSqlitePreparedStatement(this.db.prepare(sql)) + } + + transaction(fn: () => T): T { + return this.db.transaction(fn)() + } + + pragma(pragma: string): unknown { + return this.db.pragma(pragma) + } + + close(): void { + this.db.close() + } +} + +/** + * 从文件路径打开数据库并返回适配器 + * + * @param options.nativeBinding - 指定 better-sqlite3 原生模块路径, + * 用于在独立 Node.js 环境中加载与 Electron 隔离的二进制。 + */ +export function openBetterSqliteDatabase( + filePath: string, + options?: { readonly?: boolean; nativeBinding?: string } +): BetterSqliteAdapter { + const db = new Database(filePath, { + readonly: options?.readonly ?? false, + nativeBinding: options?.nativeBinding, + }) + if (!options?.readonly) { + db.pragma('journal_mode = WAL') + } + return new BetterSqliteAdapter(db) +} diff --git a/packages/node-runtime/src/cache/analytics-cache.test.ts b/packages/node-runtime/src/cache/analytics-cache.test.ts new file mode 100644 index 000000000..f75c2cfa4 --- /dev/null +++ b/packages/node-runtime/src/cache/analytics-cache.test.ts @@ -0,0 +1,127 @@ +/** + * Analytics result cache tests. + * + * 运行:node --import tsx --test packages/node-runtime/src/cache/analytics-cache.test.ts + * + * 覆盖:版本指纹派生、命中/未命中、版本变更后重算覆盖、损坏缓存兜底。 + * 这些是 CLI Web 与桌面端共享的分析结果缓存核心,失效一旦错误会导致脏数据展示,必须回归。 + */ + +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { getDbFileVersion, getOrComputeAnalysisCache } from './analytics-cache' + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'analytics-cache-')) +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('getDbFileVersion', () => { + it('changes when the DB file size changes', () => { + const dbPath = path.join(tmpDir, 's1.db') + fs.writeFileSync(dbPath, 'a') + const v1 = getDbFileVersion(dbPath) + + fs.writeFileSync(dbPath, 'aa') + const v2 = getDbFileVersion(dbPath) + + assert.notEqual(v1, v2, 'size change must change the version') + }) + + it('changes when the -wal sidecar appears or grows', () => { + const dbPath = path.join(tmpDir, 's2.db') + fs.writeFileSync(dbPath, 'main') + const v1 = getDbFileVersion(dbPath) + + fs.writeFileSync(`${dbPath}-wal`, 'wal-bytes') + const v2 = getDbFileVersion(dbPath) + + assert.notEqual(v1, v2, 'wal sidecar change must change the version') + }) + + it('is stable when nothing changes', () => { + const dbPath = path.join(tmpDir, 's3.db') + fs.writeFileSync(dbPath, 'stable') + assert.equal(getDbFileVersion(dbPath), getDbFileVersion(dbPath)) + }) + + it('does not throw when the file is missing', () => { + assert.doesNotThrow(() => getDbFileVersion(path.join(tmpDir, 'missing.db'))) + }) +}) + +describe('getOrComputeAnalysisCache', () => { + const KEY = 'wf:s1-e2' + + it('computes once on cold miss and serves from cache on repeat (same version)', () => { + let calls = 0 + const compute = () => { + calls++ + return { words: ['a', 'b'], n: calls } + } + + const first = getOrComputeAnalysisCache('sess', KEY, tmpDir, 'v1', compute) + const second = getOrComputeAnalysisCache('sess', KEY, tmpDir, 'v1', compute) + + assert.equal(calls, 1, 'compute must run only once') + assert.deepEqual(first, { words: ['a', 'b'], n: 1 }) + assert.deepEqual(second, first, 'second call must return the cached value') + }) + + it('recomputes and overwrites when the version changes', () => { + let calls = 0 + const compute = () => { + calls++ + return { n: calls } + } + + getOrComputeAnalysisCache('sess', KEY, tmpDir, 'v1', compute) + const afterChange = getOrComputeAnalysisCache('sess', KEY, tmpDir, 'v2', compute) + + assert.equal(calls, 2, 'version change must trigger recompute') + assert.deepEqual(afterChange, { n: 2 }) + + // 覆盖后,再用新版本读应命中、不再重算 + const cached = getOrComputeAnalysisCache('sess', KEY, tmpDir, 'v2', compute) + assert.equal(calls, 2) + assert.deepEqual(cached, { n: 2 }) + }) + + it('persists the result to {sessionId}.cache.json under the cache dir', () => { + getOrComputeAnalysisCache('persist-sess', KEY, tmpDir, 'v1', () => ({ ok: true })) + const file = path.join(tmpDir, 'persist-sess.cache.json') + assert.ok(fs.existsSync(file), 'cache file must be written') + const parsed = JSON.parse(fs.readFileSync(file, 'utf-8')) + assert.equal(parsed[KEY].data.v, 'v1') + assert.deepEqual(parsed[KEY].data.data, { ok: true }) + }) + + it('isolates entries by key within the same session file', () => { + let calls = 0 + const compute = () => ({ n: ++calls }) + getOrComputeAnalysisCache('sess', 'k1', tmpDir, 'v1', compute) + getOrComputeAnalysisCache('sess', 'k2', tmpDir, 'v1', compute) + assert.equal(calls, 2, 'different keys must not collide') + + getOrComputeAnalysisCache('sess', 'k1', tmpDir, 'v1', compute) + assert.equal(calls, 2, 'existing key must still be a hit') + }) + + it('falls back to recompute when the cache file is corrupted', () => { + const file = path.join(tmpDir, 'sess.cache.json') + fs.writeFileSync(file, '{ not valid json') + + let calls = 0 + const result = getOrComputeAnalysisCache('sess', KEY, tmpDir, 'v1', () => ({ n: ++calls })) + assert.equal(calls, 1) + assert.deepEqual(result, { n: 1 }) + }) +}) diff --git a/packages/node-runtime/src/cache/analytics-cache.ts b/packages/node-runtime/src/cache/analytics-cache.ts new file mode 100644 index 000000000..4fd1caed8 --- /dev/null +++ b/packages/node-runtime/src/cache/analytics-cache.ts @@ -0,0 +1,67 @@ +/** + * Analytics result cache (platform-agnostic). + * + * Caches expensive analytics / NLP computations (word frequency, catchphrase, + * activity stats, ...) as JSON under {queryCacheDir}/{sessionId}.cache.json, + * reusing the generic session-cache infrastructure. + * + * Each entry carries a `v` fingerprint derived from the session DB file state. + * Any write to the chat DB (import, incremental import, member merge/rename, + * owner change, summary generation) changes the file and thus the fingerprint, + * forcing a recompute. This makes the cache self-validating even across + * processes — e.g. when `chatlab start` is serving while a separate + * `chatlab import` mutates the same database file. + */ + +import * as fs from 'fs' +import { getCache, setCache } from './session-cache' + +interface VersionedEntry { + v: string + data: T +} + +/** + * Derive a cheap version fingerprint from the session DB file state. + * + * Combines mtime + size of the main DB file and its `-wal` sidecar so that + * WAL-only writes (before a checkpoint) are detected too. Missing files + * contribute a placeholder; a fully missing DB yields a stable placeholder + * version, which is acceptable because the caller only computes once the DB + * actually exists. + */ +export function getDbFileVersion(dbPath: string): string { + const parts: string[] = [] + for (const p of [dbPath, `${dbPath}-wal`]) { + try { + const st = fs.statSync(p) + parts.push(`${Math.floor(st.mtimeMs)}:${st.size}`) + } catch { + parts.push('-') + } + } + return parts.join('|') +} + +/** + * Cache-first analytics read with version validation. + * + * Returns the cached value when the stored fingerprint matches `version`; + * otherwise runs `compute()`, persists the result tagged with the current + * version (overwriting any stale entry under the same key), and returns it. + */ +export function getOrComputeAnalysisCache( + sessionId: string, + key: string, + queryCacheDir: string, + version: string, + compute: () => T +): T { + const cached = getCache>(sessionId, key, queryCacheDir) + if (cached && cached.v === version) { + return cached.data + } + const data = compute() + setCache>(sessionId, key, { v: version, data }, queryCacheDir) + return data +} diff --git a/packages/node-runtime/src/cache/index.ts b/packages/node-runtime/src/cache/index.ts new file mode 100644 index 000000000..a57b3b9c7 --- /dev/null +++ b/packages/node-runtime/src/cache/index.ts @@ -0,0 +1,14 @@ +export { + getCachePath, + getCache, + setCache, + invalidateCache, + deleteSessionCache, + computeAndSetOverviewCache, + computeAndSetMembersCache, + getValidatedOverviewCache, + CACHE_KEY_OVERVIEW, + CACHE_KEY_MEMBERS, +} from './session-cache' +export type { OverviewCache, MembersCache, MemberStat } from './session-cache' +export { getDbFileVersion, getOrComputeAnalysisCache } from './analytics-cache' diff --git a/packages/node-runtime/src/cache/session-cache.test.ts b/packages/node-runtime/src/cache/session-cache.test.ts new file mode 100644 index 000000000..5fecbfd92 --- /dev/null +++ b/packages/node-runtime/src/cache/session-cache.test.ts @@ -0,0 +1,147 @@ +/** + * Regression tests for overview cache self-invalidation. + * + * Key scenario: after incremental import inserts new messages the cache + * fingerprint (MAX(message.id)) must mismatch, triggering a recompute so + * the AI system prompt always sees the latest lastMessageTs. + */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import Database from 'better-sqlite3' +import { CHAT_DB_SCHEMA } from '@openchatlab/core' +import { BetterSqliteAdapter } from '../better-sqlite3-adapter' +import { + computeAndSetOverviewCache, + getCache, + setCache, + getValidatedOverviewCache, + CACHE_KEY_OVERVIEW, + type OverviewCache, +} from './session-cache' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-session-cache-')) +} + +/** + * Create an in-memory (or temp-file) chat DB with schema and one member so + * sender_id FK joins work. + */ +function makeTestDb(filePath?: string): Database.Database { + const db = new Database(filePath ?? ':memory:', { nativeBinding }) + db.exec(CHAT_DB_SCHEMA) + // Insert a system member and a regular member + db.prepare( + `INSERT INTO member (platform_id, account_name, group_nickname, avatar, roles) + VALUES (?, ?, ?, NULL, '[]')` + ).run('sys', '系统消息', null) + db.prepare( + `INSERT INTO member (platform_id, account_name, group_nickname, avatar, roles) + VALUES (?, ?, ?, NULL, '[]')` + ).run('u1', 'Alice', 'Alice') + return db +} + +function insertMessage(db: Database.Database, senderId: number, ts: number): void { + db.prepare('INSERT INTO message (sender_id, ts, type, content) VALUES (?, ?, 0, ?)').run(senderId, ts, `msg at ${ts}`) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Test 1: core regression — cache becomes stale after incremental import; must recompute +// ────────────────────────────────────────────────────────────────────────────── +test('getValidatedOverviewCache recomputes when new messages are inserted after caching', () => { + const tmpDir = makeTempDir() + const dbPath = path.join(tmpDir, 'test.db') + const db = makeTestDb(dbPath) + const adapter = new BetterSqliteAdapter(db) + + // Pre-import: two messages, latest ts=1000 + insertMessage(db, 2, 500) // sender_id 2 = Alice (id=2 after sys=1) + insertMessage(db, 2, 1000) + + // Build initial cache (simulates what postImportHook does after first import) + computeAndSetOverviewCache(adapter, 'test-session', tmpDir) + + // Verify cache was written with correct values + const cached = getCache('test-session', CACHE_KEY_OVERVIEW, tmpDir) + assert.ok(cached, 'cache should exist after computeAndSetOverviewCache') + assert.equal(cached.lastMessageTs, 1000, 'cached lastMessageTs should be 1000') + + // Simulate incremental import: insert a newer message (ts=2000) + // This is what happens when the user imports new chat data + insertMessage(db, 2, 2000) + + // NOW call getValidatedOverviewCache — it must detect the fingerprint mismatch + // and recompute, returning the updated lastMessageTs=2000 + const result = getValidatedOverviewCache(adapter, 'test-session', tmpDir) + assert.equal( + result.lastMessageTs, + 2000, + 'getValidatedOverviewCache must return updated lastMessageTs=2000 after incremental import' + ) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Test 2: cache hit — no new messages means fingerprint matches, no recompute +// ────────────────────────────────────────────────────────────────────────────── +test('getValidatedOverviewCache returns cached value without recompute when data is unchanged', () => { + const tmpDir = makeTempDir() + const dbPath = path.join(tmpDir, 'test.db') + const db = makeTestDb(dbPath) + const adapter = new BetterSqliteAdapter(db) + + insertMessage(db, 2, 1000) + + computeAndSetOverviewCache(adapter, 'test-session', tmpDir) + + // Inject a stale lastMessageTs into the cache to prove we are NOT recomputing + const staleOverride: OverviewCache & { maxMessageId: number } = { + totalMessages: 1, + totalMembers: 1, + firstMessageTs: 1000, + lastMessageTs: 999, // intentionally wrong — we want to detect if this is returned as-is + maxMessageId: (db.prepare('SELECT MAX(id) AS m FROM message').get() as { m: number }).m, + } + setCache('test-session', CACHE_KEY_OVERVIEW, staleOverride, tmpDir) + + // Since no new messages were inserted, fingerprint still matches + // The function must return the cached value (999) without recomputing + const result = getValidatedOverviewCache(adapter, 'test-session', tmpDir) + assert.equal(result.lastMessageTs, 999, 'should return cached value unchanged when fingerprint matches (no new data)') +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Test 3: legacy cache without maxMessageId triggers recompute +// ────────────────────────────────────────────────────────────────────────────── +test('getValidatedOverviewCache recomputes for old cache files that lack maxMessageId', () => { + const tmpDir = makeTempDir() + const dbPath = path.join(tmpDir, 'test.db') + const db = makeTestDb(dbPath) + const adapter = new BetterSqliteAdapter(db) + + insertMessage(db, 2, 1500) + + // Manually write a legacy-format cache entry WITHOUT maxMessageId + const legacyCache: OverviewCache = { + totalMessages: 0, + totalMembers: 0, + firstMessageTs: null, + lastMessageTs: null, + // maxMessageId intentionally absent (legacy format) + } + setCache('test-session', CACHE_KEY_OVERVIEW, legacyCache, tmpDir) + + // Must recompute because maxMessageId is missing from cached value + const result = getValidatedOverviewCache(adapter, 'test-session', tmpDir) + assert.equal( + result.lastMessageTs, + 1500, + 'should recompute and return real lastMessageTs when legacy cache lacks maxMessageId' + ) +}) diff --git a/packages/node-runtime/src/cache/session-cache.ts b/packages/node-runtime/src/cache/session-cache.ts new file mode 100644 index 000000000..de809b05c --- /dev/null +++ b/packages/node-runtime/src/cache/session-cache.ts @@ -0,0 +1,204 @@ +/** + * Session-level JSON cache module (platform-agnostic). + * + * Extracted from electron/main/database/sessionCache.ts. + * Each session has a {sessionId}.cache.json file partitioned by key. + * Decoupled from DB schema — reads auto-rebuild on failure, no versioning needed. + * + * File path: {cacheDir}/{sessionId}.cache.json + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { DatabaseAdapter } from '@openchatlab/core' + +// ==================== Generic cache infrastructure ==================== + +interface CacheEntry { + data: T + ts: number +} + +type CacheFile = Record + +export function getCachePath(sessionId: string, cacheDir: string): string { + return path.join(cacheDir, `${sessionId}.cache.json`) +} + +function readCacheFile(cachePath: string): CacheFile | null { + try { + if (!fs.existsSync(cachePath)) return null + return JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as CacheFile + } catch { + return null + } +} + +function writeCacheFile(cachePath: string, content: CacheFile): void { + try { + const dir = path.dirname(cachePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(cachePath, JSON.stringify(content), 'utf-8') + } catch { + // Write failure is non-fatal + } +} + +export function getCache(sessionId: string, key: string, cacheDir: string): T | null { + const cachePath = getCachePath(sessionId, cacheDir) + const file = readCacheFile(cachePath) + if (!file || !file[key]) return null + return file[key].data as T +} + +export function setCache(sessionId: string, key: string, data: T, cacheDir: string): void { + const cachePath = getCachePath(sessionId, cacheDir) + const file = readCacheFile(cachePath) ?? {} + file[key] = { data, ts: Math.floor(Date.now() / 1000) } + writeCacheFile(cachePath, file) +} + +export function invalidateCache(sessionId: string, cacheDir: string, key?: string): void { + const cachePath = getCachePath(sessionId, cacheDir) + try { + if (!key) { + if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath) + } else { + const file = readCacheFile(cachePath) + if (file && file[key]) { + delete file[key] + writeCacheFile(cachePath, file) + } + } + } catch { + // Ignore + } +} + +export function deleteSessionCache(sessionId: string, cacheDir: string): void { + const cachePath = getCachePath(sessionId, cacheDir) + try { + if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath) + } catch { + // Ignore + } +} + +// ==================== Overview cache (aggregate stats) ==================== + +export const CACHE_KEY_OVERVIEW = 'overview' + +export interface OverviewCache { + totalMessages: number + totalMembers: number + firstMessageTs: number | null + lastMessageTs: number | null + /** + * MAX(message.id) at the time the cache was written. + * Used as a cheap O(1) freshness fingerprint: if current MAX(id) differs + * the cache is stale and must be recomputed. Absent in legacy cache files + * written before this field was added; absence is treated as stale. + */ + maxMessageId?: number +} + +/** + * Return the current MAX(message.id) for freshness checking. + * This is O(1) via the AUTOINCREMENT rowid B-tree. + * Returns 0 for an empty table. + */ +function getMaxMessageId(db: DatabaseAdapter): number { + const row = db.prepare('SELECT MAX(id) AS m FROM message').get() as { m: number | null } + return row.m ?? 0 +} + +export function computeAndSetOverviewCache(db: DatabaseAdapter, sessionId: string, cacheDir: string): OverviewCache { + const msgStats = db.prepare('SELECT MIN(ts) as first_ts, MAX(ts) as last_ts FROM message').get() as { + first_ts: number | null + last_ts: number | null + } + + const totalMessages = ( + db + .prepare( + `SELECT COUNT(*) as count FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE COALESCE(m.account_name, '') != '系统消息'` + ) + .get() as { count: number } + ).count + + const totalMembers = ( + db.prepare(`SELECT COUNT(*) as count FROM member WHERE COALESCE(account_name, '') != '系统消息'`).get() as { + count: number + } + ).count + + const data: OverviewCache = { + totalMessages, + totalMembers, + firstMessageTs: msgStats.first_ts, + lastMessageTs: msgStats.last_ts, + maxMessageId: getMaxMessageId(db), + } + + setCache(sessionId, CACHE_KEY_OVERVIEW, data, cacheDir) + computeAndSetMembersCache(db, sessionId, cacheDir) + + return data +} + +/** + * Cache-first overview read with fingerprint validation. + * + * Checks cached `maxMessageId` against the live `MAX(message.id)`. If they + * match the cache is fresh and returned as-is (one extra O(1) query). + * On any mismatch — new inserts, legacy cache lacking the field, or a cold + * miss — the cache is recomputed and written before returning. + * + * Use this instead of bare `getCache` whenever you need a guaranteed-fresh + * overview (e.g. AI system prompt data-snapshot construction). + */ +export function getValidatedOverviewCache(db: DatabaseAdapter, sessionId: string, cacheDir: string): OverviewCache { + const cached = getCache(sessionId, CACHE_KEY_OVERVIEW, cacheDir) + if (cached && cached.maxMessageId !== undefined && cached.maxMessageId === getMaxMessageId(db)) { + return cached + } + return computeAndSetOverviewCache(db, sessionId, cacheDir) +} + +// ==================== Members cache (per-member stats) ==================== + +export const CACHE_KEY_MEMBERS = 'members' + +export interface MemberStat { + name: string + count: number +} + +export interface MembersCache { + members: Record +} + +export function computeAndSetMembersCache(db: DatabaseAdapter, sessionId: string, cacheDir: string): MembersCache { + const rows = db + .prepare( + `SELECT msg.sender_id, COUNT(*) as count, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name + FROM message msg + JOIN member m ON msg.sender_id = m.id + GROUP BY msg.sender_id` + ) + .all() as Array<{ sender_id: number; count: number; name: string }> + + const members: Record = {} + for (const row of rows) { + members[row.sender_id] = { name: row.name, count: row.count } + } + + const data: MembersCache = { members } + setCache(sessionId, CACHE_KEY_MEMBERS, data, cacheDir) + return data +} diff --git a/packages/node-runtime/src/data-dir-compat.test.ts b/packages/node-runtime/src/data-dir-compat.test.ts new file mode 100644 index 000000000..927fa9cd8 --- /dev/null +++ b/packages/node-runtime/src/data-dir-compat.test.ts @@ -0,0 +1,293 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { PathProvider } from '@openchatlab/core' +import { + assertDataDirCompatible, + DataDirCompatibilityError, + raiseDataDirMinRuntimeVersion, + readDataDirCompatibilityMeta, +} from './data-dir-compat' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-data-compat-')) +} + +function makePathProvider(userDataDir: string): PathProvider { + return { + getSystemDir: () => path.join(userDataDir, '..', 'system'), + getUserDataDir: () => userDataDir, + getDatabaseDir: () => path.join(userDataDir, 'databases'), + getVectorDir: () => path.join(userDataDir, 'vector'), + getAiDataDir: () => path.join(userDataDir, '..', 'system', 'ai'), + getSettingsDir: () => path.join(userDataDir, '..', 'system', 'settings'), + getCacheDir: () => path.join(userDataDir, '..', 'system', 'cache'), + getTempDir: () => path.join(userDataDir, '..', 'system', 'temp'), + getLogsDir: () => path.join(userDataDir, '..', 'system', 'logs'), + getDownloadsDir: () => path.join(userDataDir, '..', 'downloads'), + } +} + +function writeMeta(userDataDir: string, meta: unknown): void { + fs.mkdirSync(userDataDir, { recursive: true }) + fs.writeFileSync(path.join(userDataDir, '.chatlab-meta.json'), JSON.stringify(meta, null, 2), 'utf-8') +} + +function withOverride(value: string | undefined, fn: () => T): T { + const previous = process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR + if (value === undefined) { + delete process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR + } else { + process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR = value + } + + try { + return fn() + } finally { + if (previous === undefined) { + delete process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR + } else { + process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR = previous + } + } +} + +test('missing data dir compatibility meta is compatible', () => { + const userDataDir = makeTempDir() + const provider = makePathProvider(userDataDir) + + assert.equal(readDataDirCompatibilityMeta(userDataDir), null) + assert.doesNotThrow(() => { + assertDataDirCompatible(provider, { version: '0.25.1', kind: 'cli' }) + }) +}) + +test('current runtime satisfying minRuntimeVersion is compatible', () => { + const userDataDir = makeTempDir() + writeMeta(userDataDir, { + formatVersion: 1, + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.25.1' }, + updatedAt: 1780830000, + }) + + assert.doesNotThrow(() => { + assertDataDirCompatible(makePathProvider(userDataDir), { version: '0.25.1', kind: 'desktop' }) + }) +}) + +test('prerelease current runtime satisfying stable minRuntimeVersion is compatible', () => { + const userDataDir = makeTempDir() + writeMeta(userDataDir, { + formatVersion: 1, + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.25.1' }, + updatedAt: 1780830000, + }) + + assert.doesNotThrow(() => { + assertDataDirCompatible(makePathProvider(userDataDir), { version: '0.26.4-beta.1', kind: 'desktop' }) + }) +}) + +test('prerelease current runtime is compared by its stable core version', () => { + const equalDir = makeTempDir() + writeMeta(equalDir, { + formatVersion: 1, + minRuntimeVersion: '0.26.4', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.26.4' }, + updatedAt: 1780830000, + }) + + assert.doesNotThrow(() => { + assertDataDirCompatible(makePathProvider(equalDir), { version: '0.26.4-beta.1', kind: 'desktop' }) + }) + + const newerDir = makeTempDir() + writeMeta(newerDir, { + formatVersion: 1, + minRuntimeVersion: '0.26.5', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.26.5' }, + updatedAt: 1780830000, + }) + + assert.throws( + () => assertDataDirCompatible(makePathProvider(newerDir), { version: '0.26.4-beta.1', kind: 'desktop' }), + (error) => + error instanceof DataDirCompatibilityError && + error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' && + error.currentVersion === '0.26.4-beta.1' && + error.minRuntimeVersion === '0.26.5' + ) +}) + +test('current runtime below minRuntimeVersion is blocked', () => { + const userDataDir = makeTempDir() + writeMeta(userDataDir, { + formatVersion: 1, + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '0.25.1' }, + updatedAt: 1780830000, + }) + + assert.throws( + () => { + assertDataDirCompatible(makePathProvider(userDataDir), { version: '0.25.0', kind: 'cli' }) + }, + (error) => + error instanceof DataDirCompatibilityError && + error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' && + error.minRuntimeVersion === '0.25.1' && + error.currentVersion === '0.25.0' && + error.userDataDir === userDataDir + ) +}) + +test('override bypasses only version insufficiency and emits a warning', () => { + const userDataDir = makeTempDir() + const warnings: string[] = [] + writeMeta(userDataDir, { + formatVersion: 1, + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '0.25.1' }, + updatedAt: 1780830000, + }) + + withOverride('1', () => { + assert.doesNotThrow(() => { + assertDataDirCompatible( + makePathProvider(userDataDir), + { version: '0.25.0', kind: 'cli' }, + { + warn: (message) => warnings.push(message), + } + ) + }) + }) + + assert.equal(warnings.length, 1) + assert.match(warnings[0], /CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR=1/) + assert.match(warnings[0], /0\.25\.0/) + assert.match(warnings[0], /0\.25\.1/) + assert.match(warnings[0], new RegExp(userDataDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) +}) + +test('override does not bypass malformed current runtime versions', () => { + const userDataDir = makeTempDir() + writeMeta(userDataDir, { + formatVersion: 1, + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '0.25.1' }, + updatedAt: 1780830000, + }) + + withOverride('1', () => { + assert.throws( + () => assertDataDirCompatible(makePathProvider(userDataDir), { version: '0.0.0-dev', kind: 'cli' }), + (error) => + error instanceof DataDirCompatibilityError && + error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' && + error.currentVersion === '0.0.0-dev' && + error.minRuntimeVersion === '0.25.1' + ) + }) +}) + +test('broken JSON and invalid meta are blocked even with override', () => { + const brokenDir = makeTempDir() + fs.writeFileSync(path.join(brokenDir, '.chatlab-meta.json'), '{ broken', 'utf-8') + + withOverride('1', () => { + assert.throws( + () => assertDataDirCompatible(makePathProvider(brokenDir), { version: '0.25.0', kind: 'cli' }), + (error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_COMPATIBILITY_META_INVALID' + ) + }) + + const invalidDir = makeTempDir() + writeMeta(invalidDir, { + formatVersion: 1, + minRuntimeVersion: '0.25.1-beta.0', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.25.1' }, + updatedAt: 1780830000, + }) + + withOverride('1', () => { + assert.throws( + () => assertDataDirCompatible(makePathProvider(invalidDir), { version: '0.25.0', kind: 'cli' }), + (error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_COMPATIBILITY_META_INVALID' + ) + }) +}) + +test('raising minRuntimeVersion writes atomically and never lowers existing requirements', () => { + const userDataDir = makeTempDir() + const provider = makePathProvider(userDataDir) + + raiseDataDirMinRuntimeVersion(provider, { + minRuntimeVersion: '0.26.0', + dataCompatibilityVersion: 2, + reason: 'future-schema', + runtime: { version: '0.26.0', kind: 'desktop' }, + module: 'future-migration', + now: () => 1780830000, + }) + + raiseDataDirMinRuntimeVersion(provider, { + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reason: 'segment-schema', + runtime: { version: '0.25.1', kind: 'cli' }, + module: 'chat-db-migration', + now: () => 1780830001, + }) + + const meta = readDataDirCompatibilityMeta(userDataDir) + + assert.equal(meta?.formatVersion, 1) + assert.equal(meta?.minRuntimeVersion, '0.26.0') + assert.equal(meta?.dataCompatibilityVersion, 2) + assert.deepEqual(meta?.reasons, ['future-schema', 'segment-schema']) + assert.deepEqual(meta?.updatedBy, { runtime: 'cli', module: 'chat-db-migration', version: '0.25.1' }) + assert.equal(meta?.updatedAt, 1780830001) + assert.equal(fs.existsSync(path.join(userDataDir, '.chatlab-meta.json')), true) +}) + +test('raising minRuntimeVersion records prerelease runtime by stable core version', () => { + const userDataDir = makeTempDir() + + const meta = raiseDataDirMinRuntimeVersion(makePathProvider(userDataDir), { + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reason: 'segment-schema', + runtime: { version: '0.26.4-beta.1', kind: 'desktop' }, + module: 'chat-db-migration', + now: () => 1780830000, + }) + + assert.deepEqual(meta.updatedBy, { runtime: 'desktop', module: 'chat-db-migration', version: '0.26.4' }) + assert.deepEqual(readDataDirCompatibilityMeta(userDataDir)?.updatedBy, { + runtime: 'desktop', + module: 'chat-db-migration', + version: '0.26.4', + }) +}) diff --git a/packages/node-runtime/src/data-dir-compat.ts b/packages/node-runtime/src/data-dir-compat.ts new file mode 100644 index 000000000..6c79395eb --- /dev/null +++ b/packages/node-runtime/src/data-dir-compat.ts @@ -0,0 +1,315 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { PathProvider } from '@openchatlab/core' + +export type RuntimeKind = 'cli' | 'desktop' | 'mcp' | 'unknown' + +export interface RuntimeIdentity { + version: string + kind: RuntimeKind +} + +export interface DataDirCompatibilityMeta { + formatVersion: 1 + minRuntimeVersion: string + dataCompatibilityVersion: number + reasons: string[] + updatedBy: { + runtime: RuntimeKind + module: string + version: string + } + updatedAt: number +} + +export interface RaiseDataDirCompatibilityInput { + minRuntimeVersion: string + dataCompatibilityVersion: number + reason: string + runtime: RuntimeIdentity + module: string + now?: () => number +} + +export interface AssertDataDirCompatibilityOptions { + warn?: (message: string) => void + env?: Pick +} + +type DataDirCompatibilityErrorCode = 'DATA_DIR_REQUIRES_NEWER_RUNTIME' | 'DATA_DIR_COMPATIBILITY_META_INVALID' + +export class DataDirCompatibilityError extends Error { + readonly code: DataDirCompatibilityErrorCode + readonly userDataDir: string + readonly metaPath: string + readonly currentVersion?: string + readonly minRuntimeVersion?: string + readonly statusCode = 409 + + constructor( + code: DataDirCompatibilityErrorCode, + message: string, + options: { + userDataDir: string + metaPath: string + currentVersion?: string + minRuntimeVersion?: string + cause?: unknown + } + ) { + super(message, { cause: options.cause }) + this.name = 'DataDirCompatibilityError' + this.code = code + this.userDataDir = options.userDataDir + this.metaPath = options.metaPath + this.currentVersion = options.currentVersion + this.minRuntimeVersion = options.minRuntimeVersion + } +} + +export function readDataDirCompatibilityMeta(userDataDir: string): DataDirCompatibilityMeta | null { + const metaPath = getMetaPath(userDataDir) + if (!fs.existsSync(metaPath)) return null + + let parsed: unknown + try { + parsed = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) + } catch (error) { + throw createInvalidMetaError(userDataDir, metaPath, 'Invalid data directory compatibility meta JSON.', error) + } + + return validateDataDirCompatibilityMeta(parsed, userDataDir, metaPath) +} + +export function assertDataDirCompatible( + pathProvider: PathProvider, + runtime: RuntimeIdentity, + options: AssertDataDirCompatibilityOptions = {} +): void { + const userDataDir = pathProvider.getUserDataDir() + const meta = readDataDirCompatibilityMeta(userDataDir) + if (!meta) return + + const runtimeStableVersion = normalizeRuntimeStableVersion(runtime.version) + if (!runtimeStableVersion) { + throw new DataDirCompatibilityError( + 'DATA_DIR_REQUIRES_NEWER_RUNTIME', + `ChatLab data directory compatibility check requires a valid runtime semver; current version is ${runtime.version}.`, + { + userDataDir, + metaPath: getMetaPath(userDataDir), + currentVersion: runtime.version, + minRuntimeVersion: meta.minRuntimeVersion, + } + ) + } + + if (compareStableSemver(runtimeStableVersion, meta.minRuntimeVersion) < 0) { + const env = options.env ?? process.env + if (env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR === '1') { + const warn = options.warn ?? console.warn + warn( + [ + 'CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR=1 is set. ChatLab will bypass the data directory runtime version gate.', + `Current runtime ${runtime.kind}@${runtime.version} is below required version ${meta.minRuntimeVersion}.`, + `Data directory: ${userDataDir}. Continuing may risk data corruption.`, + ].join(' ') + ) + return + } + + throw new DataDirCompatibilityError( + 'DATA_DIR_REQUIRES_NEWER_RUNTIME', + `ChatLab data directory requires runtime version ${meta.minRuntimeVersion} or newer; current version is ${runtime.version}.`, + { + userDataDir, + metaPath: getMetaPath(userDataDir), + currentVersion: runtime.version, + minRuntimeVersion: meta.minRuntimeVersion, + } + ) + } +} + +export function raiseDataDirMinRuntimeVersion( + pathProvider: PathProvider, + input: RaiseDataDirCompatibilityInput +): DataDirCompatibilityMeta { + const userDataDir = pathProvider.getUserDataDir() + const metaPath = getMetaPath(userDataDir) + + assertStableVersion(input.minRuntimeVersion, userDataDir, metaPath, 'Invalid minRuntimeVersion.') + const runtimeStableVersion = assertRuntimeComparableVersion(input.runtime.version, userDataDir, metaPath) + if (!isRuntimeKind(input.runtime.kind)) { + throw createInvalidMetaError(userDataDir, metaPath, 'Invalid runtime kind.') + } + if (!isNonEmptyString(input.module) || !isNonEmptyString(input.reason)) { + throw createInvalidMetaError(userDataDir, metaPath, 'Invalid compatibility raise metadata.') + } + if (!Number.isInteger(input.dataCompatibilityVersion) || input.dataCompatibilityVersion < 0) { + throw createInvalidMetaError(userDataDir, metaPath, 'Invalid dataCompatibilityVersion.') + } + + const existing = readDataDirCompatibilityMeta(userDataDir) + const minRuntimeVersion = + existing && compareStableSemver(existing.minRuntimeVersion, input.minRuntimeVersion) > 0 + ? existing.minRuntimeVersion + : input.minRuntimeVersion + const dataCompatibilityVersion = Math.max(existing?.dataCompatibilityVersion ?? 0, input.dataCompatibilityVersion) + const reasons = mergeReasons(existing?.reasons ?? [], input.reason) + const now = input.now ?? (() => Math.floor(Date.now() / 1000)) + + const nextMeta: DataDirCompatibilityMeta = { + formatVersion: 1, + minRuntimeVersion, + dataCompatibilityVersion, + reasons, + updatedBy: { + runtime: input.runtime.kind, + module: input.module, + version: runtimeStableVersion, + }, + updatedAt: now(), + } + + writeMetaAtomic(userDataDir, metaPath, nextMeta) + return nextMeta +} + +function getMetaPath(userDataDir: string): string { + return path.join(userDataDir, '.chatlab-meta.json') +} + +function validateDataDirCompatibilityMeta( + value: unknown, + userDataDir: string, + metaPath: string +): DataDirCompatibilityMeta { + if (!isRecord(value)) { + throw createInvalidMetaError(userDataDir, metaPath, 'Data directory compatibility meta must be an object.') + } + + if (value.formatVersion !== 1) { + throw createInvalidMetaError(userDataDir, metaPath, 'Unsupported data directory compatibility meta format.') + } + + const minRuntimeVersion = value.minRuntimeVersion + const dataCompatibilityVersion = value.dataCompatibilityVersion + const reasons = value.reasons + const updatedBy = value.updatedBy + const updatedAt = value.updatedAt + + if ( + !isStableSemver(minRuntimeVersion) || + typeof dataCompatibilityVersion !== 'number' || + !Number.isInteger(dataCompatibilityVersion) || + dataCompatibilityVersion < 0 || + !Array.isArray(reasons) || + !reasons.every(isNonEmptyString) || + !isRecord(updatedBy) || + !isRuntimeKind(updatedBy.runtime) || + !isNonEmptyString(updatedBy.module) || + !isStableSemver(updatedBy.version) || + typeof updatedAt !== 'number' || + !Number.isInteger(updatedAt) || + updatedAt < 0 + ) { + throw createInvalidMetaError(userDataDir, metaPath, 'Invalid data directory compatibility meta fields.') + } + + return { + formatVersion: 1, + minRuntimeVersion, + dataCompatibilityVersion, + reasons: [...reasons], + updatedBy: { + runtime: updatedBy.runtime, + module: updatedBy.module, + version: updatedBy.version, + }, + updatedAt, + } +} + +function createInvalidMetaError( + userDataDir: string, + metaPath: string, + message: string, + cause?: unknown +): DataDirCompatibilityError { + return new DataDirCompatibilityError('DATA_DIR_COMPATIBILITY_META_INVALID', message, { + userDataDir, + metaPath, + cause, + }) +} + +function assertStableVersion(version: string, userDataDir: string, metaPath: string, message: string): void { + if (!isStableSemver(version)) { + throw createInvalidMetaError(userDataDir, metaPath, message) + } +} + +function isStableSemver(version: unknown): version is string { + return typeof version === 'string' && /^\d+\.\d+\.\d+$/.test(version) +} + +function assertRuntimeComparableVersion(version: string, userDataDir: string, metaPath: string): string { + const normalized = normalizeRuntimeStableVersion(version) + if (!normalized) { + throw createInvalidMetaError(userDataDir, metaPath, 'Invalid runtime version.') + } + return normalized +} + +// 当前运行时允许使用 beta/rc 等 prerelease;数据目录门禁比较和 meta 写入只使用稳定 core 版本。 +function normalizeRuntimeStableVersion(version: string): string | null { + const match = /^v?(\d+\.\d+\.\d+)(-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/.exec(version.trim()) + if (!match) return null + if (match[1] === '0.0.0' && match[2]) return null + return match[1] +} + +function compareStableSemver(left: string, right: string): number { + const leftParts = left.split('.').map(Number) + const rightParts = right.split('.').map(Number) + + for (let i = 0; i < 3; i += 1) { + if (leftParts[i] > rightParts[i]) return 1 + if (leftParts[i] < rightParts[i]) return -1 + } + + return 0 +} + +function mergeReasons(existing: string[], next: string): string[] { + return [...new Set([...existing, next])] +} + +function writeMetaAtomic(userDataDir: string, metaPath: string, meta: DataDirCompatibilityMeta): void { + fs.mkdirSync(userDataDir, { recursive: true }) + const tempPath = path.join(userDataDir, `.chatlab-meta.json.${process.pid}.${Date.now()}.tmp`) + try { + fs.writeFileSync(tempPath, `${JSON.stringify(meta, null, 2)}\n`, 'utf-8') + fs.renameSync(tempPath, metaPath) + } catch (error) { + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath) + } catch { + // Ignore cleanup failures so callers see the original write/rename error. + } + throw error + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function isRuntimeKind(value: unknown): value is RuntimeKind { + return value === 'cli' || value === 'desktop' || value === 'mcp' || value === 'unknown' +} diff --git a/packages/node-runtime/src/data-dir-switch.test.ts b/packages/node-runtime/src/data-dir-switch.test.ts new file mode 100644 index 000000000..b76f97abc --- /dev/null +++ b/packages/node-runtime/src/data-dir-switch.test.ts @@ -0,0 +1,238 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { + applyPendingNodeDataDirMigration, + createPendingDataDirMigration, + createNodeDataDirSwitch, + getPendingNodeDataDirMigration, + isExistingUserDataDir, + runPendingDataDirMigration, +} from './data-dir-switch' +import { applyPendingNodeDataDirMigrationIfNeeded, NodePathProvider } from './node-path-provider' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-data-switch-')) +} + +function writeFile(filePath: string, content = 'data'): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, content, 'utf-8') +} + +test('createPendingDataDirMigration records a restart-time migration without mutating config', () => { + const pending = createPendingDataDirMigration({ + from: '/old/data', + to: '/new/data', + migrate: true, + targetWasEmpty: true, + }) + + assert.equal(pending.from, '/old/data') + assert.equal(pending.to, '/new/data') + assert.equal(pending.migrate, true) + assert.equal(pending.deleteSourceOnSuccess, true) + assert.match(pending.createdAt, /^\d{4}-\d{2}-\d{2}T/) +}) + +test('runPendingDataDirMigration writes config only after copy succeeds', () => { + const root = makeTempDir() + const source = path.join(root, 'source') + const target = path.join(root, 'target') + writeFile(path.join(source, 'databases', 'session.db'), 'sqlite') + + let configuredDir = source + let pendingCleared = false + let pendingDeleteDir: string | null = null + + const result = runPendingDataDirMigration( + createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }), + { + writeUserDataDir(dir) { + configuredDir = dir + }, + clearPendingMigration() { + pendingCleared = true + }, + markPendingDeleteDir(dir) { + pendingDeleteDir = dir + }, + } + ) + + assert.equal(result.success, true) + assert.equal(configuredDir, target) + assert.equal(pendingCleared, true) + assert.equal(pendingDeleteDir, source) + assert.equal(fs.readFileSync(path.join(target, 'databases', 'session.db'), 'utf-8'), 'sqlite') +}) + +test('runPendingDataDirMigration keeps old config and pending task when copy fails', () => { + const root = makeTempDir() + const source = path.join(root, 'source') + const target = path.join(root, 'target') + writeFile(path.join(source, 'databases', 'session.db'), 'sqlite') + + let configuredDir = source + let pendingCleared = false + + const result = runPendingDataDirMigration( + createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }), + { + copyDirMerge() { + return { copied: 0, skipped: 0, errors: ['copy failed'] } + }, + writeUserDataDir(dir) { + configuredDir = dir + }, + clearPendingMigration() { + pendingCleared = true + }, + } + ) + + assert.equal(result.success, false) + assert.equal(configuredDir, source) + assert.equal(pendingCleared, false) + assert.equal(fs.existsSync(path.join(target, 'databases', 'session.db')), false) +}) + +test('runPendingDataDirMigration fails when source directory is missing', () => { + const root = makeTempDir() + const source = path.join(root, 'missing-source') + const target = path.join(root, 'target') + + let configuredDir = source + let pendingCleared = false + + const result = runPendingDataDirMigration( + createPendingDataDirMigration({ from: source, to: target, migrate: true, targetWasEmpty: true }), + { + writeUserDataDir(dir) { + configuredDir = dir + }, + clearPendingMigration() { + pendingCleared = true + }, + } + ) + + assert.equal(result.success, false) + assert.equal(configuredDir, source) + assert.equal(pendingCleared, false) + assert.equal(fs.existsSync(target), false) +}) + +test('isExistingUserDataDir accepts current user data layout without settings directory', () => { + const root = makeTempDir() + const dataDir = path.join(root, 'data') + writeFile(path.join(dataDir, '.chatlab'), 'ChatLab Data Directory') + fs.mkdirSync(path.join(dataDir, 'databases'), { recursive: true }) + + assert.equal(isExistingUserDataDir(dataDir), true) +}) + +test('createNodeDataDirSwitch accepts existing CLI data directories without marker', () => { + const root = makeTempDir() + const currentDir = path.join(root, 'current') + const targetDir = path.join(root, 'previous-cli-data') + writeFile(path.join(currentDir, 'databases', 'current.db'), 'sqlite') + writeFile(path.join(targetDir, 'databases', 'session.db'), 'sqlite') + + const result = createNodeDataDirSwitch({ + systemDir: path.join(root, 'system'), + currentDir, + targetDir, + migrate: true, + }) + + assert.equal(result.success, true) + assert.equal(result.requiresRelaunch, true) +}) + +test('NodePathProvider marks CLI-created data directories', () => { + const root = makeTempDir() + const dataDir = path.join(root, 'data') + const provider = new NodePathProvider(dataDir) + + provider.ensureAllDirs() + + assert.equal(fs.readFileSync(path.join(dataDir, '.chatlab'), 'utf-8'), 'ChatLab Data Directory') +}) + +test('createNodeDataDirSwitch writes pending migration under the system settings directory', () => { + const root = makeTempDir() + const systemDir = path.join(root, 'system') + const currentDir = path.join(root, 'current') + const targetDir = path.join(root, 'target') + writeFile(path.join(currentDir, 'databases', 'session.db'), 'sqlite') + + const result = createNodeDataDirSwitch({ systemDir, currentDir, targetDir, migrate: true }) + const pending = getPendingNodeDataDirMigration(systemDir) + + assert.equal(result.success, true) + assert.equal(result.requiresRelaunch, true) + assert.equal(pending?.from, currentDir) + assert.equal(pending?.to, targetDir) +}) + +test('applyPendingNodeDataDirMigration deletes old data directory after successful migration to empty target', () => { + const root = makeTempDir() + const systemDir = path.join(root, 'system') + const currentDir = path.join(root, 'current') + const targetDir = path.join(root, 'target') + writeFile(path.join(currentDir, '.chatlab'), 'ChatLab Data Directory') + writeFile(path.join(currentDir, 'databases', 'session.db'), 'sqlite') + + const switchResult = createNodeDataDirSwitch({ systemDir, currentDir, targetDir, migrate: true }) + assert.equal(switchResult.success, true) + + const writes: Array<{ section: string; key: string; value: unknown }> = [] + const result = applyPendingNodeDataDirMigration(systemDir, { + writeConfigField(section, key, value) { + writes.push({ section, key, value }) + }, + }) + + assert.equal(result.success, true) + assert.equal(fs.existsSync(currentDir), false) + assert.equal(fs.readFileSync(path.join(targetDir, 'databases', 'session.db'), 'utf-8'), 'sqlite') + assert.equal(getPendingNodeDataDirMigration(systemDir), null) + assert.deepEqual(writes, [ + { section: 'data', key: 'user_data_dir', value: targetDir }, + { section: 'data', key: 'electron_migration_done', value: true }, + ]) +}) + +test('createNodeDataDirSwitch rejects data directory changes while CHATLAB_DATA_DIR is active', () => { + const root = makeTempDir() + const result = createNodeDataDirSwitch({ + systemDir: path.join(root, 'system'), + currentDir: path.join(root, 'current'), + targetDir: path.join(root, 'target'), + migrate: true, + envDataDir: '/env/data', + }) + + assert.equal(result.success, false) +}) + +test('applyPendingNodeDataDirMigrationIfNeeded skips while CHATLAB_DATA_DIR is active', () => { + const originalEnvDir = process.env.CHATLAB_DATA_DIR + process.env.CHATLAB_DATA_DIR = '/env/data' + + try { + const result = applyPendingNodeDataDirMigrationIfNeeded() + assert.equal(result.success, true) + assert.equal(result.skipped, true) + } finally { + if (originalEnvDir === undefined) { + delete process.env.CHATLAB_DATA_DIR + } else { + process.env.CHATLAB_DATA_DIR = originalEnvDir + } + } +}) diff --git a/packages/node-runtime/src/data-dir-switch.ts b/packages/node-runtime/src/data-dir-switch.ts new file mode 100644 index 000000000..3a0c4ff21 --- /dev/null +++ b/packages/node-runtime/src/data-dir-switch.ts @@ -0,0 +1,377 @@ +import * as fs from 'fs' +import * as path from 'path' +import { writeConfigField } from '@openchatlab/config' + +const CHATLAB_MARKER_FILE = '.chatlab' +const USER_DATA_REQUIRED_DIRS = ['databases'] +const PENDING_MIGRATION_FILE = 'data-dir-migration.json' + +const DANGEROUS_PATHS = [ + 'C:\\Windows', + 'C:\\Program Files', + 'C:\\Program Files (x86)', + 'C:\\ProgramData', + '/usr', + '/etc', + '/bin', + '/sbin', + '/lib', + '/var', + '/boot', + '/root', + '/System', + '/Library', +] + +export interface CopyStats { + copied: number + skipped: number + errors: string[] +} + +export interface PendingDataDirMigration { + from: string + to: string + migrate: boolean + deleteSourceOnSuccess: boolean + createdAt: string +} + +export interface RunPendingDataDirMigrationDeps { + copyDirMerge?: typeof copyDirMerge + ensureDir?: (dirPath: string) => void + writeUserDataDir: (dir: string) => void + clearPendingMigration: () => void + markPendingDeleteDir?: (dir: string) => void + log?: (message: string) => void +} + +export interface RunPendingDataDirMigrationResult { + success: boolean + from: string + to: string + copied: number + skipped: number + errors: string[] +} + +export interface DataDirSwitchResult { + success: boolean + error?: string + from?: string + to?: string + requiresRelaunch?: boolean +} + +export interface ApplyPendingNodeDataDirMigrationDeps { + writeConfigField?: typeof writeConfigField + removeDir?: (dir: string) => void +} + +function normalizePathForCompare(input: string): string { + const resolved = path.resolve(input) + const normalized = path.normalize(resolved) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function isSubPath(parent: string, child: string): boolean { + const parentPath = normalizePathForCompare(parent) + const childPath = normalizePathForCompare(child) + + if (parentPath === childPath) return false + return childPath.startsWith(`${parentPath}${path.sep}`) +} + +function isPathSafe(targetPath: string): boolean { + const normalizedTarget = targetPath.toLowerCase().replace(/\//g, '\\') + + for (const dangerous of DANGEROUS_PATHS) { + const normalizedDangerous = dangerous.toLowerCase().replace(/\//g, '\\') + if (normalizedTarget.startsWith(normalizedDangerous)) { + return false + } + } + + return true +} + +function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }) + } +} + +function hasChatLabUserDataStructure(entries: string[]): boolean { + return entries.includes(CHATLAB_MARKER_FILE) && USER_DATA_REQUIRED_DIRS.every((dir) => entries.includes(dir)) +} + +function hasChatLabDatabaseFiles(dirPath: string, entries?: string[]): boolean { + const dirEntries = entries ?? fs.readdirSync(dirPath) + if (!dirEntries.includes('databases')) return false + + const dbDir = path.join(dirPath, 'databases') + try { + return fs.existsSync(dbDir) && fs.readdirSync(dbDir).some((file) => file.endsWith('.db')) + } catch { + return false + } +} + +export function isExistingUserDataDir(dirPath: string): boolean { + if (!fs.existsSync(dirPath)) return false + + try { + const entries = fs.readdirSync(dirPath) + return hasChatLabUserDataStructure(entries) || hasChatLabDatabaseFiles(dirPath, entries) + } catch { + return false + } +} + +export function isUserDataDirSafeToUse(dirPath: string): boolean { + if (!fs.existsSync(dirPath)) return true + + try { + const entries = fs.readdirSync(dirPath) + if (entries.length === 0) return true + return hasChatLabUserDataStructure(entries) || hasChatLabDatabaseFiles(dirPath, entries) + } catch { + return false + } +} + +export function isDirectoryEmptyOrMissing(dirPath: string): boolean { + if (!fs.existsSync(dirPath)) return true + + try { + return fs.readdirSync(dirPath).length === 0 + } catch { + return false + } +} + +export function copyDirMerge( + src: string, + dest: string, + mkdir: (dirPath: string) => void = ensureDir, + stats: CopyStats = { copied: 0, skipped: 0, errors: [] } +): CopyStats { + if (!fs.existsSync(src)) return stats + + try { + mkdir(dest) + const entries = fs.readdirSync(src, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + + try { + if (entry.isDirectory()) { + copyDirMerge(srcPath, destPath, mkdir, stats) + } else if (!fs.existsSync(destPath)) { + fs.copyFileSync(srcPath, destPath) + stats.copied++ + } else { + stats.skipped++ + } + } catch (error) { + stats.errors.push(`复制失败: ${srcPath} -> ${error instanceof Error ? error.message : String(error)}`) + } + } + } catch (error) { + stats.errors.push(`读取目录失败: ${src} -> ${error instanceof Error ? error.message : String(error)}`) + } + + return stats +} + +export function createPendingDataDirMigration(input: { + from: string + to: string + migrate: boolean + targetWasEmpty: boolean +}): PendingDataDirMigration { + return { + from: input.from, + to: input.to, + migrate: input.migrate, + deleteSourceOnSuccess: input.migrate && input.targetWasEmpty, + createdAt: new Date().toISOString(), + } +} + +export function runPendingDataDirMigration( + pending: PendingDataDirMigration, + deps: RunPendingDataDirMigrationDeps +): RunPendingDataDirMigrationResult { + const copy = deps.copyDirMerge ?? copyDirMerge + const mkdir = deps.ensureDir ?? ensureDir + + let stats: CopyStats = { copied: 0, skipped: 0, errors: [] } + if (pending.migrate && path.resolve(pending.from) !== path.resolve(pending.to)) { + if (!fs.existsSync(pending.from)) { + return { + success: false, + from: pending.from, + to: pending.to, + copied: 0, + skipped: 0, + errors: [`源数据目录不存在: ${pending.from}`], + } + } + + stats = copy(pending.from, pending.to, mkdir) + deps.log?.( + `数据目录迁移完成: 从 ${pending.from} 到 ${pending.to},复制 ${stats.copied} 项,跳过 ${stats.skipped} 项,错误 ${stats.errors.length} 项` + ) + if (stats.errors.length > 0) { + return { + success: false, + from: pending.from, + to: pending.to, + copied: stats.copied, + skipped: stats.skipped, + errors: stats.errors, + } + } + } else { + mkdir(pending.to) + } + + deps.writeUserDataDir(pending.to) + deps.clearPendingMigration() + + if (pending.deleteSourceOnSuccess && path.resolve(pending.from) !== path.resolve(pending.to)) { + deps.markPendingDeleteDir?.(pending.from) + } + + return { + success: true, + from: pending.from, + to: pending.to, + copied: stats.copied, + skipped: stats.skipped, + errors: [], + } +} + +function getPendingMigrationPath(systemDir: string): string { + return path.join(systemDir, 'settings', PENDING_MIGRATION_FILE) +} + +export function getPendingNodeDataDirMigration(systemDir: string): PendingDataDirMigration | null { + const filePath = getPendingMigrationPath(systemDir) + if (!fs.existsSync(filePath)) return null + + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as PendingDataDirMigration + if (!parsed.from || !parsed.to || typeof parsed.migrate !== 'boolean') return null + return parsed + } catch { + return null + } +} + +export function clearPendingNodeDataDirMigration(systemDir: string): void { + const filePath = getPendingMigrationPath(systemDir) + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } +} + +// 只删除确认仍是 ChatLab 用户数据目录的旧路径,避免误删用户选择的普通目录。 +export function deleteOldUserDataDirIfSafe( + dirPath: string, + currentDir: string, + removeDir?: (dir: string) => void +): boolean { + if (path.resolve(dirPath) === path.resolve(currentDir)) return false + if (!isPathSafe(dirPath)) return false + if (!fs.existsSync(dirPath)) return false + if (!isExistingUserDataDir(dirPath)) return false + + const remove = removeDir ?? ((dir: string) => fs.rmSync(dir, { recursive: true, force: true })) + remove(dirPath) + return true +} + +function writePendingNodeDataDirMigration(systemDir: string, pending: PendingDataDirMigration): void { + const filePath = getPendingMigrationPath(systemDir) + ensureDir(path.dirname(filePath)) + fs.writeFileSync(filePath, JSON.stringify(pending, null, 2), 'utf-8') +} + +export function createNodeDataDirSwitch(input: { + systemDir: string + currentDir: string + targetDir: string | null + defaultDir?: string + migrate?: boolean + envDataDir?: string +}): DataDirSwitchResult { + if (input.envDataDir) { + return { success: false, error: 'CHATLAB_DATA_DIR is set, data directory cannot be changed from Web UI' } + } + + const newDir = (input.targetDir?.trim() || input.defaultDir || '').trim() + if (!newDir) return { success: false, error: 'Data directory is required' } + if (!path.isAbsolute(newDir)) return { success: false, error: 'Data directory must be an absolute path' } + if (!isPathSafe(newDir)) return { success: false, error: 'System directories cannot be used as data directory' } + + const migrate = input.migrate !== false + if (migrate && path.resolve(input.currentDir) !== path.resolve(newDir) && isSubPath(input.currentDir, newDir)) { + return { success: false, error: 'Target directory cannot be inside current data directory' } + } + + if (!isUserDataDirSafeToUse(newDir)) { + return { success: false, error: 'Target directory is not empty and is not a ChatLab data directory' } + } + + if (path.resolve(input.currentDir) === path.resolve(newDir)) { + clearPendingNodeDataDirMigration(input.systemDir) + return { success: true, from: input.currentDir, to: newDir, requiresRelaunch: false } + } + + const pending = createPendingDataDirMigration({ + from: input.currentDir, + to: newDir, + migrate, + targetWasEmpty: isDirectoryEmptyOrMissing(newDir), + }) + writePendingNodeDataDirMigration(input.systemDir, pending) + + return { success: true, from: input.currentDir, to: newDir, requiresRelaunch: true } +} + +export function applyPendingNodeDataDirMigration( + systemDir: string, + deps: ApplyPendingNodeDataDirMigrationDeps = {} +): { + success: boolean + skipped?: boolean + error?: string +} { + const pending = getPendingNodeDataDirMigration(systemDir) + if (!pending) return { success: true, skipped: true } + const writeConfig = deps.writeConfigField ?? writeConfigField + + const result = runPendingDataDirMigration(pending, { + writeUserDataDir(dir) { + writeConfig('data', 'user_data_dir', dir) + writeConfig('data', 'electron_migration_done', true) + }, + clearPendingMigration() { + clearPendingNodeDataDirMigration(systemDir) + }, + markPendingDeleteDir(dir) { + deleteOldUserDataDirIfSafe(dir, pending.to, deps.removeDir) + }, + }) + + if (!result.success) { + return { success: false, error: result.errors.join('; ') || 'Data directory migration failed' } + } + + return { success: true } +} diff --git a/packages/node-runtime/src/database-manager.test.ts b/packages/node-runtime/src/database-manager.test.ts new file mode 100644 index 000000000..fe6fb3e13 --- /dev/null +++ b/packages/node-runtime/src/database-manager.test.ts @@ -0,0 +1,944 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import Database from 'better-sqlite3' +import type { PathProvider } from '@openchatlab/core' +import { CHAT_DB_SCHEMA, CURRENT_SCHEMA_VERSION, getSessionInfo } from '@openchatlab/core' +import { DataDirCompatibilityError, readDataDirCompatibilityMeta } from './data-dir-compat' +import { DatabaseManager } from './database-manager' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-db-manager-')) +} + +function createPathProvider(root: string): PathProvider { + return { + getSystemDir: () => root, + getUserDataDir: () => path.join(root, 'data'), + getDatabaseDir: () => path.join(root, 'data', 'databases'), + getVectorDir: () => path.join(root, 'data', 'vector'), + getAiDataDir: () => path.join(root, 'ai'), + getSettingsDir: () => path.join(root, 'settings'), + getCacheDir: () => path.join(root, 'cache'), + getTempDir: () => path.join(root, 'temp'), + getLogsDir: () => path.join(root, 'logs'), + getDownloadsDir: () => path.join(root, 'downloads'), + } +} + +function getIndexNames(db: Database.Database): string[] { + const rows = db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' ORDER BY name").all() as Array<{ + name: string + }> + return rows.map((row) => row.name) +} + +function assertAnalysisToolIndexes(db: Database.Database): void { + const indexes = getIndexNames(db) + assert.ok(indexes.includes('idx_message_reply_to')) + assert.ok(indexes.includes('idx_message_sender_ts')) + assert.ok(indexes.includes('idx_message_type_ts')) +} + +test('constructor rejects missing runtime unless the test-only bypass is explicit', () => { + const root = makeTempDir() + + assert.throws(() => new DatabaseManager(createPathProvider(root), { nativeBinding }), /runtime identity is required/i) + assert.doesNotThrow( + () => new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true }) + ) +}) + +test('open migrates legacy member name columns before readonly queries', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'legacy.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 4 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Legacy Chat', 'qq', 'group', 1000, 4); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + nickname TEXT + ); + INSERT INTO member (platform_id, name, nickname) VALUES ('u1', 'Alice Account', 'Alice Group'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello'); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true }) + const db = manager.open('legacy') + assert.ok(db) + + const info = getSessionInfo(db) + assert.equal(info?.name, 'Legacy Chat') + assert.equal(info?.messageCount, 1) + + const columns = db.pragma('table_info(member)') as Array<{ name: string }> + assert.equal( + columns.some((col) => col.name === 'account_name'), + true + ) + const member = db.prepare('SELECT account_name, group_nickname FROM member WHERE platform_id = ?').get('u1') as { + account_name: string | null + group_nickname: string | null + } + assert.equal(member.account_name, 'Alice Account') + assert.equal(member.group_nickname, 'Alice Group') + + manager.closeAll() +}) + +test('open backfills FTS index when migrating legacy sessions', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'fts-legacy.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 3 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('FTS Legacy Chat', 'qq', 'group', 1000, 3); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Alice'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello searchable history'); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1001, 1, 'image message ignored'); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true }) + const db = manager.open('fts-legacy') + assert.ok(db) + + const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number } + assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION) + + const ftsCount = db.prepare('SELECT COUNT(*) as total FROM message_fts').get() as { total: number } + assert.equal(ftsCount.total, 1) + + const searchCount = db + .prepare("SELECT COUNT(*) as total FROM message_fts WHERE content MATCH 'searchable'") + .get() as { total: number } + assert.equal(searchCount.total, 1) + + manager.closeAll() +}) + +test('open migrates v7 databases to include analysis tool indexes', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'v7-analysis-indexes.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 7 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('V7 Analysis Indexes', 'qq', 'group', 1000, 7); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Alice'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT, + reply_to_message_id TEXT DEFAULT NULL, + platform_message_id TEXT DEFAULT NULL + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello indexed tools'); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true }) + const db = manager.open('v7-analysis-indexes') + assert.ok(db) + + const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number } + assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION) + assertAnalysisToolIndexes(db as unknown as Database.Database) + + manager.closeAll() +}) + +test('CHAT_DB_SCHEMA creates analysis tool indexes for new databases', () => { + const db = new Database(':memory:', { nativeBinding }) + try { + db.exec(CHAT_DB_SCHEMA) + assertAnalysisToolIndexes(db) + } finally { + db.close() + } +}) + +test('open migrates v2 chat_session schema to current segment schema', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'v2-segment-schema.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 2 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('V2 Segment Schema', 'qq', 'group', 1000, 2); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Alice'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT, + platform_message_id TEXT DEFAULT NULL + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello v2'); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true }) + const db = manager.open('v2-segment-schema') + assert.ok(db) + + const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number } + assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION) + + const segmentTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'segment'").get() + const legacyTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_session'").get() + assert.ok(segmentTable) + assert.equal(legacyTable, undefined) + + const contextColumns = db.pragma('table_info(message_context)') as Array<{ name: string }> + assert.equal( + contextColumns.some((col) => col.name === 'segment_id'), + true + ) + assert.equal( + contextColumns.some((col) => col.name === 'session_id'), + false + ) + + manager.closeAll() +}) + +test('open migrates legacy chat_session rows into segment after v5 creates segment table', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'legacy-segments.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 4 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Legacy Segment Chat', 'qq', 'group', 1000, 4); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Alice'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello segment'); + + CREATE TABLE chat_session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + INSERT INTO chat_session (id, start_ts, end_ts, message_count, is_manual, summary) + VALUES (7, 1000, 1010, 1, 0, 'legacy summary'); + + CREATE TABLE message_context ( + message_id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL, + topic_id INTEGER + ); + INSERT INTO message_context (message_id, session_id, topic_id) VALUES (1, 7, 3); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + const db = manager.open('legacy-segments') + assert.ok(db) + + const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number } + assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION) + + const legacyTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_session'").get() + assert.equal(legacyTable, undefined) + + const segment = db.prepare('SELECT id, start_ts, end_ts, message_count, summary FROM segment').get() as + | { id: number; start_ts: number; end_ts: number; message_count: number; summary: string | null } + | undefined + assert.deepEqual(segment, { + id: 7, + start_ts: 1000, + end_ts: 1010, + message_count: 1, + summary: 'legacy summary', + }) + + const contextColumns = db.pragma('table_info(message_context)') as Array<{ name: string }> + assert.equal( + contextColumns.some((col) => col.name === 'segment_id'), + true + ) + assert.equal( + contextColumns.some((col) => col.name === 'session_id'), + false + ) + + const context = db.prepare('SELECT message_id, segment_id, topic_id FROM message_context').get() as { + message_id: number + segment_id: number + topic_id: number + } + assert.deepEqual(context, { message_id: 1, segment_id: 7, topic_id: 3 }) + + const meta = readDataDirCompatibilityMeta(path.join(root, 'data')) + assert.equal(meta?.minRuntimeVersion, '0.25.1') + assert.equal(meta?.dataCompatibilityVersion, 1) + assert.deepEqual(meta?.reasons, ['segment-schema']) + assert.deepEqual(meta?.updatedBy, { + runtime: 'cli', + module: 'chat-db-migration', + version: '0.25.1', + }) + + manager.closeAll() +}) + +test('open repairs a v6 segment index whose message contexts are entirely missing', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'missing-segment-contexts.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 6 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Missing Segment Contexts', 'qq', 'group', 1000, 6); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + INSERT INTO member (id, platform_id, account_name) VALUES (1, 'u1', 'Alice'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + INSERT INTO message (id, sender_id, ts, type, content) VALUES + (1, 1, 1000, 0, 'first segment message'), + (2, 1, 1100, 0, 'second segment message'), + (3, 1, 5000, 0, 'later segment message'); + + CREATE TABLE segment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + INSERT INTO segment (id, start_ts, end_ts, message_count, is_manual, summary) VALUES + (7, 1000, 1100, 2, 0, 'existing summary'), + (8, 5000, 5000, 1, 0, NULL); + + CREATE TABLE message_context ( + message_id INTEGER PRIMARY KEY, + segment_id INTEGER NOT NULL, + topic_id INTEGER + ); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.26.2', kind: 'cli' }, + }) + const db = manager.open('missing-segment-contexts') + assert.ok(db) + + const version = db.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number } + assert.equal(version.schema_version, CURRENT_SCHEMA_VERSION) + + const contexts = db.prepare('SELECT message_id, segment_id FROM message_context ORDER BY message_id').all() as Array<{ + message_id: number + segment_id: number + }> + assert.deepEqual(contexts, [ + { message_id: 1, segment_id: 7 }, + { message_id: 2, segment_id: 7 }, + { message_id: 3, segment_id: 8 }, + ]) + + const summary = db.prepare('SELECT summary FROM segment WHERE id = 7').get() as { summary: string | null } + assert.equal(summary.summary, 'existing summary') + + manager.closeAll() +}) + +test('open rejects ambiguous v6 segment data instead of guessing missing message contexts', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'ambiguous-segment-contexts.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 6 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Ambiguous Segment Contexts', 'qq', 'group', 1000, 6); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + INSERT INTO member (id, platform_id, account_name) VALUES (1, 'u1', 'Alice'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + INSERT INTO message (id, sender_id, ts, type, content) VALUES + (1, 1, 1000, 0, 'first message'), + (2, 1, 1100, 0, 'second message'); + + CREATE TABLE segment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + INSERT INTO segment (id, start_ts, end_ts, message_count, is_manual, summary) + VALUES (7, 1000, 1100, 3, 0, 'must be preserved'); + + CREATE TABLE message_context ( + message_id INTEGER PRIMARY KEY, + segment_id INTEGER NOT NULL, + topic_id INTEGER + ); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.26.2', kind: 'cli' }, + }) + + assert.throws(() => manager.open('ambiguous-segment-contexts'), /Cannot safely repair missing message_context rows/) + + const checkDb = new Database(dbPath, { readonly: true, nativeBinding }) + try { + const version = checkDb.prepare('SELECT schema_version FROM meta LIMIT 1').get() as { schema_version: number } + assert.equal(version.schema_version, 6) + assert.equal((checkDb.prepare('SELECT COUNT(*) AS count FROM message_context').get() as { count: number }).count, 0) + assert.equal( + (checkDb.prepare('SELECT summary FROM segment WHERE id = 7').get() as { summary: string | null }).summary, + 'must be preserved' + ) + } finally { + checkDb.close() + } +}) + +test('open blocks database access when data directory requires a newer runtime', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'blocked.db') + + fs.writeFileSync( + path.join(root, 'data', '.chatlab-meta.json'), + JSON.stringify( + { + formatVersion: 1, + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '0.25.1' }, + updatedAt: 1780830000, + }, + null, + 2 + ), + 'utf-8' + ) + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT ${CURRENT_SCHEMA_VERSION} + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Blocked Chat', 'qq', 'group', 1000, ${CURRENT_SCHEMA_VERSION}); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.25.0', kind: 'cli' }, + }) + + assert.throws( + () => manager.open('blocked'), + (error) => + error instanceof DataDirCompatibilityError && + error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' && + error.minRuntimeVersion === '0.25.1' + ) +}) + +test('openRawSessionDatabase blocks raw access when data directory requires a newer runtime', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + fs.writeFileSync( + path.join(root, 'data', '.chatlab-meta.json'), + JSON.stringify( + { + formatVersion: 1, + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reasons: ['segment-schema'], + updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '0.25.1' }, + updatedAt: 1780830000, + }, + null, + 2 + ), + 'utf-8' + ) + + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.25.0', kind: 'cli' }, + }) + + assert.throws( + () => manager.openRawSessionDatabase('blocked-raw', { create: true }), + (error) => + error instanceof DataDirCompatibilityError && + error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' && + error.minRuntimeVersion === '0.25.1' + ) + assert.equal(fs.existsSync(path.join(dbDir, 'blocked-raw.db')), false) +}) + +test('openRawSessionDatabase can initialize current chat tables for controlled import adapters', () => { + const root = makeTempDir() + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + + const db = manager.openRawSessionDatabase('raw-created', { create: true, initializeChatTables: true }) + + const metaTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'").get() + const messageTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'message'").get() + assert.ok(metaTable) + assert.ok(messageTable) + db.close() +}) + +test('raiseCurrentChatDbCompatibilityGate writes metadata for fresh current-schema databases', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'fresh-current.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT ${CURRENT_SCHEMA_VERSION} + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Fresh Current Chat', 'qq', 'group', 1000, ${CURRENT_SCHEMA_VERSION}); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + + manager.raiseCurrentChatDbCompatibilityGate() + + const meta = readDataDirCompatibilityMeta(path.join(root, 'data')) + assert.equal(meta?.minRuntimeVersion, '0.25.1') + assert.equal(meta?.dataCompatibilityVersion, 1) + assert.deepEqual(meta?.reasons, ['segment-schema']) + assert.deepEqual(meta?.updatedBy, { + runtime: 'cli', + module: 'chat-db-migration', + version: '0.25.1', + }) +}) + +test('open repairs the data directory gate for existing current-schema databases', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'already-current.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT ${CURRENT_SCHEMA_VERSION} + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Already Current Chat', 'qq', 'group', 1000, ${CURRENT_SCHEMA_VERSION}); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + + const db = manager.open('already-current') + assert.ok(db) + manager.closeAll() + + const meta = readDataDirCompatibilityMeta(path.join(root, 'data')) + assert.equal(meta?.minRuntimeVersion, '0.25.1') + assert.equal(meta?.dataCompatibilityVersion, 1) + assert.deepEqual(meta?.reasons, ['segment-schema']) +}) + +test('open keeps a higher existing data directory runtime requirement after migration', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'higher-meta.db') + + fs.writeFileSync( + path.join(root, 'data', '.chatlab-meta.json'), + JSON.stringify( + { + formatVersion: 1, + minRuntimeVersion: '0.26.0', + dataCompatibilityVersion: 2, + reasons: ['future-schema'], + updatedBy: { runtime: 'desktop', module: 'future-migration', version: '0.26.0' }, + updatedAt: 1780830000, + }, + null, + 2 + ), + 'utf-8' + ) + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 4 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Higher Meta Chat', 'qq', 'group', 1000, 4); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.26.0', kind: 'desktop' }, + }) + const db = manager.open('higher-meta') + assert.ok(db) + manager.closeAll() + + const meta = readDataDirCompatibilityMeta(path.join(root, 'data')) + assert.equal(meta?.minRuntimeVersion, '0.26.0') + assert.equal(meta?.dataCompatibilityVersion, 2) + assert.deepEqual(meta?.reasons, ['future-schema', 'segment-schema']) +}) + +test( + 'open fails after a compatibility-raising migration when data directory meta cannot be written', + { skip: process.platform === 'win32' }, + () => { + const root = makeTempDir() + const userDataDir = path.join(root, 'data') + const dbDir = path.join(userDataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'unwritable-meta.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT 4 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Unwritable Meta Chat', 'qq', 'group', 1000, 4); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + `) + rawDb.close() + + fs.chmodSync(userDataDir, 0o555) + + try { + const manager = new DatabaseManager(createPathProvider(root), { + nativeBinding, + runtime: { version: '0.25.1', kind: 'cli' }, + }) + + assert.throws(() => manager.open('unwritable-meta'), /EACCES|EPERM|permission/i) + } finally { + fs.chmodSync(userDataDir, 0o755) + } + } +) + +test('open preserves readonly access for current-schema databases', { skip: process.platform === 'win32' }, () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'current-readonly.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + imported_at INTEGER NOT NULL, + schema_version INTEGER DEFAULT ${CURRENT_SCHEMA_VERSION} + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Current Readonly Chat', 'qq', 'group', 1000, ${CURRENT_SCHEMA_VERSION}); + + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, + account_name TEXT + ); + INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Alice'); + + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + ts INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'readonly current schema'); + `) + rawDb.close() + + fs.chmodSync(dbPath, 0o444) + fs.chmodSync(dbDir, 0o555) + + try { + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true }) + const db = manager.open('current-readonly') + assert.ok(db) + assert.equal(db.readonly, true) + + const info = getSessionInfo(db) + assert.equal(info?.name, 'Current Readonly Chat') + assert.equal(info?.messageCount, 1) + + manager.closeAll() + } finally { + fs.chmodSync(dbDir, 0o755) + fs.chmodSync(dbPath, 0o644) + } +}) + +test('listSessionIds ignores non-ChatLab sqlite databases without migrating them', () => { + const root = makeTempDir() + const dbDir = path.join(root, 'data', 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'notes.db') + + const rawDb = new Database(dbPath, { nativeBinding }) + rawDb.exec(` + CREATE TABLE note ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT + ); + INSERT INTO note (content) VALUES ('not a chatlab session'); + `) + rawDb.close() + + const manager = new DatabaseManager(createPathProvider(root), { nativeBinding, allowMissingRuntimeForTests: true }) + + assert.deepEqual(manager.listSessionIds(), []) + manager.closeAll() +}) diff --git a/packages/node-runtime/src/database-manager.ts b/packages/node-runtime/src/database-manager.ts new file mode 100644 index 000000000..e59a468e0 --- /dev/null +++ b/packages/node-runtime/src/database-manager.ts @@ -0,0 +1,294 @@ +/** + * 数据库连接管理器 + * + * 管理 ChatLab 会话数据库的打开、缓存与关闭。 + * 等效于 electron/main/worker/core/dbCore.ts 中的 dbCache 机制, + * 但基于 DatabaseAdapter 接口而非直接使用 better-sqlite3。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { DatabaseAdapter, PathProvider } from '@openchatlab/core' +import { + CHAT_DB_TABLES, + isChatSessionDb, + runMigrations as coreRunMigrations, + needsMigration as coreNeedsMigration, + CURRENT_SCHEMA_VERSION, + getSchemaVersion, +} from '@openchatlab/core' +import { openBetterSqliteDatabase } from './better-sqlite3-adapter' +import { deleteSessionCache } from './cache/session-cache' +import { assertDataDirCompatible, type RuntimeIdentity } from './data-dir-compat' +import { + CHAT_DB_COMPATIBILITY_RAISES, + getChatDbMigrations, + raiseChatDbCompatibilityGate, + type MigrationDeps, +} from './migrations' +import { tokenizeForFts } from './nlp/fts-tokenizer' +import { getContactsFactsCacheDir } from './services/contacts/paths' +import { getPeopleRelationshipsFactsCacheDir } from './services/people/relationships/paths' + +function createMigrationDeps(overrides?: MigrationDeps): MigrationDeps { + return { + tokenizeForFts, + ...overrides, + } +} + +interface DatabaseManagerOptions { + nativeBinding?: string + migrationDeps?: MigrationDeps + runtime?: RuntimeIdentity + allowMissingRuntimeForTests?: boolean +} + +interface OpenRawSessionDatabaseOptions { + readonly?: boolean + create?: boolean + initializeChatTables?: boolean +} + +export class DatabaseManager { + private cache = new Map() + private nativeBinding?: string + private migrationDeps?: MigrationDeps + private runtime: RuntimeIdentity | null + + constructor( + private pathProvider: PathProvider, + options: DatabaseManagerOptions = {} + ) { + if (!options.runtime && !options.allowMissingRuntimeForTests) { + throw new Error('DatabaseManager runtime identity is required for data directory compatibility checks.') + } + + this.nativeBinding = options?.nativeBinding + this.migrationDeps = createMigrationDeps(options?.migrationDeps) + this.runtime = options?.runtime ?? null + } + + /** + * Open a session DB (read-only by default, cached). + */ + open(sessionId: string, options?: { readonly?: boolean }): DatabaseAdapter | null { + this.assertCompatible() + + if (this.cache.has(sessionId)) { + return this.cache.get(sessionId)! + } + + const dbPath = this.getDbPath(sessionId) + if (!fs.existsSync(dbPath)) return null + + this.migrateIfNeeded(dbPath) + this.assertCompatible() + + const adapter = openBetterSqliteDatabase(dbPath, { + readonly: options?.readonly ?? true, + nativeBinding: this.nativeBinding, + }) + this.cache.set(sessionId, adapter) + return adapter + } + + private migrateIfNeeded(dbPath: string): void { + const readonlyAdapter = openBetterSqliteDatabase(dbPath, { + readonly: true, + nativeBinding: this.nativeBinding, + }) + + try { + if (!isChatSessionDb(readonlyAdapter)) return + this.raiseCompatibilityGateIfNeeded(getSchemaVersion(readonlyAdapter)) + if (!coreNeedsMigration(readonlyAdapter, CURRENT_SCHEMA_VERSION)) return + } finally { + readonlyAdapter.close() + } + + const adapter = openBetterSqliteDatabase(dbPath, { + readonly: false, + nativeBinding: this.nativeBinding, + }) + + try { + const migrations = getChatDbMigrations(this.migrationDeps) + this.runMigrations(adapter, migrations) + } finally { + adapter.close() + } + } + + /** + * Open a session DB in read-write mode and auto-run pending migrations. + * Falls back to `open(id, { readonly: false })` if migration is unnecessary. + */ + openWritable(sessionId: string): DatabaseAdapter | null { + this.assertCompatible() + + const existing = this.cache.get(sessionId) + if (existing && !existing.readonly) return existing + + if (existing) { + existing.close() + this.cache.delete(sessionId) + } + + const dbPath = this.getDbPath(sessionId) + if (!fs.existsSync(dbPath)) return null + + const adapter = openBetterSqliteDatabase(dbPath, { + readonly: false, + nativeBinding: this.nativeBinding, + }) + + if (coreNeedsMigration(adapter, CURRENT_SCHEMA_VERSION)) { + const migrations = getChatDbMigrations(this.migrationDeps) + this.runMigrations(adapter, migrations) + this.assertCompatible() + } else { + this.raiseCompatibilityGateIfNeeded(getSchemaVersion(adapter)) + } + + this.cache.set(sessionId, adapter) + return adapter + } + + /** + * 关闭指定会话的数据库连接 + */ + close(sessionId: string): void { + const adapter = this.cache.get(sessionId) + if (adapter) { + adapter.close() + this.cache.delete(sessionId) + } + } + + /** + * 关闭所有数据库连接 + */ + closeAll(): void { + for (const [id, adapter] of this.cache) { + adapter.close() + this.cache.delete(id) + } + } + + /** + * 列举数据库目录下的所有聊天会话 ID + */ + listSessionIds(): string[] { + this.assertCompatible() + + const dbDir = this.pathProvider.getDatabaseDir() + if (!fs.existsSync(dbDir)) return [] + + return fs + .readdirSync(dbDir) + .filter((f) => f.endsWith('.db')) + .map((f) => f.replace('.db', '')) + .filter((id) => { + const db = this.open(id) + if (!db) return false + return isChatSessionDb(db) + }) + } + + /** + * 获取数据库文件路径 + */ + getDbPath(sessionId: string): string { + return path.join(this.pathProvider.getDatabaseDir(), `${sessionId}.db`) + } + + openRawSessionDatabase(sessionId: string, options: OpenRawSessionDatabaseOptions = {}): DatabaseAdapter { + this.assertCompatible() + + const dbPath = this.getDbPath(sessionId) + if (!options.create && !fs.existsSync(dbPath)) { + throw new Error(`Session database not found: ${sessionId}`) + } + + if (!options.readonly) { + this.close(sessionId) + fs.mkdirSync(path.dirname(dbPath), { recursive: true }) + } + + const adapter = openBetterSqliteDatabase(dbPath, { + readonly: options.readonly ?? false, + nativeBinding: this.nativeBinding, + }) + + if (options.initializeChatTables) { + adapter.exec(CHAT_DB_TABLES) + } + + return adapter + } + + /** + * 删除一个会话在本地留下的完整文件集合。 + * SQLite WAL 模式会产生 sidecar 文件,前端查询还会写 JSON 缓存;只删主库会让列表和后续同步读到脏状态。 + */ + deleteSessionDatabaseFiles(sessionId: string): boolean { + this.close(sessionId) + + const dbPath = this.getDbPath(sessionId) + const existed = ['', '-wal', '-shm'].some((suffix) => fs.existsSync(dbPath + suffix)) + for (const suffix of ['', '-wal', '-shm']) { + try { + const filePath = dbPath + suffix + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + } catch { + /* ignore cleanup failures */ + } + } + const cacheDir = this.pathProvider.getCacheDir() + deleteSessionCache(sessionId, cacheDir) + deleteSessionCache(sessionId, path.join(cacheDir, 'query')) + deleteSessionCache(sessionId, getContactsFactsCacheDir(this.pathProvider.getUserDataDir())) + deleteSessionCache(sessionId, getPeopleRelationshipsFactsCacheDir(this.pathProvider.getUserDataDir())) + return existed + } + + raiseCurrentChatDbCompatibilityGate(): void { + if (!this.runtime) return + raiseChatDbCompatibilityGate(this.pathProvider, this.runtime) + } + + private assertCompatible(): void { + if (!this.runtime) return + assertDataDirCompatible(this.pathProvider, this.runtime) + } + + private runMigrations(adapter: DatabaseAdapter, migrations: ReturnType): void { + const beforeVersion = getSchemaVersion(adapter) + const migrated = coreRunMigrations(adapter, migrations) + if (!migrated || !this.runtime) return + + const afterVersion = getSchemaVersion(adapter) + if (shouldRaiseCompatibilityGate(beforeVersion, afterVersion)) { + this.raiseCurrentChatDbCompatibilityGate() + } + } + + private raiseCompatibilityGateIfNeeded(schemaVersion: number): void { + if (!this.runtime) return + if (shouldRepairCompatibilityGate(schemaVersion)) { + this.raiseCurrentChatDbCompatibilityGate() + } + } +} + +function shouldRaiseCompatibilityGate(beforeVersion: number, afterVersion: number): boolean { + return CHAT_DB_COMPATIBILITY_RAISES.some( + (compatibilityRaise) => + beforeVersion < compatibilityRaise.migrationVersion && afterVersion >= compatibilityRaise.migrationVersion + ) +} + +function shouldRepairCompatibilityGate(schemaVersion: number): boolean { + return CHAT_DB_COMPATIBILITY_RAISES.some((compatibilityRaise) => schemaVersion >= compatibilityRaise.migrationVersion) +} diff --git a/packages/node-runtime/src/export/format-exporter.ts b/packages/node-runtime/src/export/format-exporter.ts new file mode 100644 index 000000000..ae7474393 --- /dev/null +++ b/packages/node-runtime/src/export/format-exporter.ts @@ -0,0 +1,156 @@ +/** + * Multi-format export: txt / json / markdown. + * Simplified for time-range-only filtering (no keyword/context block logic). + */ + +import type { DatabaseAdapter } from '@openchatlab/core' + +export type ExportFormat = 'txt' | 'json' | 'markdown' + +export interface FormatExportParams { + sessionId: string + sessionName: string + format: ExportFormat + timeFilter?: { startTs: number; endTs: number } +} + +export interface FormatExportResult { + success: boolean + error?: string + totalMessages: number + content: string + filename: string + mimeType: string +} + +interface MessageRow { + ts: number + senderName: string + content: string | null +} + +function queryMessages(db: DatabaseAdapter, timeFilter?: { startTs: number; endTs: number }): MessageRow[] { + const hasFilter = !!timeFilter + const sql = ` + SELECT msg.ts, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + msg.content + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${hasFilter ? 'WHERE msg.ts >= ? AND msg.ts <= ?' : ''} + ORDER BY msg.ts ASC, msg.id ASC + ` + const params: unknown[] = [] + if (hasFilter) { + params.push(timeFilter!.startTs, timeFilter!.endTs) + } + return db.prepare(sql).all(...params) as unknown as MessageRow[] +} + +function formatTime(ts: number): string { + return new Date(ts * 1000).toLocaleString() +} + +function formatTimeShort(ts: number): string { + return new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +function exportAsTxt(messages: MessageRow[], sessionName: string): string { + const lines: string[] = [`${sessionName}\n`] + let lastDate = '' + for (const msg of messages) { + const date = new Date(msg.ts * 1000).toLocaleDateString() + if (date !== lastDate) { + lines.push(`\n--- ${date} ---\n`) + lastDate = date + } + lines.push(`${formatTimeShort(msg.ts)} ${msg.senderName}: ${msg.content || '[non-text]'}`) + } + return lines.join('\n') +} + +function exportAsJson(messages: MessageRow[], sessionName: string): string { + const data = { + sessionName, + exportTime: new Date().toISOString(), + totalMessages: messages.length, + messages: messages.map((msg) => ({ + timestamp: msg.ts, + time: formatTime(msg.ts), + sender: msg.senderName, + content: msg.content || '', + })), + } + return JSON.stringify(data, null, 2) +} + +function exportAsMarkdown(messages: MessageRow[], sessionName: string): string { + const lines: string[] = [`# ${sessionName}\n`, `> Export time: ${new Date().toLocaleString()}\n`] + let lastDate = '' + for (const msg of messages) { + const date = new Date(msg.ts * 1000).toLocaleDateString() + if (date !== lastDate) { + lines.push(`\n## ${date}\n`) + lastDate = date + } + lines.push(`**${formatTimeShort(msg.ts)} ${msg.senderName}**: ${msg.content || '*[non-text]*'}`) + } + return lines.join('\n') +} + +const FORMAT_CONFIG: Record = { + txt: { ext: 'txt', mime: 'text/plain; charset=utf-8' }, + json: { ext: 'json', mime: 'application/json; charset=utf-8' }, + markdown: { ext: 'md', mime: 'text/markdown; charset=utf-8' }, +} + +export function exportWithFormat( + params: FormatExportParams, + openDatabase: (sessionId: string) => DatabaseAdapter | null +): FormatExportResult { + const db = openDatabase(params.sessionId) + if (!db) { + return { success: false, error: 'Cannot open database', totalMessages: 0, content: '', filename: '', mimeType: '' } + } + + try { + const messages = queryMessages(db, params.timeFilter) + if (messages.length === 0) { + return { + success: false, + error: 'No messages found in the specified range', + totalMessages: 0, + content: '', + filename: '', + mimeType: '', + } + } + + const { ext, mime } = FORMAT_CONFIG[params.format] + let content: string + switch (params.format) { + case 'txt': + content = exportAsTxt(messages, params.sessionName) + break + case 'json': + content = exportAsJson(messages, params.sessionName) + break + case 'markdown': + content = exportAsMarkdown(messages, params.sessionName) + break + } + + const timestamp = Date.now() + const filename = `${params.sessionName}_export_${timestamp}.${ext}` + return { success: true, totalMessages: messages.length, content, filename, mimeType: mime } + } catch (error) { + return { + success: false, + error: String(error), + totalMessages: 0, + content: '', + filename: '', + mimeType: '', + } + } +} diff --git a/packages/node-runtime/src/export/index.ts b/packages/node-runtime/src/export/index.ts new file mode 100644 index 000000000..cd94849e3 --- /dev/null +++ b/packages/node-runtime/src/export/index.ts @@ -0,0 +1,12 @@ +export { exportFilterResultToMarkdown } from './markdown-exporter' +export type { + ExportFilterParams, + ExportProgress, + ExportProgressCallback, + ExportWriter, + ExportDeps, + ExportResult, +} from './markdown-exporter' + +export { exportWithFormat } from './format-exporter' +export type { ExportFormat, FormatExportParams, FormatExportResult } from './format-exporter' diff --git a/packages/node-runtime/src/export/markdown-exporter.ts b/packages/node-runtime/src/export/markdown-exporter.ts new file mode 100644 index 000000000..f304ab027 --- /dev/null +++ b/packages/node-runtime/src/export/markdown-exporter.ts @@ -0,0 +1,355 @@ +/** + * Markdown export engine (platform-agnostic). + * + * Extracted from electron/main/worker/query/session/export.ts. + * Exports filtered chat messages to Markdown format via a writer abstraction. + * Supports condition-based filtering (keyword/sender/time) and session-based export. + */ + +import type { DatabaseAdapter } from '@openchatlab/core' + +// ==================== Public interfaces ==================== + +export interface ExportFilterParams { + sessionId: string + sessionName: string + filterMode: 'condition' | 'session' + keywords?: string[] + timeFilter?: { startTs: number; endTs: number } + senderIds?: number[] + contextSize?: number + segmentIds?: number[] +} + +export interface ExportProgress { + stage: 'preparing' | 'exporting' | 'done' | 'error' + currentBlock: number + totalBlocks: number + percentage: number + message: string +} + +export type ExportProgressCallback = (progress: ExportProgress) => void + +export interface ExportWriter { + write(chunk: string): void + end(): void +} + +export interface ExportDeps { + openDatabase(sessionId: string): DatabaseAdapter | null + onProgress?: ExportProgressCallback +} + +export interface ExportResult { + success: boolean + error?: string + totalBlocks: number + totalMessages: number +} + +// ==================== Core export logic ==================== + +/** + * Export filter results to Markdown via the provided writer. + * Platform-agnostic: no filesystem or IPC dependency. + */ +export function exportFilterResultToMarkdown( + params: ExportFilterParams, + deps: ExportDeps, + writer: ExportWriter +): ExportResult { + const db = deps.openDatabase(params.sessionId) + if (!db) { + return { success: false, error: 'Cannot open database', totalBlocks: 0, totalMessages: 0 } + } + + const progress = deps.onProgress + + try { + writer.write(`# ${params.sessionName} - Chat Filter Results\n\n`) + writer.write(`> Export time: ${new Date().toLocaleString()}\n\n`) + + writer.write(`## Filter Conditions\n\n`) + if (params.filterMode === 'condition') { + if (params.keywords && params.keywords.length > 0) { + writer.write(`- Keywords: ${params.keywords.join(', ')}\n`) + } + if (params.timeFilter) { + const start = new Date(params.timeFilter.startTs * 1000).toLocaleString() + const end = new Date(params.timeFilter.endTs * 1000).toLocaleString() + writer.write(`- Time range: ${start} ~ ${end}\n`) + } + writer.write(`- Context: ±${params.contextSize || 10} messages\n`) + } else { + writer.write(`- Mode: session filter\n`) + writer.write(`- Selected sessions: ${params.segmentIds?.length || 0}\n`) + } + writer.write('\n') + + let totalMessages = 0 + let blockIndex = 0 + + if (params.filterMode === 'condition') { + const result = exportConditionMode(db, params, writer, progress) + totalMessages = result.totalMessages + blockIndex = result.blockIndex + } else { + const result = exportSessionMode(db, params, writer, progress) + totalMessages = result.totalMessages + blockIndex = result.blockIndex + } + + writer.end() + + progress?.({ + stage: 'done', + currentBlock: blockIndex, + totalBlocks: blockIndex, + percentage: 100, + message: `Export complete, ${blockIndex} blocks`, + }) + + return { success: true, totalBlocks: blockIndex, totalMessages } + } catch (error) { + progress?.({ + stage: 'error', + currentBlock: 0, + totalBlocks: 0, + percentage: 0, + message: `Export failed: ${String(error)}`, + }) + return { success: false, error: String(error), totalBlocks: 0, totalMessages: 0 } + } +} + +// ==================== Condition mode ==================== + +function exportConditionMode( + db: DatabaseAdapter, + params: ExportFilterParams, + writer: ExportWriter, + progress?: ExportProgressCallback +): { totalMessages: number; blockIndex: number } { + const contextSize = params.contextSize || 10 + + const lightweightSql = ` + SELECT id, ts, sender_id as senderId, content + FROM message + ${params.timeFilter ? 'WHERE ts >= ? AND ts <= ?' : ''} + ORDER BY ts ASC, id ASC + ` + const sqlParams: unknown[] = [] + if (params.timeFilter) { + sqlParams.push(params.timeFilter.startTs, params.timeFilter.endTs) + } + + const hitIndexes: number[] = [] + let msgIndex = 0 + const stmt = db.prepare(lightweightSql) + const rows = stmt.all(...sqlParams) as Array<{ + id: number + ts: number + senderId: number + content: string | null + }> + + for (const row of rows) { + let isHit = true + if (params.keywords && params.keywords.length > 0) { + const content = (row.content || '').toLowerCase() + isHit = params.keywords.some((kw) => content.includes(kw.toLowerCase())) + } + if (isHit && params.senderIds && params.senderIds.length > 0) { + isHit = params.senderIds.includes(row.senderId) + } + if (isHit) hitIndexes.push(msgIndex) + msgIndex++ + } + + const totalHits = hitIndexes.length + + progress?.({ + stage: 'preparing', + currentBlock: 0, + totalBlocks: 0, + percentage: 10, + message: `Analyzing: found ${totalHits} matching messages...`, + }) + + if (hitIndexes.length === 0) { + writer.write(`## Statistics\n\n- No matches\n`) + writer.end() + return { totalMessages: 0, blockIndex: 0 } + } + + const totalMsgCount = msgIndex + const ranges: Array<{ start: number; end: number; hitIndexes: number[] }> = [] + for (const hitIdx of hitIndexes) { + const start = Math.max(0, hitIdx - contextSize) + const end = Math.min(totalMsgCount - 1, hitIdx + contextSize) + if (ranges.length > 0) { + const last = ranges[ranges.length - 1] + if (start <= last.end + 1) { + last.end = Math.max(last.end, end) + last.hitIndexes.push(hitIdx) + continue + } + } + ranges.push({ start, end, hitIndexes: [hitIdx] }) + } + + const totalBlocks = ranges.length + progress?.({ + stage: 'exporting', + currentBlock: 0, + totalBlocks, + percentage: 15, + message: `Exporting ${totalBlocks} conversation blocks...`, + }) + + writer.write(`## Statistics\n\n- Blocks: ${totalBlocks}\n- Hits: ${totalHits}\n\n`) + writer.write(`## Conversation Content\n\n`) + + let totalMessages = 0 + let blockIndex = 0 + + for (const range of ranges) { + blockIndex++ + progress?.({ + stage: 'exporting', + currentBlock: blockIndex, + totalBlocks, + percentage: Math.round(15 + ((blockIndex - 1) / totalBlocks) * 80), + message: `Exporting block ${blockIndex}/${totalBlocks}...`, + }) + + const blockSql = ` + SELECT msg.id, msg.ts, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + msg.content + FROM message msg + JOIN member m ON msg.sender_id = m.id + ${params.timeFilter ? 'WHERE msg.ts >= ? AND msg.ts <= ?' : ''} + ORDER BY msg.ts ASC, msg.id ASC + LIMIT ? OFFSET ? + ` + const blockParams: unknown[] = [] + if (params.timeFilter) { + blockParams.push(params.timeFilter.startTs, params.timeFilter.endTs) + } + blockParams.push(range.end - range.start + 1, range.start) + + const messages = db.prepare(blockSql).all(...blockParams) as Array<{ + id: number + ts: number + senderName: string + content: string | null + }> + + if (messages.length === 0) continue + + const hitIndexSet = new Set(range.hitIndexes.map((idx) => idx - range.start)) + const startTime = new Date(messages[0].ts * 1000).toLocaleString() + const endTime = new Date(messages[messages.length - 1].ts * 1000).toLocaleString() + writer.write(`### Block ${blockIndex} (${startTime} ~ ${endTime})\n\n`) + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + const time = new Date(msg.ts * 1000).toLocaleTimeString() + const hitMark = hitIndexSet.has(i) ? ' ⭐' : '' + writer.write(`${time} ${msg.senderName}${hitMark}: ${msg.content || '[non-text message]'}\n`) + totalMessages++ + } + writer.write('\n') + } + + return { totalMessages, blockIndex } +} + +// ==================== Session mode ==================== + +function exportSessionMode( + db: DatabaseAdapter, + params: ExportFilterParams, + writer: ExportWriter, + progress?: ExportProgressCallback +): { totalMessages: number; blockIndex: number } { + if (!params.segmentIds || params.segmentIds.length === 0) { + writer.write(`## Statistics\n\n- No sessions selected\n`) + writer.end() + return { totalMessages: 0, blockIndex: 0 } + } + + progress?.({ + stage: 'preparing', + currentBlock: 0, + totalBlocks: params.segmentIds.length, + percentage: 10, + message: `Preparing to export ${params.segmentIds.length} sessions...`, + }) + + const sessionsSql = ` + SELECT id, start_ts as startTs, end_ts as endTs + FROM segment + WHERE id IN (${params.segmentIds.map(() => '?').join(',')}) + ORDER BY start_ts ASC + ` + const sessions = db.prepare(sessionsSql).all(...params.segmentIds) as Array<{ + id: number + startTs: number + endTs: number + }> + + const totalBlocks = sessions.length + writer.write(`## Statistics\n\n- Blocks: ${totalBlocks}\n\n`) + writer.write(`## Conversation Content\n\n`) + + const messagesSql = ` + SELECT msg.id, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as senderName, + msg.content, + msg.ts as timestamp + FROM message_context mc + JOIN message msg ON msg.id = mc.message_id + JOIN member m ON msg.sender_id = m.id + WHERE mc.segment_id = ? + ORDER BY msg.ts ASC + ` + + let totalMessages = 0 + let blockIndex = 0 + + for (const session of sessions) { + blockIndex++ + progress?.({ + stage: 'exporting', + currentBlock: blockIndex, + totalBlocks, + percentage: Math.round(15 + ((blockIndex - 1) / totalBlocks) * 80), + message: `Exporting session ${blockIndex}/${totalBlocks}...`, + }) + + const messages = db.prepare(messagesSql).all(session.id) as Array<{ + id: number + senderName: string + content: string | null + timestamp: number + }> + + if (messages.length === 0) continue + + const startTime = new Date(session.startTs * 1000).toLocaleString() + const endTime = new Date(session.endTs * 1000).toLocaleString() + writer.write(`### Block ${blockIndex} (${startTime} ~ ${endTime})\n\n`) + + for (const msg of messages) { + const time = new Date(msg.timestamp * 1000).toLocaleTimeString() + writer.write(`${time} ${msg.senderName}: ${msg.content || '[non-text message]'}\n`) + totalMessages++ + } + writer.write('\n') + } + + return { totalMessages, blockIndex } +} diff --git a/packages/node-runtime/src/fts/index.ts b/packages/node-runtime/src/fts/index.ts new file mode 100644 index 000000000..64984c781 --- /dev/null +++ b/packages/node-runtime/src/fts/index.ts @@ -0,0 +1,140 @@ +/** + * FTS5 full-text search index operations (platform-agnostic). + * + * Extracted from electron/main/worker/query/fts.ts. + * Works with DatabaseAdapter + NLP tokenizer from this package. + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import { FTS_TABLE_SCHEMA } from '@openchatlab/core' +import { tokenizeForFts, tokenizeQueryForFts } from '../nlp' + +const BATCH_SIZE = 5000 + +/** + * Check if FTS virtual table exists in the database. + */ +export function hasFtsTable(db: DatabaseAdapter): boolean { + try { + const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='message_fts'").get() + return !!row + } catch { + return false + } +} + +/** + * Create FTS virtual table if it doesn't exist. + */ +export function createFtsTable(db: DatabaseAdapter): void { + db.exec(FTS_TABLE_SCHEMA) +} + +/** + * Build FTS index from all text messages in the database. + * Processes in batches for memory efficiency. + */ +export function buildFtsIndex(db: DatabaseAdapter): { indexed: number } { + createFtsTable(db) + + const insertFts = db.prepare('INSERT INTO message_fts(rowid, content) VALUES (?, ?)') + + const countRow = db + .prepare("SELECT COUNT(*) as total FROM message WHERE type = 0 AND content IS NOT NULL AND content != ''") + .get() as { total: number } | undefined + const total = countRow?.total ?? 0 + + let indexed = 0 + let offset = 0 + + while (offset < total) { + const rows = db + .prepare( + `SELECT id, content FROM message + WHERE type = 0 AND content IS NOT NULL AND content != '' + ORDER BY id ASC LIMIT ? OFFSET ?` + ) + .all(BATCH_SIZE, offset) as Array<{ id: number; content: string }> + + if (rows.length === 0) break + + db.transaction(() => { + for (const row of rows) { + const tokens = tokenizeForFts(row.content) + if (tokens) { + insertFts.run(row.id, tokens) + } + } + }) + + indexed += rows.length + offset += BATCH_SIZE + } + + return { indexed } +} + +/** + * Rebuild FTS index by dropping and recreating. + */ +export function rebuildFtsIndex(db: DatabaseAdapter): { indexed: number } { + if (hasFtsTable(db)) { + db.exec('DROP TABLE message_fts') + } + return buildFtsIndex(db) +} + +/** + * Insert FTS entries for a batch of messages. + * Used during incremental import to sync new messages. + */ +export function insertFtsEntries(db: DatabaseAdapter, entries: Array<{ id: number; content: string | null }>): void { + if (!hasFtsTable(db)) return + + const insertFts = db.prepare('INSERT INTO message_fts(rowid, content) VALUES (?, ?)') + + db.transaction(() => { + for (const entry of entries) { + if (entry.content) { + const tokens = tokenizeForFts(entry.content) + if (tokens) { + insertFts.run(entry.id, tokens) + } + } + } + }) +} + +/** + * Search messages using FTS5, returning matching rowids. + */ +export function searchByFts( + db: DatabaseAdapter, + keywords: string[], + limit = 1000, + offset = 0 +): { rowids: number[]; total: number } { + if (keywords.length === 0) return { rowids: [], total: 0 } + + const matchQuery = tokenizeQueryForFts(keywords) + if (!matchQuery) return { rowids: [], total: 0 } + + try { + const countRow = db.prepare('SELECT COUNT(*) as total FROM message_fts WHERE content MATCH ?').get(matchQuery) as + | { total: number } + | undefined + const total = countRow?.total ?? 0 + + const rows = db + .prepare(`SELECT rowid FROM message_fts WHERE content MATCH ? ORDER BY rank LIMIT ? OFFSET ?`) + .all(matchQuery, limit, offset) as Array<{ rowid: number }> + + return { + rowids: rows.map((r) => r.rowid), + total, + } + } catch (error) { + console.error('[FTS] Search failed, query:', matchQuery, error) + return { rowids: [], total: 0 } + } +} diff --git a/packages/node-runtime/src/import/archive/archive-reader.test.ts b/packages/node-runtime/src/import/archive/archive-reader.test.ts new file mode 100644 index 000000000..fdc91a665 --- /dev/null +++ b/packages/node-runtime/src/import/archive/archive-reader.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, it } from 'node:test' + +import { ArchiveImportError } from './errors' +import { ZipArchiveReader, validateArchiveEntryName } from './archive-reader' +import { writeZipFixture } from './test-utils' + +async function streamToString(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = [] + await new Promise((resolve, reject) => { + stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + stream.on('end', resolve) + stream.on('error', reject) + }) + return Buffer.concat(chunks).toString('utf8') +} + +describe('ZipArchiveReader', () => { + it('lists Unicode entries and streams selected content', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-archive-reader-')) + try { + const zipPath = join(dir, 'sample.zip') + writeZipFixture(zipPath, [ + { name: 'Takeout/Google Chat/Groups/DM sample/messages.json', content: '{"messages":[]}' }, + { name: 'Takeout/Google Chat/Groups/DM sample/File-测试.txt', content: 'ignored' }, + ]) + + const reader = new ZipArchiveReader(zipPath) + const entries = await reader.listEntries() + assert.deepEqual( + entries.map((entry) => entry.name), + ['Takeout/Google Chat/Groups/DM sample/messages.json', 'Takeout/Google Chat/Groups/DM sample/File-测试.txt'] + ) + + let messages = '' + await reader.visitEntries(async (entry, openStream) => { + if (entry.name.endsWith('/messages.json')) { + messages = await streamToString(await openStream()) + } + }) + assert.equal(messages, '{"messages":[]}') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('pipes selected entries to fixed destinations', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-archive-pipe-')) + try { + const zipPath = join(dir, 'sample.zip') + const outputPath = join(dir, 'messages.json') + writeZipFixture(zipPath, [{ name: 'source/messages.json', content: '{"messages":[1]}' }]) + + const reader = new ZipArchiveReader(zipPath) + await reader.pipeEntries(new Map([['source/messages.json', outputPath]])) + assert.equal(readFileSync(outputPath, 'utf8'), '{"messages":[1]}') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects unsafe names, corrupt archives, encryption, and limits', async () => { + for (const unsafe of ['/absolute', 'C:/drive', '../escape', 'safe/../escape', 'back\\slash']) { + assert.throws( + () => validateArchiveEntryName(unsafe), + (error) => error instanceof ArchiveImportError && error.code === 'error.archive_unsafe_path' + ) + } + + const dir = mkdtempSync(join(tmpdir(), 'chatlab-archive-invalid-')) + try { + const corruptPath = join(dir, 'corrupt.zip') + writeFileSync(corruptPath, 'not a zip') + await assert.rejects( + () => new ZipArchiveReader(corruptPath).listEntries(), + (error) => error instanceof ArchiveImportError && error.code === 'error.archive_corrupt' + ) + + const encryptedPath = join(dir, 'encrypted.zip') + writeZipFixture(encryptedPath, [{ name: 'secret.json', content: '{}', encrypted: true }]) + await assert.rejects( + () => new ZipArchiveReader(encryptedPath).listEntries(), + (error) => error instanceof ArchiveImportError && error.code === 'error.archive_encrypted' + ) + + const manyPath = join(dir, 'many.zip') + writeZipFixture(manyPath, [ + { name: 'a.json', content: '{}' }, + { name: 'b.json', content: '{}' }, + ]) + await assert.rejects( + () => new ZipArchiveReader(manyPath, { maxEntries: 1 }).listEntries(), + (error) => error instanceof ArchiveImportError && error.code === 'error.archive_limit_exceeded' + ) + + const ratioPath = join(dir, 'ratio.zip') + writeZipFixture(ratioPath, [{ name: 'large.json', content: 'a'.repeat(10_000) }]) + await assert.rejects( + () => new ZipArchiveReader(ratioPath, { maxCompressionRatio: 2 }).listEntries(), + (error) => error instanceof ArchiveImportError && error.code === 'error.archive_limit_exceeded' + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/node-runtime/src/import/archive/archive-reader.ts b/packages/node-runtime/src/import/archive/archive-reader.ts new file mode 100644 index 000000000..6ae9c6a4c --- /dev/null +++ b/packages/node-runtime/src/import/archive/archive-reader.ts @@ -0,0 +1,166 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import { pipeline } from 'node:stream/promises' +import type { Entry, ZipFile } from 'yauzl' +import { openPromise } from 'yauzl' +import { ArchiveImportError } from './errors' +import type { + ArchiveEntryStreamOpener, + ArchiveEntrySummary, + ArchiveEntryVisitor, + ZipArchiveReaderOptions, +} from './types' + +const DEFAULT_MAX_ENTRIES = 100_000 +const DEFAULT_MAX_ENTRY_BYTES = 20 * 1024 * 1024 * 1024 +const DEFAULT_MAX_COMPRESSION_RATIO = 10_000 + +export function validateArchiveEntryName(fileName: string): void { + if ( + fileName.startsWith('/') || + /^[A-Za-z]:\//.test(fileName) || + fileName.includes('\\') || + fileName.split('/').includes('..') + ) { + throw new ArchiveImportError('error.archive_unsafe_path', `Unsafe archive entry path: ${fileName}`) + } +} + +function mapArchiveError(error: unknown): ArchiveImportError { + if (error instanceof ArchiveImportError) return error + const message = error instanceof Error ? error.message : String(error) + return new ArchiveImportError('error.archive_corrupt', `Invalid ZIP archive: ${message}`, { + cause: error, + }) +} + +function toSummary(entry: Entry): ArchiveEntrySummary { + return { + name: entry.fileName, + compressedSize: entry.compressedSize, + uncompressedSize: entry.uncompressedSize, + compressionMethod: entry.compressionMethod, + isDirectory: entry.fileName.endsWith('/'), + } +} + +/** + * 对 ZIP 中央目录和条目元数据做统一校验。所有调用方必须经过这里, + * 防止 resolver 在看到恶意路径或异常压缩比后继续处理。 + */ +function validateEntry(entry: Entry, options: Required): void { + validateArchiveEntryName(entry.fileName) + + if (entry.isEncrypted()) { + throw new ArchiveImportError('error.archive_encrypted', `Encrypted ZIP entry is not supported: ${entry.fileName}`) + } + if (!entry.canDecodeFileData() || (entry.compressionMethod !== 0 && entry.compressionMethod !== 8)) { + throw new ArchiveImportError( + 'error.archive_unsupported', + `Unsupported ZIP compression method for entry: ${entry.fileName}` + ) + } + if (entry.uncompressedSize > options.maxEntryBytes) { + throw new ArchiveImportError('error.archive_limit_exceeded', `ZIP entry exceeds the size limit: ${entry.fileName}`) + } + + if (entry.uncompressedSize > 0) { + const ratio = entry.compressedSize === 0 ? Number.POSITIVE_INFINITY : entry.uncompressedSize / entry.compressedSize + if (ratio > options.maxCompressionRatio) { + throw new ArchiveImportError( + 'error.archive_limit_exceeded', + `ZIP entry exceeds the compression ratio limit: ${entry.fileName}` + ) + } + } +} + +export class ZipArchiveReader { + private readonly options: Required + + constructor( + private readonly archivePath: string, + options: ZipArchiveReaderOptions = {} + ) { + this.options = { + maxEntries: options.maxEntries ?? DEFAULT_MAX_ENTRIES, + maxEntryBytes: options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES, + maxCompressionRatio: options.maxCompressionRatio ?? DEFAULT_MAX_COMPRESSION_RATIO, + } + } + + private async open(): Promise { + try { + return await openPromise(this.archivePath, { + autoClose: true, + decodeStrings: true, + validateEntrySizes: true, + strictFileNames: true, + }) + } catch (error) { + throw mapArchiveError(error) + } + } + + async listEntries(): Promise { + const entries: ArchiveEntrySummary[] = [] + await this.visitEntries((entry) => { + entries.push(entry) + }) + return entries + } + + /** + * 每次只推进一个 ZIP 条目;visitor 若打开流,必须等待流消费结束后返回。 + * 这样大 ZIP 不会同时创建大量 inflate stream。 + */ + async visitEntries(visitor: ArchiveEntryVisitor): Promise { + const zipFile = await this.open() + let count = 0 + try { + for await (const entry of zipFile.eachEntry()) { + count++ + if (count > this.options.maxEntries) { + throw new ArchiveImportError('error.archive_limit_exceeded', 'ZIP archive contains too many entries') + } + validateEntry(entry, this.options) + + let opened = false + const openStream: ArchiveEntryStreamOpener = async () => { + if (opened) { + throw new ArchiveImportError('error.archive_corrupt', `ZIP entry stream opened twice: ${entry.fileName}`) + } + opened = true + try { + return await zipFile.openReadStreamPromise(entry) + } catch (error) { + throw mapArchiveError(error) + } + } + await visitor(toSummary(entry), openStream) + } + } catch (error) { + throw mapArchiveError(error) + } finally { + if (zipFile.isOpen) zipFile.close() + } + } + + async pipeEntries(targets: ReadonlyMap): Promise { + const remaining = new Set(targets.keys()) + await this.visitEntries(async (entry, openStream) => { + const destination = targets.get(entry.name) + if (!destination || entry.isDirectory) return + fs.mkdirSync(path.dirname(destination), { recursive: true }) + await pipeline(await openStream(), fs.createWriteStream(destination, { flags: 'wx' })) + remaining.delete(entry.name) + }) + + if (remaining.size > 0) { + throw new ArchiveImportError( + 'error.archive_corrupt', + `ZIP archive is missing expected entries: ${Array.from(remaining).join(', ')}` + ) + } + } +} diff --git a/packages/node-runtime/src/import/archive/errors.ts b/packages/node-runtime/src/import/archive/errors.ts new file mode 100644 index 000000000..0ce9cb55b --- /dev/null +++ b/packages/node-runtime/src/import/archive/errors.ts @@ -0,0 +1,21 @@ +export type ArchiveImportErrorCode = + | 'error.archive_unsupported' + | 'error.archive_encrypted' + | 'error.archive_corrupt' + | 'error.archive_unsafe_path' + | 'error.archive_limit_exceeded' + | 'error.google_chat_structure_not_found' + | 'error.google_chat_conversation_incomplete' + | 'error.import_source_not_found' + | 'error.import_source_expired' + +export class ArchiveImportError extends Error { + constructor( + readonly code: ArchiveImportErrorCode, + message: string, + options?: ErrorOptions + ) { + super(message, options) + this.name = 'ArchiveImportError' + } +} diff --git a/packages/node-runtime/src/import/archive/google-chat-import.integration.test.ts b/packages/node-runtime/src/import/archive/google-chat-import.integration.test.ts new file mode 100644 index 000000000..ce38d410f --- /dev/null +++ b/packages/node-runtime/src/import/archive/google-chat-import.integration.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, it } from 'node:test' +import { ChatType, KNOWN_PLATFORMS, MessageType } from '@openchatlab/shared-types' +import { parseFileSync } from '@openchatlab/parser' + +import { ArchiveImportSourceManager } from './source-manager' +import { writeZipFixture } from './test-utils' + +function createLocalizedTakeout(zipPath: string, createdDate: string): void { + writeZipFixture(zipPath, [ + { + name: 'Takeout/Google Chat/Users/User sample/user_info.json', + content: JSON.stringify({ user: { email: 'owner@example.com', name: 'Owner' } }), + }, + { + name: 'Takeout/Google Chat/Groups/DM sample/group_info.json', + content: JSON.stringify({ + members: [ + { email: 'owner@example.com', name: 'Owner' }, + { email: 'other@example.com', name: 'Other User' }, + ], + }), + }, + { + name: 'Takeout/Google Chat/Groups/DM sample/messages.json', + content: JSON.stringify({ + messages: [ + { + message_id: 'message-1', + created_date: createdDate, + creator: { email: 'other@example.com', name: 'Other User' }, + attached_files: [{ original_name: 'voice.m4a', export_name: 'File-voice.m4a' }], + }, + ], + }), + }, + { + name: 'Takeout/Google Chat/Groups/DM sample/File-voice.m4a', + content: 'attachment bytes', + }, + ]) +} + +describe('Google Chat archive import integration', () => { + for (const [locale, createdDate] of [ + ['Chinese', '2026年5月29日星期五 UTC 03:00:29'], + ['English', 'Friday, May 29, 2026 at 3:00:29\u202fAM UTC'], + ] as const) { + it(`materializes and parses the ${locale} Takeout date format`, async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-google-chat-integration-')) + try { + const zipPath = join(dir, `${locale}.zip`) + createLocalizedTakeout(zipPath, createdDate) + const manager = new ArchiveImportSourceManager({ tempRoot: join(dir, 'temp') }) + const source = await manager.prepareLocalArchive(zipPath) + + const parsed = await manager.withMaterializedChat(source.sourceId, 'Groups/DM sample', parseFileSync) + assert.equal(parsed.meta.platform, KNOWN_PLATFORMS.GOOGLE_CHAT) + assert.equal(parsed.meta.type, ChatType.PRIVATE) + assert.equal(parsed.messages[0].timestamp, Date.UTC(2026, 4, 29, 3, 0, 29) / 1000) + assert.equal(parsed.messages[0].type, MessageType.FILE) + assert.equal(parsed.messages[0].content, '[附件] voice.m4a') + await manager.close() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + } +}) diff --git a/packages/node-runtime/src/import/archive/google-chat-resolver.test.ts b/packages/node-runtime/src/import/archive/google-chat-resolver.test.ts new file mode 100644 index 000000000..a24667027 --- /dev/null +++ b/packages/node-runtime/src/import/archive/google-chat-resolver.test.ts @@ -0,0 +1,186 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, it } from 'node:test' + +import { ZipArchiveReader } from './archive-reader' +import { ArchiveImportError } from './errors' +import { GoogleChatTakeoutResolver } from './google-chat-resolver' +import { writeZipFixture } from './test-utils' + +class CountingZipArchiveReader extends ZipArchiveReader { + visitCount = 0 + + override async visitEntries(...args: Parameters): Promise { + this.visitCount++ + return super.visitEntries(...args) + } +} + +function createTakeoutZip( + zipPath: string, + options: { + mixedProduct?: boolean + omitGroupInfo?: boolean + omitMessages?: boolean + } = {} +): void { + const entries = [ + { + name: 'Takeout/Google Chat/Users/User sample/user_info.json', + content: JSON.stringify({ + user: { email: 'owner@example.com', name: 'Owner', user_type: 'Human' }, + }), + }, + ...(!options.omitGroupInfo + ? [ + { + name: 'Takeout/Google Chat/Groups/DM sample/group_info.json', + content: JSON.stringify({ + members: [ + { email: 'owner@example.com', name: 'Owner', user_type: 'Human' }, + { email: 'other@example.com', name: 'Other User', user_type: 'Human' }, + ], + }), + }, + ] + : []), + ...(!options.omitMessages + ? [ + { + name: 'Takeout/Google Chat/Groups/DM sample/messages.json', + content: JSON.stringify({ + messages: [ + { + message_id: 'dm-1', + created_date: 'Friday, May 29, 2026 at 3:00:29 AM UTC', + creator: { email: 'other@example.com', name: 'Other User', user_type: 'Human' }, + text: 'Hello', + }, + { + message_id: 'dm-2', + created_date: 'Friday, May 29, 2026 at 3:01:29 AM UTC', + creator: { email: 'owner@example.com', name: 'Owner', user_type: 'Human' }, + attached_files: [{ original_name: 'photo.jpg', export_name: 'File-photo.jpg' }], + }, + ], + }), + }, + ] + : []), + { + name: 'Takeout/Google Chat/Groups/DM sample/File-测试.txt', + content: 'attachment bytes must not be extracted', + }, + { + name: 'Takeout/Google Chat/Groups/Space project/group_info.json', + content: JSON.stringify({ + name: 'Project Space', + members: [ + { email: 'owner@example.com', name: 'Owner', user_type: 'Human' }, + { email: 'member@example.com', name: 'Member', user_type: 'Human' }, + ], + }), + }, + { + name: 'Takeout/Google Chat/Groups/Space project/messages.json', + content: JSON.stringify({ messages: [] }), + }, + ...(options.mixedProduct ? [{ name: 'Takeout/Drive/file.txt', content: 'unrelated' }] : []), + ] + + writeZipFixture(zipPath, entries) +} + +describe('GoogleChatTakeoutResolver', () => { + it('detects and scans DMs, Spaces, and empty conversations', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-google-chat-resolver-')) + try { + const zipPath = join(dir, 'takeout.zip') + createTakeoutZip(zipPath) + const reader = new ZipArchiveReader(zipPath) + const entries = await reader.listEntries() + const resolver = new GoogleChatTakeoutResolver() + + assert.equal(resolver.detect(entries), true) + assert.deepEqual(await resolver.scan(reader), [ + { + chatId: 'Groups/DM sample', + name: 'Other User', + type: 'private', + messageCount: 2, + memberCount: 2, + }, + { + chatId: 'Groups/Space project', + name: 'Project Space', + type: 'group', + messageCount: 0, + memberCount: 2, + }, + ]) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('materializes only selected JSON entries with a fixed internal manifest', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-google-chat-materialize-')) + try { + const zipPath = join(dir, 'takeout.zip') + const targetDir = join(dir, 'selected') + createTakeoutZip(zipPath) + const resolver = new GoogleChatTakeoutResolver() + const reader = new CountingZipArchiveReader(zipPath) + const chats = await resolver.scan(reader) + + const materialized = await resolver.materialize(reader, chats[0], targetDir) + assert.equal(reader.visitCount, 3) + assert.equal(materialized.manifestPath, join(targetDir, 'google-chat-import.json')) + assert.deepEqual(readdirSync(targetDir).sort(), [ + 'google-chat-import.json', + 'group_info.json', + 'messages.json', + 'user_info.json', + ]) + assert.equal(existsSync(join(targetDir, 'File-测试.txt')), false) + assert.deepEqual(JSON.parse(readFileSync(materialized.manifestPath, 'utf8')), { + format: 'chatlab-google-chat-takeout', + version: 1, + chatId: 'Groups/DM sample', + chatType: 'private', + chatName: 'Other User', + userInfoFile: 'user_info.json', + groupInfoFile: 'group_info.json', + messagesFile: 'messages.json', + }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('rejects mixed Takeout products and incomplete conversation structures', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-google-chat-invalid-')) + try { + const mixedPath = join(dir, 'mixed.zip') + createTakeoutZip(mixedPath, { mixedProduct: true }) + const resolver = new GoogleChatTakeoutResolver() + assert.equal(resolver.detect(await new ZipArchiveReader(mixedPath).listEntries()), false) + + for (const [name, options] of [ + ['missing-group.zip', { omitGroupInfo: true }], + ['missing-messages.zip', { omitMessages: true }], + ] as const) { + const zipPath = join(dir, name) + createTakeoutZip(zipPath, options) + await assert.rejects( + () => resolver.scan(new ZipArchiveReader(zipPath)), + (error) => error instanceof ArchiveImportError && error.code === 'error.google_chat_conversation_incomplete' + ) + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/node-runtime/src/import/archive/google-chat-resolver.ts b/packages/node-runtime/src/import/archive/google-chat-resolver.ts new file mode 100644 index 000000000..2711aa22d --- /dev/null +++ b/packages/node-runtime/src/import/archive/google-chat-resolver.ts @@ -0,0 +1,228 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import type { Readable } from 'node:stream' +import streamChain from 'stream-chain' +import streamJson from 'stream-json' +import pickModule from 'stream-json/filters/Pick.js' +import streamValuesModule from 'stream-json/streamers/StreamValues.js' +import { ArchiveImportError } from './errors' +import type { ZipArchiveReader } from './archive-reader' +import type { ArchiveEntrySummary, ArchiveResolver, MaterializedImport, PreparedImportChat } from './types' + +const { chain } = streamChain +const { parser } = streamJson +const { pick } = pickModule +const { streamValues } = streamValuesModule + +const GROUP_FILE = /^Takeout\/Google Chat\/Groups\/((DM|Space) [^/]+)\/(group_info|messages)\.json$/ +const USER_INFO_FILE = /^Takeout\/Google Chat\/Users\/[^/]+\/user_info\.json$/ +const TAKEOUT_ROOT = /^Takeout\/([^/]+)\// +const MAX_METADATA_BYTES = 5 * 1024 * 1024 + +interface GoogleChatUser { + email?: string + name?: string +} + +interface GoogleChatUserInfo { + user?: GoogleChatUser +} + +interface GoogleChatGroupInfo { + name?: string + members?: GoogleChatUser[] +} + +interface ConversationDraft { + chatId: string + kind: 'DM' | 'Space' + groupInfoEntry?: string + messagesEntry?: string + groupInfo?: GoogleChatGroupInfo + messageCount?: number +} + +async function readSmallJson(stream: Readable, maxBytes = MAX_METADATA_BYTES): Promise { + const chunks: Buffer[] = [] + let total = 0 + for await (const chunk of stream as AsyncIterable) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + total += buffer.length + if (total > maxBytes) { + throw new ArchiveImportError('error.archive_limit_exceeded', 'Google Chat metadata exceeds the size limit') + } + chunks.push(buffer) + } + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T + } catch (error) { + throw new ArchiveImportError('error.archive_corrupt', 'Invalid Google Chat metadata JSON', { cause: error }) + } +} + +async function countMessages(stream: Readable): Promise { + const pipeline = chain([stream, parser(), pick({ filter: /^messages\.\d+$/ }), streamValues()]) + let count = 0 + try { + for await (const _item of pipeline as AsyncIterable) count++ + return count + } catch (error) { + throw new ArchiveImportError('error.archive_corrupt', 'Invalid Google Chat messages JSON', { cause: error }) + } +} + +function normalizeEmail(user: GoogleChatUser | undefined): string | null { + return user?.email?.trim().toLowerCase() || null +} + +function deriveConversationName(draft: ConversationDraft, ownerEmail: string | null): string { + const groupInfo = draft.groupInfo + if (draft.kind === 'Space' && groupInfo?.name?.trim()) return groupInfo.name.trim() + + const members = groupInfo?.members ?? [] + const other = members.find((member) => { + const email = normalizeEmail(member) + return !ownerEmail || email !== ownerEmail + }) + return other?.name?.trim() || other?.email?.trim() || members[0]?.name?.trim() || draft.chatId +} + +export class GoogleChatTakeoutResolver implements ArchiveResolver { + readonly id = 'google-chat-takeout' + readonly platform = 'google-chat' + + detect(entries: ArchiveEntrySummary[]): boolean { + const products = new Set() + const groupFiles = new Map>() + + for (const entry of entries) { + const product = entry.name.match(TAKEOUT_ROOT)?.[1] + if (product) products.add(product) + + const match = entry.name.match(GROUP_FILE) + if (!match) continue + const files = groupFiles.get(match[1]) ?? new Set() + files.add(match[3]) + groupFiles.set(match[1], files) + } + + return ( + products.size === 1 && + products.has('Google Chat') && + Array.from(groupFiles.values()).some((files) => files.has('group_info') && files.has('messages')) + ) + } + + /** + * 扫描时按 ZIP 顺序消费必要 JSON。会话元数据和消息计数先暂存, + * 等 owner 信息齐备后统一生成名称,避免依赖归档内部文件顺序。 + */ + async scan(reader: ZipArchiveReader): Promise { + let ownerEmail: string | null = null + const drafts = new Map() + + await reader.visitEntries(async (entry, openStream) => { + if (USER_INFO_FILE.test(entry.name)) { + const ownerInfo = await readSmallJson(await openStream()) + ownerEmail = normalizeEmail(ownerInfo.user) + return + } + + const match = entry.name.match(GROUP_FILE) + if (!match) return + const chatId = `Groups/${match[1]}` + const draft = + drafts.get(chatId) ?? + ({ + chatId, + kind: match[2] as 'DM' | 'Space', + } satisfies ConversationDraft) + + if (match[3] === 'group_info') { + draft.groupInfoEntry = entry.name + draft.groupInfo = await readSmallJson(await openStream()) + } else { + draft.messagesEntry = entry.name + draft.messageCount = await countMessages(await openStream()) + } + drafts.set(chatId, draft) + }) + + if (drafts.size === 0) { + throw new ArchiveImportError( + 'error.google_chat_structure_not_found', + 'Google Chat conversation structure was not found' + ) + } + + return Array.from(drafts.values()) + .map((draft) => { + if (!draft.groupInfoEntry || !draft.messagesEntry || !draft.groupInfo || draft.messageCount === undefined) { + throw new ArchiveImportError( + 'error.google_chat_conversation_incomplete', + `Google Chat conversation is missing required files: ${draft.chatId}` + ) + } + return { + chatId: draft.chatId, + name: deriveConversationName(draft, ownerEmail), + type: draft.kind === 'Space' ? 'group' : 'private', + messageCount: draft.messageCount, + memberCount: draft.groupInfo.members?.length ?? 0, + } satisfies PreparedImportChat + }) + .sort((a, b) => a.chatId.localeCompare(b.chatId)) + } + + async materialize( + reader: ZipArchiveReader, + chat: PreparedImportChat, + targetDir: string + ): Promise { + const entries = await reader.listEntries() + const groupPrefix = `Takeout/Google Chat/${chat.chatId}/` + const userInfoEntry = entries.find((entry) => USER_INFO_FILE.test(entry.name))?.name + const groupInfoEntry = `${groupPrefix}group_info.json` + const messagesEntry = `${groupPrefix}messages.json` + if ( + !userInfoEntry || + !entries.some((entry) => entry.name === groupInfoEntry) || + !entries.some((entry) => entry.name === messagesEntry) + ) { + throw new ArchiveImportError( + 'error.google_chat_conversation_incomplete', + `Google Chat conversation is missing required files: ${chat.chatId}` + ) + } + + fs.mkdirSync(targetDir, { recursive: true }) + await reader.pipeEntries( + new Map([ + [userInfoEntry, path.join(targetDir, 'user_info.json')], + [groupInfoEntry, path.join(targetDir, 'group_info.json')], + [messagesEntry, path.join(targetDir, 'messages.json')], + ]) + ) + + const manifestPath = path.join(targetDir, 'google-chat-import.json') + fs.writeFileSync( + manifestPath, + JSON.stringify( + { + format: 'chatlab-google-chat-takeout', + version: 1, + chatId: chat.chatId, + chatType: chat.type, + chatName: chat.name, + userInfoFile: 'user_info.json', + groupInfoFile: 'group_info.json', + messagesFile: 'messages.json', + }, + null, + 2 + ), + { encoding: 'utf8', flag: 'wx' } + ) + return { manifestPath } + } +} diff --git a/packages/node-runtime/src/import/archive/source-manager.test.ts b/packages/node-runtime/src/import/archive/source-manager.test.ts new file mode 100644 index 000000000..97c90fc0d --- /dev/null +++ b/packages/node-runtime/src/import/archive/source-manager.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, it } from 'node:test' + +import { ArchiveImportError } from './errors' +import { ArchiveImportSourceManager } from './source-manager' +import { writeZipFixture } from './test-utils' + +function createMinimalTakeout(zipPath: string): void { + writeZipFixture(zipPath, [ + { + name: 'Takeout/Google Chat/Users/User sample/user_info.json', + content: JSON.stringify({ + user: { email: 'owner@example.com', name: 'Owner', user_type: 'Human' }, + }), + }, + { + name: 'Takeout/Google Chat/Groups/DM sample/group_info.json', + content: JSON.stringify({ + members: [ + { email: 'owner@example.com', name: 'Owner', user_type: 'Human' }, + { email: 'other@example.com', name: 'Other User', user_type: 'Human' }, + ], + }), + }, + { + name: 'Takeout/Google Chat/Groups/DM sample/messages.json', + content: JSON.stringify({ messages: [] }), + }, + ]) +} + +describe('ArchiveImportSourceManager', () => { + it('keeps local archives and deletes owned archives on release', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-source-ownership-')) + try { + const localZip = join(dir, 'local.zip') + const ownedZip = join(dir, 'owned.zip') + createMinimalTakeout(localZip) + createMinimalTakeout(ownedZip) + const manager = new ArchiveImportSourceManager({ tempRoot: join(dir, 'temp') }) + + const local = await manager.prepareLocalArchive(localZip) + const owned = await manager.prepareOwnedArchive(ownedZip) + assert.equal(local.platform, 'google-chat') + assert.equal(local.chats[0].chatId, 'Groups/DM sample') + + await manager.release(local.sourceId) + await manager.release(owned.sourceId) + await manager.release(owned.sourceId) + + assert.equal(existsSync(localZip), true) + assert.equal(existsSync(ownedZip), false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('cleans materialized directories after success and errors', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-source-materialize-')) + try { + const zipPath = join(dir, 'takeout.zip') + createMinimalTakeout(zipPath) + const manager = new ArchiveImportSourceManager({ tempRoot: join(dir, 'temp') }) + const source = await manager.prepareLocalArchive(zipPath) + let successDir = '' + + const result = await manager.withMaterializedChat(source.sourceId, 'Groups/DM sample', async (manifestPath) => { + successDir = dirname(manifestPath) + assert.equal(existsSync(manifestPath), true) + return 'ok' + }) + assert.equal(result, 'ok') + assert.equal(existsSync(successDir), false) + + let errorDir = '' + await assert.rejects( + () => + manager.withMaterializedChat(source.sourceId, 'Groups/DM sample', async (manifestPath) => { + errorDir = dirname(manifestPath) + throw new Error('handler failed') + }), + /handler failed/ + ) + assert.equal(existsSync(errorDir), false) + await manager.close() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('expires inactive sources and distinguishes expired from unknown IDs', async () => { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-source-expiry-')) + let now = 1_000 + try { + const ownedZip = join(dir, 'owned.zip') + createMinimalTakeout(ownedZip) + const manager = new ArchiveImportSourceManager({ + tempRoot: join(dir, 'temp'), + ttlMs: 500, + now: () => now, + }) + const source = await manager.prepareOwnedArchive(ownedZip) + now = 2_000 + + assert.equal(await manager.cleanupExpired(), 1) + assert.equal(existsSync(ownedZip), false) + await assert.rejects( + () => manager.withMaterializedChat(source.sourceId, 'Groups/DM sample', async () => undefined), + (error) => error instanceof ArchiveImportError && error.code === 'error.import_source_expired' + ) + await assert.rejects( + () => manager.withMaterializedChat('missing', 'Groups/DM sample', async () => undefined), + (error) => error instanceof ArchiveImportError && error.code === 'error.import_source_not_found' + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/node-runtime/src/import/archive/source-manager.ts b/packages/node-runtime/src/import/archive/source-manager.ts new file mode 100644 index 000000000..749c0dfb4 --- /dev/null +++ b/packages/node-runtime/src/import/archive/source-manager.ts @@ -0,0 +1,168 @@ +import * as fs from 'node:fs' +import * as path from 'node:path' +import { randomUUID } from 'node:crypto' +import { ZipArchiveReader } from './archive-reader' +import { ArchiveImportError } from './errors' +import { GoogleChatTakeoutResolver } from './google-chat-resolver' +import type { ArchiveResolver, PreparedImportSource } from './types' + +const DEFAULT_TTL_MS = 30 * 60 * 1000 + +interface SourceRecord { + descriptor: PreparedImportSource + archivePath: string + ownsArchive: boolean + resolver: ArchiveResolver + lastAccessAt: number +} + +export interface ArchiveImportSourceManagerOptions { + tempRoot?: string + ttlMs?: number + now?: () => number + resolvers?: ArchiveResolver[] +} + +export class ArchiveImportSourceManager { + private readonly records = new Map() + private readonly expiredSourceIds = new Set() + private readonly tempRoot: string + private readonly ttlMs: number + private readonly now: () => number + private readonly resolvers: ArchiveResolver[] + + constructor(options: ArchiveImportSourceManagerOptions = {}) { + this.tempRoot = options.tempRoot ?? path.join(process.cwd(), '.chatlab-import-sources') + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS + this.now = options.now ?? Date.now + this.resolvers = options.resolvers ?? [new GoogleChatTakeoutResolver()] + fs.mkdirSync(this.tempRoot, { recursive: true }) + } + + prepareLocalArchive(archivePath: string): Promise { + return this.prepareArchive(archivePath, false) + } + + prepareOwnedArchive(archivePath: string): Promise { + return this.prepareArchive(archivePath, true) + } + + private async prepareArchive(archivePath: string, ownsArchive: boolean): Promise { + await this.cleanupExpired() + const reader = new ZipArchiveReader(archivePath) + try { + const entries = await reader.listEntries() + const matches = this.resolvers.filter((resolver) => resolver.detect(entries)) + if (matches.length !== 1) { + throw new ArchiveImportError( + 'error.archive_unsupported', + matches.length === 0 ? 'Unsupported archive format' : 'Archive matches multiple import formats' + ) + } + + const resolver = matches[0] + const chats = await resolver.scan(reader) + const createdAt = this.now() + const sourceId = randomUUID() + const descriptor: PreparedImportSource = { + sourceId, + formatId: resolver.id, + platform: resolver.platform, + chats, + expiresAt: createdAt + this.ttlMs, + } + this.records.set(sourceId, { + descriptor, + archivePath, + ownsArchive, + resolver, + lastAccessAt: createdAt, + }) + return descriptor + } catch (error) { + if (ownsArchive) this.removePath(archivePath) + throw error + } + } + + /** + * selected chat 的 JSON 物化目录只在 handler 生命周期内存在。 + * 无论数据库导入成功、失败还是抛异常,finally 都会删除它。 + */ + async withMaterializedChat( + sourceId: string, + chatId: string, + handler: (manifestPath: string) => Promise + ): Promise { + const record = await this.getActiveRecord(sourceId) + const chat = record.descriptor.chats.find((item) => item.chatId === chatId) + if (!chat) { + throw new ArchiveImportError( + 'error.google_chat_conversation_incomplete', + `Archive conversation was not found: ${chatId}` + ) + } + const materializedDir = fs.mkdtempSync(path.join(this.tempRoot, `materialized-${sourceId}-`)) + try { + const reader = new ZipArchiveReader(record.archivePath) + const materialized = await record.resolver.materialize(reader, chat, materializedDir) + return await handler(materialized.manifestPath) + } finally { + this.removePath(materializedDir) + } + } + + async release(sourceId: string): Promise { + const record = this.records.get(sourceId) + if (!record) return + this.records.delete(sourceId) + if (record.ownsArchive) this.removePath(record.archivePath) + } + + async cleanupExpired(now = this.now()): Promise { + let removed = 0 + for (const [sourceId, record] of this.records) { + if (now - record.lastAccessAt < this.ttlMs) continue + this.records.delete(sourceId) + this.expiredSourceIds.add(sourceId) + if (record.ownsArchive) this.removePath(record.archivePath) + removed++ + } + return removed + } + + async close(): Promise { + for (const sourceId of Array.from(this.records.keys())) { + await this.release(sourceId) + } + this.removePath(this.tempRoot) + } + + private async getActiveRecord(sourceId: string): Promise { + const record = this.records.get(sourceId) + if (!record) { + const code = this.expiredSourceIds.has(sourceId) ? 'error.import_source_expired' : 'error.import_source_not_found' + throw new ArchiveImportError(code, `Archive import source is unavailable: ${sourceId}`) + } + + const now = this.now() + if (now - record.lastAccessAt >= this.ttlMs) { + this.records.delete(sourceId) + this.expiredSourceIds.add(sourceId) + if (record.ownsArchive) this.removePath(record.archivePath) + throw new ArchiveImportError('error.import_source_expired', `Archive import source expired: ${sourceId}`) + } + + record.lastAccessAt = now + record.descriptor.expiresAt = now + this.ttlMs + return record + } + + private removePath(targetPath: string): void { + try { + fs.rmSync(targetPath, { recursive: true, force: true }) + } catch { + // 清理失败不覆盖原始导入错误,残留文件由下次启动或系统临时目录回收。 + } + } +} diff --git a/packages/node-runtime/src/import/archive/test-utils.ts b/packages/node-runtime/src/import/archive/test-utils.ts new file mode 100644 index 000000000..74af4ef32 --- /dev/null +++ b/packages/node-runtime/src/import/archive/test-utils.ts @@ -0,0 +1,80 @@ +import { deflateRawSync } from 'node:zlib' +import { writeFileSync } from 'node:fs' + +interface ZipFixtureEntry { + name: string + content: string | Buffer + encrypted?: boolean +} + +const CRC_TABLE = new Uint32Array(256) +for (let n = 0; n < 256; n++) { + let value = n + for (let bit = 0; bit < 8; bit++) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1 + } + CRC_TABLE[n] = value >>> 0 +} + +function crc32(buffer: Buffer): number { + let crc = 0xffffffff + for (const byte of buffer) { + crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +/** + * 测试专用的最小 ZIP 生成器,仅实现 UTF-8 文件名和 deflate, + * 避免测试依赖系统 zip 命令或额外生产依赖。 + */ +export function writeZipFixture(filePath: string, entries: ZipFixtureEntry[]): void { + const localParts: Buffer[] = [] + const centralParts: Buffer[] = [] + let localOffset = 0 + + for (const entry of entries) { + const name = Buffer.from(entry.name, 'utf8') + const content = Buffer.isBuffer(entry.content) ? entry.content : Buffer.from(entry.content, 'utf8') + const compressed = deflateRawSync(content) + const flags = 0x0800 | (entry.encrypted ? 0x0001 : 0) + const checksum = crc32(content) + + const localHeader = Buffer.alloc(30) + localHeader.writeUInt32LE(0x04034b50, 0) + localHeader.writeUInt16LE(20, 4) + localHeader.writeUInt16LE(flags, 6) + localHeader.writeUInt16LE(8, 8) + localHeader.writeUInt32LE(checksum, 14) + localHeader.writeUInt32LE(compressed.length, 18) + localHeader.writeUInt32LE(content.length, 22) + localHeader.writeUInt16LE(name.length, 26) + + localParts.push(localHeader, name, compressed) + + const centralHeader = Buffer.alloc(46) + centralHeader.writeUInt32LE(0x02014b50, 0) + centralHeader.writeUInt16LE(20, 4) + centralHeader.writeUInt16LE(20, 6) + centralHeader.writeUInt16LE(flags, 8) + centralHeader.writeUInt16LE(8, 10) + centralHeader.writeUInt32LE(checksum, 16) + centralHeader.writeUInt32LE(compressed.length, 20) + centralHeader.writeUInt32LE(content.length, 24) + centralHeader.writeUInt16LE(name.length, 28) + centralHeader.writeUInt32LE(localOffset, 42) + centralParts.push(centralHeader, name) + + localOffset += localHeader.length + name.length + compressed.length + } + + const centralSize = centralParts.reduce((size, part) => size + part.length, 0) + const end = Buffer.alloc(22) + end.writeUInt32LE(0x06054b50, 0) + end.writeUInt16LE(entries.length, 8) + end.writeUInt16LE(entries.length, 10) + end.writeUInt32LE(centralSize, 12) + end.writeUInt32LE(localOffset, 16) + + writeFileSync(filePath, Buffer.concat([...localParts, ...centralParts, end])) +} diff --git a/packages/node-runtime/src/import/archive/types.ts b/packages/node-runtime/src/import/archive/types.ts new file mode 100644 index 000000000..7f4dc7f97 --- /dev/null +++ b/packages/node-runtime/src/import/archive/types.ts @@ -0,0 +1,51 @@ +import type { Readable } from 'node:stream' +import type { ZipArchiveReader } from './archive-reader' + +export interface ArchiveEntrySummary { + name: string + compressedSize: number + uncompressedSize: number + compressionMethod: number + isDirectory: boolean +} + +export type ArchiveEntryStreamOpener = () => Promise + +export type ArchiveEntryVisitor = ( + entry: ArchiveEntrySummary, + openStream: ArchiveEntryStreamOpener +) => Promise | void + +export interface ZipArchiveReaderOptions { + maxEntries?: number + maxEntryBytes?: number + maxCompressionRatio?: number +} + +export interface PreparedImportChat { + chatId: string + name: string + type: 'private' | 'group' + messageCount: number + memberCount: number +} + +export interface MaterializedImport { + manifestPath: string +} + +export interface PreparedImportSource { + sourceId: string + formatId: string + platform: string + chats: PreparedImportChat[] + expiresAt: number +} + +export interface ArchiveResolver { + readonly id: string + readonly platform: string + detect(entries: ArchiveEntrySummary[]): boolean + scan(reader: ZipArchiveReader): Promise + materialize(reader: ZipArchiveReader, chat: PreparedImportChat, targetDir: string): Promise +} diff --git a/packages/node-runtime/src/import/incremental-importer.test.ts b/packages/node-runtime/src/import/incremental-importer.test.ts new file mode 100644 index 000000000..ab76ec87f --- /dev/null +++ b/packages/node-runtime/src/import/incremental-importer.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { CHAT_DB_SCHEMA } from '@openchatlab/core' +import { openBetterSqliteDatabase } from '../better-sqlite3-adapter' +import { analyzeIncrementalImport, incrementalImport, type IncrementalImportDeps } from './incremental-importer' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-incremental-import-')) +} + +function writeChatLabJsonl(filePath: string): void { + const lines = [ + { + _type: 'header', + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'CipherTalk Export', platform: 'wechat', type: 'private' }, + }, + { + _type: 'member', + platformId: 'wxid_alice', + accountName: 'Alice', + }, + { + _type: 'message', + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: '1780330832', + type: 0, + content: 'hello from CipherTalk', + }, + ] + + fs.writeFileSync(filePath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8') +} + +function seedSessionDb(dbPath: string): void { + const db = openBetterSqliteDatabase(dbPath, { nativeBinding }) + db.exec(CHAT_DB_SCHEMA) + db.prepare( + `INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES (?, ?, ?, ?, ?)` + ).run('Existing Session', 'wechat', 'private', 1780330000, 6) + db.close() +} + +function createDeps(dbPath: string): IncrementalImportDeps { + return { + openDatabase: (_sessionId, readonly = false) => openBetterSqliteDatabase(dbPath, { readonly, nativeBinding }), + onProgress: () => {}, + } +} + +test('imports ChatLab JSONL messages with numeric string timestamps consistently with analysis', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const dbPath = path.join(tempDir, 'session.db') + const filePath = path.join(tempDir, 'cipher-talk.jsonl') + seedSessionDb(dbPath) + writeChatLabJsonl(filePath) + + const deps = createDeps(dbPath) + + const analysis = await analyzeIncrementalImport('session', filePath, deps) + assert.deepEqual(analysis, { + newMessageCount: 1, + duplicateCount: 0, + totalInFile: 1, + }) + + const result = await incrementalImport('session', filePath, deps) + assert.equal(result.success, true) + assert.equal(result.newMessageCount, 1) + assert.equal(result.batch?.writtenCount, 1) + assert.equal(result.batch?.errorCount, 0) + + const db = openBetterSqliteDatabase(dbPath, { readonly: true, nativeBinding }) + const row = db.prepare('SELECT ts, content FROM message').get() as { ts: number; content: string } | undefined + db.close() + + assert.deepEqual(row, { + ts: 1780330832, + content: 'hello from CipherTalk', + }) +}) diff --git a/packages/node-runtime/src/import/incremental-importer.ts b/packages/node-runtime/src/import/incremental-importer.ts new file mode 100644 index 000000000..69b706b2b --- /dev/null +++ b/packages/node-runtime/src/import/incremental-importer.ts @@ -0,0 +1,466 @@ +/** + * Platform-agnostic incremental importer. + * + * Extracted from electron/main/worker/import/incrementalImport.ts. + * Appends new messages to an existing session, using dual-path dedup: + * platformMessageId (preferred) + content hash (fallback). + * + * Callers provide DatabaseAdapter + progress callback via dependency injection. + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import { generateMessageKey, generateSessionIndex, generateIncrementalSessionIndex } from '@openchatlab/core' +import { streamParseFile, detectFormat, type ParseProgress } from '@openchatlab/parser' +import { insertFtsEntries, hasFtsTable } from '../fts' +import type { ImportProgressCallback } from './streaming-importer' + +// ==================== Public interfaces ==================== + +export interface ImportOptions { + metaUpdateMode?: 'patch' | 'none' + memberUpdateMode?: 'upsert' | 'none' +} + +export interface IncrementalAnalyzeResult { + newMessageCount: number + duplicateCount: number + totalInFile: number + error?: string +} + +interface ErrorSample { + index: number + reason: string + detail: string +} + +export interface IncrementalImportResult { + success: boolean + newMessageCount: number + error?: string + batch?: { + receivedCount: number + writtenCount: number + duplicateCount: number + errorCount: number + errorReasonCounts: Record + errorSample: ErrorSample[] + } + session?: { + totalCount: number + memberCount: number + firstTimestamp: number + lastTimestamp: number + } + updates?: { + metaUpdated: boolean + membersAdded: number + membersUpdated: number + } +} + +export interface IncrementalImportDeps { + /** Open existing session DB for read-only access (analyze) or read-write (import). */ + openDatabase(sessionId: string, readonly?: boolean): DatabaseAdapter + onProgress: ImportProgressCallback + /** Optional hook after incremental import (e.g. update overview cache). */ + postImportHook?: (db: DatabaseAdapter, sessionId: string) => void | Promise +} + +// ==================== Internal helpers ==================== + +function loadExistingDedup(db: DatabaseAdapter): { + existingPlatformMsgIds: Set + existingKeys: Set +} { + const existingPlatformMsgIds = new Set() + const existingKeys = new Set() + + const pmidRows = db + .prepare('SELECT platform_message_id FROM message WHERE platform_message_id IS NOT NULL') + .all() as Array<{ platform_message_id: string }> + for (const row of pmidRows) { + existingPlatformMsgIds.add(row.platform_message_id) + } + + const hashRows = db + .prepare( + `SELECT ts, m.platform_id as sender_platform_id, content + FROM message msg + JOIN member m ON msg.sender_id = m.id` + ) + .all() as Array<{ ts: number; sender_platform_id: string; content: string | null }> + for (const row of hashRows) { + existingKeys.add(generateMessageKey(row.ts, row.sender_platform_id, row.content)) + } + + return { existingPlatformMsgIds, existingKeys } +} + +function isDuplicate( + msg: { platformMessageId?: string; timestamp: number; senderPlatformId: string; content: string | null }, + existingPlatformMsgIds: Set, + existingKeys: Set +): boolean { + if (msg.platformMessageId) { + if (existingPlatformMsgIds.has(msg.platformMessageId)) return true + existingPlatformMsgIds.add(msg.platformMessageId) + return false + } + const key = generateMessageKey(msg.timestamp, msg.senderPlatformId, msg.content) + if (existingKeys.has(key)) return true + existingKeys.add(key) + return false +} + +function normalizeTimestamp(timestamp: unknown): number | null { + const value = typeof timestamp === 'string' && timestamp.trim() !== '' ? Number(timestamp) : timestamp + return typeof value === 'number' && value > 0 && Number.isFinite(value) ? value : null +} + +// ==================== Analyze (dry-run) ==================== + +export async function analyzeIncrementalImport( + sessionId: string, + filePath: string, + deps: IncrementalImportDeps +): Promise { + const formatFeature = detectFormat(filePath) + if (!formatFeature) { + return { error: 'error.unrecognized_format', newMessageCount: 0, duplicateCount: 0, totalInFile: 0 } + } + + let db: DatabaseAdapter + try { + db = deps.openDatabase(sessionId, true) + } catch { + return { error: 'error.session_not_found', newMessageCount: 0, duplicateCount: 0, totalInFile: 0 } + } + + const { existingPlatformMsgIds, existingKeys } = loadExistingDedup(db) + db.close() + + let totalInFile = 0 + let newMessageCount = 0 + let duplicateCount = 0 + + await streamParseFile(filePath, { + onMeta: () => {}, + onMembers: () => {}, + onProgress: (progress: ParseProgress) => { + deps.onProgress(progress) + }, + onMessageBatch: (batch) => { + for (const msg of batch) { + totalInFile++ + const timestamp = normalizeTimestamp(msg.timestamp) + if (timestamp === null) continue + + if (isDuplicate({ ...msg, timestamp }, existingPlatformMsgIds, existingKeys)) { + duplicateCount++ + } else { + newMessageCount++ + } + } + }, + }) + + return { newMessageCount, duplicateCount, totalInFile } +} + +// ==================== Execute incremental import ==================== + +export async function incrementalImport( + sessionId: string, + filePath: string, + deps: IncrementalImportDeps, + options?: ImportOptions +): Promise { + const formatFeature = detectFormat(filePath) + if (!formatFeature) { + return { success: false, newMessageCount: 0, error: 'error.unrecognized_format' } + } + + let db: DatabaseAdapter + try { + db = deps.openDatabase(sessionId, false) + } catch { + return { success: false, newMessageCount: 0, error: 'error.session_not_found' } + } + + const metaUpdateMode = options?.metaUpdateMode ?? 'patch' + const memberUpdateMode = options?.memberUpdateMode ?? 'upsert' + + try { + const { existingPlatformMsgIds, existingKeys } = loadExistingDedup(db) + + const memberIdMap = new Map() + const existingMembers = db.prepare('SELECT id, platform_id FROM member').all() as Array<{ + id: number + platform_id: string + }> + for (const m of existingMembers) { + memberIdMap.set(m.platform_id, m.id) + } + + const upsertMember = db.prepare(` + INSERT INTO member (platform_id, account_name, group_nickname, avatar, roles) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(platform_id) DO UPDATE SET + account_name = COALESCE(NULLIF(excluded.account_name, ''), account_name), + group_nickname = COALESCE(NULLIF(excluded.group_nickname, ''), group_nickname), + avatar = COALESCE(NULLIF(excluded.avatar, ''), avatar), + roles = CASE WHEN excluded.roles != '[]' THEN excluded.roles ELSE roles END + `) + + const insertMemberMinimal = db.prepare(` + INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) + VALUES (?, ?, ?, ?) + `) + + const getMemberId = db.prepare('SELECT id FROM member WHERE platform_id = ?') + + const insertMessage = db.prepare(` + INSERT INTO message (sender_id, sender_account_name, sender_group_nickname, ts, type, content, reply_to_message_id, platform_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `) + + const updateMeta = db.prepare(` + UPDATE meta SET + name = COALESCE(NULLIF(?, ''), name), + group_id = COALESCE(NULLIF(?, ''), group_id), + group_avatar = COALESCE(NULLIF(?, ''), group_avatar), + owner_id = COALESCE(NULLIF(?, ''), owner_id), + imported_at = ? + `) + + const preWriteMaxTs = + (db.prepare('SELECT MAX(ts) as max_ts FROM message').get() as { max_ts: number | null })?.max_ts ?? 0 + + db.exec('BEGIN TRANSACTION') + + let newMessageCount = 0 + let duplicateCount = 0 + let processedCount = 0 + let minWrittenTs = Infinity + let metaUpdated = false + let membersAdded = 0 + let membersUpdated = 0 + let errorCount = 0 + const errorReasonCounts: Record = {} + const errorSamples: ErrorSample[] = [] + const MAX_ERROR_SAMPLES = 5 + const BATCH_SIZE = 5000 + + function trackError(index: number, reason: string, detail: string) { + errorCount++ + errorReasonCounts[reason] = (errorReasonCounts[reason] || 0) + 1 + if (errorSamples.length < MAX_ERROR_SAMPLES) { + errorSamples.push({ index, reason, detail }) + } + } + + const newFtsEntries: Array<{ id: number; content: string | null }> = [] + + await streamParseFile(filePath, { + onMeta: (meta) => { + if (metaUpdateMode === 'none') return + updateMeta.run( + meta.name || '', + meta.groupId || '', + meta.groupAvatar || '', + meta.ownerId || '', + Math.floor(Date.now() / 1000) + ) + metaUpdated = true + }, + onMembers: (members) => { + if (memberUpdateMode === 'none') return + for (const m of members) { + const existed = memberIdMap.has(m.platformId) + upsertMember.run( + m.platformId, + m.accountName || null, + m.groupNickname || null, + m.avatar || null, + m.roles ? JSON.stringify(m.roles) : '[]' + ) + if (!existed) { + const row = getMemberId.get(m.platformId) as { id: number } | undefined + if (row) memberIdMap.set(m.platformId, row.id) + membersAdded++ + } else { + membersUpdated++ + } + } + }, + onProgress: (progress: ParseProgress) => { + deps.onProgress(progress) + }, + onMessageBatch: (batch) => { + for (const msg of batch) { + processedCount++ + + if (!msg.senderPlatformId) { + trackError(processedCount, 'MISSING_SENDER', 'sender field is empty') + continue + } + if (msg.timestamp === undefined || msg.timestamp === null) { + trackError(processedCount, 'MISSING_TIMESTAMP', 'timestamp field is missing') + continue + } + const timestamp = normalizeTimestamp(msg.timestamp) + if (timestamp === null) { + trackError(processedCount, 'INVALID_TIMESTAMP', `timestamp value: ${msg.timestamp}`) + continue + } + + if (isDuplicate({ ...msg, timestamp }, existingPlatformMsgIds, existingKeys)) { + duplicateCount++ + continue + } + + let memberId = memberIdMap.get(msg.senderPlatformId) + if (!memberId) { + insertMemberMinimal.run( + msg.senderPlatformId, + msg.senderAccountName || null, + msg.senderGroupNickname || null, + null + ) + const row = getMemberId.get(msg.senderPlatformId) as { id: number } | undefined + if (row) { + memberId = row.id + memberIdMap.set(msg.senderPlatformId, memberId) + membersAdded++ + } + } + if (!memberId) continue + + const msgResult = insertMessage.run( + memberId, + msg.senderAccountName || null, + msg.senderGroupNickname || null, + timestamp, + msg.type, + msg.content || null, + msg.replyToMessageId || null, + msg.platformMessageId || null + ) + + newFtsEntries.push({ + id: Number((msgResult as any).lastInsertRowid ?? 0), + content: msg.content || null, + }) + if (timestamp < minWrittenTs) minWrittenTs = timestamp + newMessageCount++ + } + + if (processedCount % BATCH_SIZE === 0) { + deps.onProgress({ + stage: 'saving', + bytesRead: 0, + totalBytes: 0, + messagesProcessed: processedCount, + percentage: 50, + message: `Processed ${processedCount}, added ${newMessageCount}`, + }) + } + }, + }) + + db.exec('COMMIT') + + if (!metaUpdated) { + db.prepare('UPDATE meta SET imported_at = ?').run(Math.floor(Date.now() / 1000)) + } + + // Incremental FTS update + if (newFtsEntries.length > 0 && hasFtsTable(db)) { + try { + insertFtsEntries(db, newFtsEntries) + } catch { + /* FTS failure is non-fatal */ + } + } + + // Incremental session index (segment / message_context tables). + // Use full rebuild for backfill batches: generateIncrementalSessionIndex + // compares the first new message with the latest existing segment, so an + // older backfilled message would be incorrectly attached to the newest segment. + if (newMessageCount > 0) { + try { + if (minWrittenTs < preWriteMaxTs) { + generateSessionIndex(db) + } else { + generateIncrementalSessionIndex(db) + } + } catch { + /* non-fatal */ + } + } + + const sessionStats = db + .prepare( + `SELECT + COUNT(*) as totalCount, + MIN(ts) as firstTimestamp, + MAX(ts) as lastTimestamp + FROM message` + ) + .get() as { totalCount: number; firstTimestamp: number; lastTimestamp: number } + const memberCountRow = db.prepare('SELECT COUNT(*) as count FROM member').get() as { count: number } + + // Post-import hook (e.g. overview cache) + try { + await deps.postImportHook?.(db, sessionId) + } catch { + /* non-fatal */ + } + + db.close() + + deps.onProgress({ + stage: 'done', + bytesRead: 0, + totalBytes: 0, + messagesProcessed: processedCount, + percentage: 100, + message: `Import complete, added ${newMessageCount} messages`, + }) + + return { + success: true, + newMessageCount, + batch: { + receivedCount: processedCount, + writtenCount: newMessageCount, + duplicateCount, + errorCount, + errorReasonCounts, + errorSample: errorSamples, + }, + session: { + totalCount: sessionStats.totalCount, + memberCount: memberCountRow.count, + firstTimestamp: sessionStats.firstTimestamp, + lastTimestamp: sessionStats.lastTimestamp, + }, + updates: { + metaUpdated, + membersAdded, + membersUpdated, + }, + } + } catch (error) { + try { + db.exec('ROLLBACK') + } catch { + /* ignore */ + } + db.close() + + console.error('[IncrementalImport] Error:', error) + return { success: false, newMessageCount: 0, error: String(error) } + } +} diff --git a/packages/node-runtime/src/import/index.ts b/packages/node-runtime/src/import/index.ts new file mode 100644 index 000000000..a633568a5 --- /dev/null +++ b/packages/node-runtime/src/import/index.ts @@ -0,0 +1,47 @@ +export { writeParseResultToDb } from './write-parse-result' +export type { ImportMeta, WriteParseResultStats } from './write-parse-result' +export { + LogLevel, + initPerfLog, + logPerf, + logPerfDetail, + resetPerfLog, + getCurrentLogFile, + logError, + logInfo, + getErrorCount, + logSummary, +} from './perf-logger' +export { streamingImport, analyzeNewImport, streamParseFileInfo } from './streaming-importer' +export type { + SkipReasons, + ImportDiagnostics, + StreamImportResult, + ImportProgressCallback, + ImportLogger, + StreamImportDeps, + AnalyzeNewImportResult, + StreamParseFileInfoResult, + StreamParseFileInfoDeps, +} from './streaming-importer' +export { analyzeIncrementalImport, incrementalImport } from './incremental-importer' +export type { + ImportOptions, + IncrementalAnalyzeResult, + IncrementalImportResult, + IncrementalImportDeps, +} from './incremental-importer' +export { ZipArchiveReader, validateArchiveEntryName } from './archive/archive-reader' +export { ArchiveImportError } from './archive/errors' +export { GoogleChatTakeoutResolver } from './archive/google-chat-resolver' +export { ArchiveImportSourceManager } from './archive/source-manager' +export type { + ArchiveEntrySummary, + ArchiveEntryStreamOpener, + ArchiveEntryVisitor, + ZipArchiveReaderOptions, + PreparedImportChat, + PreparedImportSource, + MaterializedImport, + ArchiveResolver, +} from './archive/types' diff --git a/electron/main/worker/core/perfLogger.ts b/packages/node-runtime/src/import/perf-logger.ts similarity index 55% rename from electron/main/worker/core/perfLogger.ts rename to packages/node-runtime/src/import/perf-logger.ts index 99cd5123a..8085a01cb 100644 --- a/electron/main/worker/core/perfLogger.ts +++ b/packages/node-runtime/src/import/perf-logger.ts @@ -1,85 +1,65 @@ /** - * 导入日志模块 - * 实时记录导入过程的性能指标、错误和警告信息 + * Import performance logger. + * + * Extracted from electron/main/worker/core/perfLogger.ts. + * Records real-time performance metrics during import operations. + * + * Caller provides the log directory; this module has no path assumptions. */ import * as fs from 'fs' import * as path from 'path' -import { getDbDir } from './dbCore' -// 日志级别 export enum LogLevel { ERROR = 'ERROR', INFO = 'INFO', } -// 状态 let lastLogTime = Date.now() let lastMessageCount = 0 let currentLogFile: string | null = null - -// 统计计数器 let errorCount = 0 -/** - * 获取性能日志目录 - */ -function getLogDir(): string { - const dbDir = getDbDir() - const logDir = path.join(path.dirname(dbDir), 'logs', 'import') - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }) - } - return logDir -} - -/** - * 初始化日志文件(实时写入) - */ -export function initPerfLog(sessionId: string): void { +export function initPerfLog(sessionId: string, logDir: string): void { try { - const logDir = getLogDir() + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }) + } currentLogFile = path.join(logDir, `import_${sessionId}_${Date.now()}.log`) - // 写入头部 - fs.writeFileSync(currentLogFile, `=== 导入日志 ===\n开始时间: ${new Date().toISOString()}\n\n`, 'utf-8') + fs.writeFileSync(currentLogFile, `=== Import Log ===\nStart time: ${new Date().toISOString()}\n\n`, 'utf-8') } catch { - // 忽略初始化失败 + // Ignore initialization failure } } -/** - * 实时记录性能日志(每次追加写入文件) - */ export function logPerf(event: string, messagesProcessed: number, batchSize?: number): void { const now = Date.now() const duration = now - lastLogTime const messagesDelta = messagesProcessed - lastMessageCount const speed = duration > 0 ? Math.round((messagesDelta / duration) * 1000) : 0 - // 获取内存使用 let memory = 0 try { const used = process.memoryUsage() memory = Math.round(used.heapUsed / 1024 / 1024) } catch { - // 忽略 + // Ignore } const logLine = `[${new Date().toISOString()}] ${event} | ` + - `消息: ${messagesProcessed.toLocaleString()} | ` + - `耗时: ${duration}ms | ` + - `速度: ${speed.toLocaleString()}/秒 | ` + - `内存: ${memory}MB` + - (batchSize ? ` | 批次: ${batchSize}` : '') + + `messages: ${messagesProcessed.toLocaleString()} | ` + + `elapsed: ${duration}ms | ` + + `speed: ${speed.toLocaleString()}/s | ` + + `memory: ${memory}MB` + + (batchSize ? ` | batch: ${batchSize}` : '') + '\n' - // 实时写入文件 if (currentLogFile) { try { fs.appendFileSync(currentLogFile, logLine, 'utf-8') } catch { - // 忽略写入失败 + // Ignore write failure } } @@ -87,22 +67,16 @@ export function logPerf(event: string, messagesProcessed: number, batchSize?: nu lastMessageCount = messagesProcessed } -/** - * 追加详细日志(分阶段耗时) - */ export function logPerfDetail(detail: string): void { if (currentLogFile) { try { fs.appendFileSync(currentLogFile, ` ${detail}\n`, 'utf-8') } catch { - // 忽略 + // Ignore } } } -/** - * 重置性能日志状态 - */ export function resetPerfLog(): void { lastLogTime = Date.now() lastMessageCount = 0 @@ -110,18 +84,10 @@ export function resetPerfLog(): void { errorCount = 0 } -/** - * 获取当前日志文件路径 - */ export function getCurrentLogFile(): string | null { return currentLogFile } -// ==================== 通用日志函数 ==================== - -/** - * 写入日志行 - */ function writeLogLine(level: LogLevel, message: string): void { if (!currentLogFile) return @@ -129,52 +95,37 @@ function writeLogLine(level: LogLevel, message: string): void { try { fs.appendFileSync(currentLogFile, logLine, 'utf-8') } catch { - // 忽略写入失败 + // Ignore write failure } } -/** - * 记录错误日志 - * @param message 错误描述 - * @param error 可选的 Error 对象 - */ export function logError(message: string, error?: Error): void { errorCount++ const errorDetail = error ? `: ${error.message}` : '' writeLogLine(LogLevel.ERROR, `${message}${errorDetail}`) } -/** - * 记录信息日志 - * @param message 信息描述 - */ export function logInfo(message: string): void { writeLogLine(LogLevel.INFO, message) } -/** - * 获取错误计数 - */ export function getErrorCount(): number { return errorCount } -/** - * 写入日志摘要(导入完成时调用) - */ export function logSummary(totalMessages: number, totalMembers: number): void { if (!currentLogFile) return const summary = ` -=== 导入摘要 === -结束时间: ${new Date().toISOString()} -总消息数: ${totalMessages.toLocaleString()} -总成员数: ${totalMembers.toLocaleString()} -错误数: ${errorCount} +=== Import Summary === +End time: ${new Date().toISOString()} +Total messages: ${totalMessages.toLocaleString()} +Total members: ${totalMembers.toLocaleString()} +Errors: ${errorCount} ` try { fs.appendFileSync(currentLogFile, summary, 'utf-8') } catch { - // 忽略 + // Ignore } } diff --git a/packages/node-runtime/src/import/streaming-importer.ts b/packages/node-runtime/src/import/streaming-importer.ts new file mode 100644 index 000000000..350bd48a6 --- /dev/null +++ b/packages/node-runtime/src/import/streaming-importer.ts @@ -0,0 +1,889 @@ +/** + * Platform-agnostic streaming importer. + * + * Extracted from electron/main/worker/import/streamImport.ts. + * Streams parsed data directly into SQLite with batched transactions, + * deferred index creation, nickname history tracking, and FTS indexing. + * + * Both Electron and Server/CLI use this module via dependency injection: + * the caller provides a DatabaseAdapter, progress callback, and optional hooks. + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import { CHAT_DB_INDEXES, generateSessionIndex } from '@openchatlab/core' +import type { ParsedMember, ParsedMessage } from '@openchatlab/shared-types' +import { + streamParseFile, + detectFormat, + detectAllFormats, + getFormatFeatureById, + getPreprocessor, + needsPreprocess, + type ParsedMeta, + type FormatFeature, + type ParseProgress, +} from '@openchatlab/parser' +import * as fs from 'fs' +import { buildFtsIndex } from '../fts' + +// ==================== Public interfaces ==================== + +export interface SkipReasons { + noSenderId: number + noAccountName: number + invalidTimestamp: number + noType: number +} + +export interface ImportDiagnostics { + logFile: string | null + detectedFormat: string | null + messagesReceived: number + messagesWritten: number + messagesSkipped: number + skipReasons: SkipReasons +} + +export interface StreamImportResult { + success: boolean + sessionId?: string + error?: string + diagnostics?: ImportDiagnostics +} + +export type ImportProgressCallback = (progress: ParseProgress) => void + +export interface ImportLogger { + info(message: string): void + error(message: string, err?: Error): void + perf(label: string, messageCount: number, batchSize?: number): void + perfDetail(detail: string): void + summary(messageCount: number, memberCount: number): void + reset(): void + init(sessionId: string): void + getCurrentLogFile(): string | null +} + +export interface StreamImportDeps { + /** Open a new database for writing (tables only, no indexes). */ + openDatabase(sessionId: string): DatabaseAdapter + /** Delete a database file (and WAL/SHM) on failure. */ + deleteDatabase(sessionId: string): void + /** Progress callback (IPC postMessage, SSE event, etc.) */ + onProgress: ImportProgressCallback + /** Optional perf/diagnostic logger */ + logger?: ImportLogger + /** Optional hook after import completes (e.g. write overview cache) */ + postImportHook?: (db: DatabaseAdapter, sessionId: string) => void | Promise + /** Generate a session ID. Defaults to timestamp + random. */ + generateSessionId?: () => string +} + +// ==================== Core streaming import ==================== + +const BATCH_COMMIT_SIZE = 50000 +const CHECKPOINT_INTERVAL = 200000 + +function defaultGenerateSessionId(): string { + const ts = Date.now() + const rand = Math.random().toString(36).substring(2, 8) + return `chat_${ts}_${rand}` +} + +/** + * High-performance streaming import: parse a file and write to DB + * with batched transactions. Supports format auto-detection with fallback. + */ +export async function streamingImport( + filePath: string, + deps: StreamImportDeps, + formatOptions?: Record, + externalSessionId?: string +): Promise { + if (formatOptions?.formatId) { + const formatId = formatOptions.formatId as string + const feature = getFormatFeatureById(formatId) + if (!feature) { + return { success: false, error: 'error.unknown_format_id' } + } + return streamImportSingle(filePath, deps, feature, formatOptions, externalSessionId) + } + + const candidates = detectAllFormats(filePath) + if (candidates.length === 0) { + return { success: false, error: 'error.unrecognized_format' } + } + + if (candidates.length > 1) { + return streamImportWithFallback(filePath, deps, candidates, formatOptions, externalSessionId) + } + + return streamImportSingle(filePath, deps, candidates[0], formatOptions, externalSessionId) +} + +async function streamImportWithFallback( + filePath: string, + deps: StreamImportDeps, + candidates: FormatFeature[], + formatOptions?: Record, + externalSessionId?: string +): Promise { + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i] + deps.logger?.info(`[StreamImport] Trying format ${i + 1}/${candidates.length}: ${candidate.name} (${candidate.id})`) + + const result = await streamImportSingle(filePath, deps, candidate, formatOptions, externalSessionId) + + if (result.success) { + if (i > 0) { + deps.logger?.info( + `[StreamImport] Fallback succeeded: ${candidate.name} (after ${i} failed attempt${i > 1 ? 's' : ''})` + ) + } + return result + } + + if (i === candidates.length - 1) return result + + deps.logger?.info(`[StreamImport] Format ${candidate.name} produced 0 messages, falling back to next candidate...`) + } + + return { success: false, error: 'error.no_messages' } +} + +async function streamImportSingle( + filePath: string, + deps: StreamImportDeps, + formatFeature: FormatFeature, + formatOptions?: Record, + externalSessionId?: string +): Promise { + const { onProgress, logger } = deps + const genId = deps.generateSessionId ?? defaultGenerateSessionId + + logger?.reset() + const sessionId = externalSessionId || genId() + logger?.init(sessionId) + + logger?.info(`File path: ${filePath}`) + logger?.info(`Detected format: ${formatFeature.name} (${formatFeature.id})`) + logger?.info(`Platform: ${formatFeature.platform}`) + logger?.perf('Import started', 0) + + // Preprocess large files if needed + let actualFilePath = filePath + let tempFilePath: string | null = null + const preprocessor = getPreprocessor(filePath) + + if (preprocessor && needsPreprocess(filePath)) { + logger?.info('File needs preprocessing, simplifying large file...') + onProgress({ + stage: 'parsing', + bytesRead: 0, + totalBytes: 0, + messagesProcessed: 0, + percentage: 0, + message: '', + }) + + try { + tempFilePath = await preprocessor.preprocess(filePath, (progress: ParseProgress) => { + onProgress({ ...progress, message: '' }) + }) + actualFilePath = tempFilePath + logger?.info(`Preprocessing done, temp file: ${tempFilePath}`) + } catch (err) { + const errorMsg = `Preprocessing failed: ${err instanceof Error ? err.message : String(err)}` + logger?.error(errorMsg, err instanceof Error ? err : undefined) + return { success: false, error: errorMsg } + } + } + + const db = deps.openDatabase(sessionId) + + const insertMeta = db.prepare( + `INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + const insertMember = db.prepare( + `INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar, roles) VALUES (?, ?, ?, ?, ?)` + ) + const getMemberId = db.prepare(`SELECT id FROM member WHERE platform_id = ?`) + const insertMessage = db.prepare( + `INSERT INTO message (sender_id, sender_account_name, sender_group_nickname, ts, type, content, reply_to_message_id, platform_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + const insertNameHistory = db.prepare( + `INSERT INTO member_name_history (member_id, name_type, name, start_ts, end_ts) VALUES (?, ?, ?, ?, ?)` + ) + const updateMemberAccountName = db.prepare(`UPDATE member SET account_name = ? WHERE platform_id = ?`) + const updateMemberGroupNickname = db.prepare(`UPDATE member SET group_nickname = ? WHERE platform_id = ?`) + + const memberIdMap = new Map() + const accountNameTracker = new Map< + string, + { currentName: string; lastSeenTs: number; history: Array<{ name: string; startTs: number }> } + >() + const groupNicknameTracker = new Map< + string, + { currentName: string; lastSeenTs: number; history: Array<{ name: string; startTs: number }> } + >() + + let metaInserted = false + let messageCountInBatch = 0 + let totalMessageCount = 0 + let lastCheckpointCount = 0 + let inTransaction = false + + const beginTransaction = () => { + if (!inTransaction) { + db.exec('BEGIN TRANSACTION') + inTransaction = true + } + } + + const doCheckpoint = () => { + try { + db.pragma('wal_checkpoint(TRUNCATE)') + } catch { + /* ignore */ + } + } + + const commitAndBeginNew = () => { + if (inTransaction) { + db.exec('COMMIT') + inTransaction = false + logger?.perf('Commit transaction', totalMessageCount, BATCH_COMMIT_SIZE) + + if (totalMessageCount - lastCheckpointCount >= CHECKPOINT_INTERVAL) { + doCheckpoint() + logger?.perf('WAL checkpoint', totalMessageCount) + lastCheckpointCount = totalMessageCount + } + + onProgress({ + stage: 'importing', + bytesRead: 0, + totalBytes: 0, + messagesProcessed: totalMessageCount, + percentage: 100, + message: '', + }) + } + beginTransaction() + } + + beginTransaction() + + let shouldDeleteDb = false + let importError: string | null = null + + const callbackStats = { + onProgressCalls: 0, + onLogCalls: 0, + onMetaCalls: 0, + onMembersCalls: 0, + onMessageBatchCalls: 0, + totalMembersReceived: 0, + totalMessagesReceived: 0, + skippedNoSenderId: 0, + skippedNoAccountName: 0, + skippedInvalidTimestamp: 0, + skippedNoType: 0, + } + + logger?.info('Starting streamParseFile...') + + try { + await streamParseFile( + actualFilePath, + { + batchSize: 5000, + formatOptions, + + onProgress: (progress: ParseProgress) => { + callbackStats.onProgressCalls++ + onProgress(progress) + }, + + onLog: (level: string, message: string) => { + callbackStats.onLogCalls++ + if (level === 'error') { + logger?.error(message) + } else { + logger?.info(message) + } + }, + + onMeta: (meta: ParsedMeta) => { + callbackStats.onMetaCalls++ + if (!metaInserted) { + logger?.info(`Writing meta: name=${meta.name}, type=${meta.type}, platform=${meta.platform}`) + insertMeta.run( + meta.name, + meta.platform, + meta.type, + Math.floor(Date.now() / 1000), + meta.groupId || null, + meta.groupAvatar || null, + meta.ownerId || null + ) + metaInserted = true + } + }, + + onMembers: (members: ParsedMember[]) => { + callbackStats.onMembersCalls++ + callbackStats.totalMembersReceived += members.length + logger?.info(`Received member batch: ${members.length} members`) + for (const member of members) { + insertMember.run( + member.platformId, + member.accountName || null, + member.groupNickname || null, + member.avatar || null, + member.roles ? JSON.stringify(member.roles) : '[]' + ) + const row = getMemberId.get(member.platformId) as { id: number } | undefined + if (row) memberIdMap.set(member.platformId, row.id) + } + }, + + onMessageBatch: (messages: ParsedMessage[]) => { + callbackStats.onMessageBatchCalls++ + callbackStats.totalMessagesReceived += messages.length + if (callbackStats.onMessageBatchCalls <= 3 || callbackStats.onMessageBatchCalls % 10 === 0) { + logger?.info(`Received message batch #${callbackStats.onMessageBatchCalls}: ${messages.length} messages`) + } + + let memberLookupTime = 0 + let memberInsertTime = 0 + let messageInsertTime = 0 + let nicknameTrackTime = 0 + let memberLookupCount = 0 + let memberInsertCount = 0 + let nicknameChangeCount = 0 + + for (const msg of messages) { + if (!msg.senderPlatformId) { + callbackStats.skippedNoSenderId++ + continue + } + if (!msg.senderAccountName) { + callbackStats.skippedNoAccountName++ + continue + } + if (msg.timestamp === undefined || msg.timestamp === null || isNaN(msg.timestamp)) { + callbackStats.skippedInvalidTimestamp++ + continue + } + if (msg.type === undefined || msg.type === null) { + callbackStats.skippedNoType++ + continue + } + + let t0 = Date.now() + if (!memberIdMap.has(msg.senderPlatformId)) { + insertMember.run( + msg.senderPlatformId, + msg.senderAccountName || null, + msg.senderGroupNickname || null, + null, + '[]' + ) + const row = getMemberId.get(msg.senderPlatformId) as { id: number } | undefined + if (row) memberIdMap.set(msg.senderPlatformId, row.id) + memberInsertCount++ + memberInsertTime += Date.now() - t0 + } else { + memberLookupCount++ + memberLookupTime += Date.now() - t0 + } + + const senderId = memberIdMap.get(msg.senderPlatformId) + if (senderId === undefined) continue + + let safeContent: string | null = null + if (msg.content != null) { + safeContent = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + } + + t0 = Date.now() + insertMessage.run( + senderId, + msg.senderAccountName || null, + msg.senderGroupNickname || null, + msg.timestamp, + msg.type, + safeContent, + msg.replyToMessageId || null, + msg.platformMessageId || null + ) + messageInsertTime += Date.now() - t0 + messageCountInBatch++ + totalMessageCount++ + + t0 = Date.now() + trackNickname(accountNameTracker, msg.senderPlatformId, msg.senderAccountName, msg.timestamp) + trackNickname(groupNicknameTracker, msg.senderPlatformId, msg.senderGroupNickname, msg.timestamp) + nicknameTrackTime += Date.now() - t0 + // nicknameChangeCount is approximate but sufficient for logging + nicknameChangeCount += accountNameTracker.get(msg.senderPlatformId)?.history.length === 1 ? 0 : 0 + + if (messageCountInBatch >= BATCH_COMMIT_SIZE) { + const detail = + `[Detail] Member lookup: ${memberLookupTime}ms (${memberLookupCount} times) | ` + + `Member insert: ${memberInsertTime}ms (${memberInsertCount} times) | ` + + `Message insert: ${messageInsertTime}ms | ` + + `Nickname tracking: ${nicknameTrackTime}ms (${nicknameChangeCount} changes)` + logger?.perfDetail(detail) + commitAndBeginNew() + messageCountInBatch = 0 + memberLookupTime = 0 + memberInsertTime = 0 + messageInsertTime = 0 + nicknameTrackTime = 0 + memberLookupCount = 0 + memberInsertCount = 0 + nicknameChangeCount = 0 + } + } + }, + }, + formatFeature.id + ) + + if (inTransaction) { + db.exec('COMMIT') + inTransaction = false + } + + // Flush nickname history in batch + onProgress({ + stage: 'importing', + bytesRead: 0, + totalBytes: 0, + messagesProcessed: totalMessageCount, + percentage: 100, + message: '', + }) + logger?.perf('Writing nickname history', totalMessageCount) + + db.exec('BEGIN TRANSACTION') + let historyCount = 0 + + flushNicknameHistory(accountNameTracker, 'account_name', memberIdMap, insertNameHistory, updateMemberAccountName) + flushNicknameHistory( + groupNicknameTracker, + 'group_nickname', + memberIdMap, + insertNameHistory, + updateMemberGroupNickname + ) + historyCount = countHistory(accountNameTracker) + countHistory(groupNicknameTracker) + + db.exec('COMMIT') + logger?.perf(`Nickname history written (${historyCount} entries)`, totalMessageCount) + + // Create indexes (deferred for performance) + onProgress({ + stage: 'importing', + bytesRead: 0, + totalBytes: 0, + messagesProcessed: totalMessageCount, + percentage: 100, + message: '', + }) + logger?.perf('Creating indexes', totalMessageCount) + db.exec(CHAT_DB_INDEXES) + logger?.perf('Indexes created', totalMessageCount) + + // Build FTS index + try { + buildFtsIndex(db) + logger?.perf('FTS index built', totalMessageCount) + } catch (ftsError) { + logger?.error('FTS index build failed (non-fatal)', ftsError instanceof Error ? ftsError : undefined) + } + + // Final WAL checkpoint + onProgress({ + stage: 'importing', + bytesRead: 0, + totalBytes: 0, + messagesProcessed: totalMessageCount, + percentage: 100, + message: '', + }) + doCheckpoint() + logger?.perf('WAL checkpoint done', totalMessageCount) + + // Build session index (segment / message_context tables) + try { + generateSessionIndex(db) + logger?.perf('Session index built', totalMessageCount) + } catch { + /* non-fatal */ + } + + // Post-import hook (e.g. overview cache) + try { + await deps.postImportHook?.(db, sessionId) + if (deps.postImportHook) logger?.perf('Post-import hook done', totalMessageCount) + } catch { + /* non-fatal */ + } + + logger?.perf('Import completed', totalMessageCount) + + // Diagnostic logging + logger?.info(`=== Parser Callback Stats ===`) + logger?.info(`onProgress calls: ${callbackStats.onProgressCalls}`) + logger?.info(`onLog calls: ${callbackStats.onLogCalls}`) + logger?.info(`onMeta calls: ${callbackStats.onMetaCalls}`) + logger?.info( + `onMembers calls: ${callbackStats.onMembersCalls}, total members: ${callbackStats.totalMembersReceived}` + ) + logger?.info( + `onMessageBatch calls: ${callbackStats.onMessageBatchCalls}, total messages: ${callbackStats.totalMessagesReceived}` + ) + if ( + callbackStats.skippedNoSenderId > 0 || + callbackStats.skippedNoAccountName > 0 || + callbackStats.skippedInvalidTimestamp > 0 || + callbackStats.skippedNoType > 0 + ) { + logger?.info(`=== Skipped Messages Stats ===`) + if (callbackStats.skippedNoSenderId > 0) + logger?.info(` missing senderPlatformId: ${callbackStats.skippedNoSenderId}`) + if (callbackStats.skippedNoAccountName > 0) + logger?.info(` missing senderAccountName: ${callbackStats.skippedNoAccountName}`) + if (callbackStats.skippedInvalidTimestamp > 0) + logger?.info(` invalid timestamp: ${callbackStats.skippedInvalidTimestamp}`) + if (callbackStats.skippedNoType > 0) logger?.info(` missing type: ${callbackStats.skippedNoType}`) + } + + logger?.summary(totalMessageCount, memberIdMap.size) + + if (totalMessageCount === 0) { + logger?.error( + `Import failed: no messages parsed (received ${callbackStats.totalMessagesReceived} messages, all skipped or none received)` + ) + shouldDeleteDb = true + importError = 'error.no_messages' + } + } catch (error) { + logger?.error('Import failed', error instanceof Error ? error : undefined) + if (inTransaction) { + try { + db.exec('ROLLBACK') + } catch { + /* ignore */ + } + } + shouldDeleteDb = true + importError = error instanceof Error ? error.message : String(error) + } finally { + db.close() + + if (tempFilePath && preprocessor) { + preprocessor.cleanup(tempFilePath) + } + + if (shouldDeleteDb) { + deps.deleteDatabase(sessionId) + } + } + + const diagnostics: ImportDiagnostics = { + logFile: logger?.getCurrentLogFile() ?? null, + detectedFormat: formatFeature ? `${formatFeature.name} (${formatFeature.id})` : null, + messagesReceived: callbackStats.totalMessagesReceived, + messagesWritten: totalMessageCount, + messagesSkipped: + callbackStats.skippedNoSenderId + + callbackStats.skippedNoAccountName + + callbackStats.skippedInvalidTimestamp + + callbackStats.skippedNoType, + skipReasons: { + noSenderId: callbackStats.skippedNoSenderId, + noAccountName: callbackStats.skippedNoAccountName, + invalidTimestamp: callbackStats.skippedInvalidTimestamp, + noType: callbackStats.skippedNoType, + }, + } + + if (importError) { + return { success: false, error: importError, diagnostics } + } + return { success: true, sessionId, diagnostics } +} + +// ==================== Dry-run analysis ==================== + +export interface AnalyzeNewImportResult { + totalMessages: number + totalMembers: number + meta: { name: string; platform: string; type: string } | null + error?: string +} + +export async function analyzeNewImport( + filePath: string, + onProgress: ImportProgressCallback +): Promise { + const formatFeature = detectFormat(filePath) + if (!formatFeature) { + return { totalMessages: 0, totalMembers: 0, meta: null, error: 'error.unrecognized_format' } + } + + let meta: { name: string; platform: string; type: string } | null = null + const memberSet = new Set() + let totalMessages = 0 + + await streamParseFile(filePath, { + onMeta: (parsedMeta: ParsedMeta) => { + meta = { name: parsedMeta.name, platform: parsedMeta.platform, type: parsedMeta.type } + }, + onMembers: (members: ParsedMember[]) => { + for (const m of members) memberSet.add(m.platformId) + }, + onProgress: (progress: ParseProgress) => { + onProgress(progress) + }, + onMessageBatch: (batch: ParsedMessage[]) => { + for (const msg of batch) { + totalMessages++ + if (!memberSet.has(msg.senderPlatformId)) memberSet.add(msg.senderPlatformId) + } + }, + }) + + return { totalMessages, totalMembers: memberSet.size, meta } +} + +// ==================== Temp DB for merge preview ==================== + +export interface StreamParseFileInfoResult { + name: string + format: string + platform: string + messageCount: number + memberCount: number + fileSize: number + tempDbPath: string +} + +export interface StreamParseFileInfoDeps { + createTempDatabase(filePath: string): { db: DatabaseAdapter; tempDbPath: string } + onProgress: ImportProgressCallback +} + +export async function streamParseFileInfo( + filePath: string, + deps: StreamParseFileInfoDeps +): Promise { + const formatFeature = detectFormat(filePath) + if (!formatFeature) { + throw new Error('Unrecognized file format') + } + + const fileSize = fs.statSync(filePath).size + + deps.onProgress({ + stage: 'parsing', + bytesRead: 0, + totalBytes: fileSize, + messagesProcessed: 0, + percentage: 0, + message: '', + }) + + const { db, tempDbPath } = deps.createTempDatabase(filePath) + + const insertMeta = db.prepare( + 'INSERT INTO meta (name, platform, type, group_id, group_avatar, owner_id) VALUES (?, ?, ?, ?, ?, ?)' + ) + const insertMember = db.prepare( + 'INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)' + ) + const insertMessage = db.prepare( + `INSERT INTO message (sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content) + VALUES (?, ?, ?, ?, ?, ?)` + ) + + let meta: ParsedMeta = { name: 'Unknown', platform: formatFeature.platform, type: 0 as any } + const memberSet = new Set() + let messageCount = 0 + let metaInserted = false + + db.exec('BEGIN TRANSACTION') + + try { + await streamParseFile(filePath, { + batchSize: fileSize > 100 * 1024 * 1024 ? 2000 : 5000, + + onProgress: (progress: ParseProgress) => { + deps.onProgress(progress) + }, + + onMeta: (parsedMeta: ParsedMeta) => { + meta = parsedMeta + if (!metaInserted) { + insertMeta.run( + parsedMeta.name, + parsedMeta.platform, + parsedMeta.type, + parsedMeta.groupId || null, + parsedMeta.groupAvatar || null, + parsedMeta.ownerId || null + ) + metaInserted = true + } + }, + + onMembers: (parsedMembers: ParsedMember[]) => { + for (const m of parsedMembers) { + if (!memberSet.has(m.platformId)) { + memberSet.add(m.platformId) + insertMember.run(m.platformId, m.accountName || null, m.groupNickname || null, m.avatar || null) + } + } + }, + + onMessageBatch: (batch: ParsedMessage[]) => { + for (const msg of batch) { + if (!memberSet.has(msg.senderPlatformId)) { + memberSet.add(msg.senderPlatformId) + insertMember.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null) + } + + insertMessage.run( + msg.senderPlatformId, + msg.senderAccountName || null, + msg.senderGroupNickname || null, + msg.timestamp, + msg.type, + msg.content || null + ) + messageCount++ + } + }, + }) + + db.exec('COMMIT') + db.close() + + return { + name: meta.name, + format: formatFeature.name, + platform: meta.platform, + messageCount, + memberCount: memberSet.size, + fileSize, + tempDbPath, + } + } catch (error) { + try { + db.exec('ROLLBACK') + } catch { + /* ignore */ + } + db.close() + + try { + if (fs.existsSync(tempDbPath)) fs.unlinkSync(tempDbPath) + } catch { + /* ignore */ + } + + throw error + } +} + +// ==================== Internal helpers ==================== + +type NicknameTracker = Map< + string, + { currentName: string; lastSeenTs: number; history: Array<{ name: string; startTs: number }> } +> + +function trackNickname( + tracker: NicknameTracker, + platformId: string, + name: string | undefined | null, + timestamp: number +): void { + if (!name) return + // For account_name tracking, skip if name equals platformId + const existing = tracker.get(platformId) + if (!existing) { + tracker.set(platformId, { + currentName: name, + lastSeenTs: timestamp, + history: [{ name, startTs: timestamp }], + }) + } else if (existing.currentName !== name) { + existing.history.push({ name, startTs: timestamp }) + existing.currentName = name + existing.lastSeenTs = timestamp + } else { + existing.lastSeenTs = timestamp + } +} + +interface PreparedStatement { + run(...args: unknown[]): unknown +} + +function flushNicknameHistory( + tracker: NicknameTracker, + nameType: string, + memberIdMap: Map, + insertNameHistory: PreparedStatement, + updateMemberName: PreparedStatement +): void { + for (const [platformId, data] of tracker.entries()) { + if (!platformId || platformId === '0' || platformId === 'undefined') continue + + const senderId = memberIdMap.get(platformId) + if (!senderId) continue + + const uniqueNames = new Map() + for (const h of data.history) { + const existing = uniqueNames.get(h.name) + if (!existing) { + uniqueNames.set(h.name, { startTs: h.startTs, lastTs: h.startTs }) + } else { + existing.lastTs = h.startTs + } + } + + // For account_name, skip the platformId itself + if (nameType === 'account_name') { + uniqueNames.delete(platformId) + } + + if (uniqueNames.size <= 1) { + updateMemberName.run(data.currentName, platformId) + continue + } + + const sortedHistory = Array.from(uniqueNames.entries()).sort((a, b) => a[1].startTs - b[1].startTs) + for (let i = 0; i < sortedHistory.length; i++) { + const [name, { startTs }] = sortedHistory[i] + const endTs = i < sortedHistory.length - 1 ? sortedHistory[i + 1][1].startTs : null + insertNameHistory.run(senderId, nameType, name, startTs, endTs) + } + + updateMemberName.run(data.currentName, platformId) + } +} + +function countHistory(tracker: NicknameTracker): number { + let count = 0 + for (const [, data] of tracker.entries()) { + if (data.history.length > 1) count += data.history.length + } + return count +} diff --git a/packages/node-runtime/src/import/write-parse-result.ts b/packages/node-runtime/src/import/write-parse-result.ts new file mode 100644 index 000000000..41ed1a486 --- /dev/null +++ b/packages/node-runtime/src/import/write-parse-result.ts @@ -0,0 +1,168 @@ +/** + * Shared data writing logic for importing parsed chat data into SQLite. + * + * Extracted from electron/main/database/core.ts importData(). + * Used by both Electron and Server import pipelines. + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import type { ParsedMember, ParsedMessage } from '@openchatlab/shared-types' + +export interface ImportMeta { + name: string + platform: string + type: string + groupId?: string | null + groupAvatar?: string | null + ownerId?: string | null +} + +export interface WriteParseResultStats { + messageCount: number + memberCount: number + skippedCount: number +} + +/** + * Write parsed chat data into a database that already has the schema initialized. + * + * Handles: + * - Meta record insertion (with schema_version and imported_at) + * - Member insertion with dedup (INSERT OR IGNORE) + * - Messages sorted by timestamp, inserted with sender_id FK + * - member_name_history tracking (account_name & group_nickname changes over time) + * - Final member name update to latest seen values + * + * @param db - Database with CHAT_DB_SCHEMA already applied + * @param meta - Chat session metadata + * @param members - Parsed member list + * @param messages - Parsed message list (will be sorted by timestamp internally) + */ +export function writeParseResultToDb( + db: DatabaseAdapter, + meta: ImportMeta, + members: readonly ParsedMember[], + messages: readonly ParsedMessage[] +): WriteParseResultStats { + let messageCount = 0 + let skippedCount = 0 + + db.transaction(() => { + db.prepare( + `INSERT INTO meta (name, platform, type, imported_at, group_id, group_avatar, owner_id) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + meta.name, + meta.platform, + meta.type, + Math.floor(Date.now() / 1000), + meta.groupId || null, + meta.groupAvatar || null, + meta.ownerId || null + ) + + const insertMember = db.prepare( + `INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar, roles) VALUES (?, ?, ?, ?, ?)` + ) + const getMemberId = db.prepare(`SELECT id FROM member WHERE platform_id = ?`) + + const memberIdMap = new Map() + + for (const member of members) { + insertMember.run( + member.platformId, + member.accountName || null, + member.groupNickname || null, + member.avatar || null, + member.roles ? JSON.stringify(member.roles) : '[]' + ) + const row = getMemberId.get(member.platformId) as { id: number } + memberIdMap.set(member.platformId, row.id) + } + + const sortedMessages = [...messages].sort((a, b) => a.timestamp - b.timestamp) + + const accountNameTracker = new Map() + const groupNicknameTracker = new Map() + + const insertMessage = db.prepare( + `INSERT INTO message (sender_id, sender_account_name, sender_group_nickname, ts, type, content, reply_to_message_id, platform_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + const insertNameHistory = db.prepare( + `INSERT INTO member_name_history (member_id, name_type, name, start_ts, end_ts) VALUES (?, ?, ?, ?, ?)` + ) + const updateMemberAccountName = db.prepare(`UPDATE member SET account_name = ? WHERE platform_id = ?`) + const updateMemberGroupNickname = db.prepare(`UPDATE member SET group_nickname = ? WHERE platform_id = ?`) + const updateNameHistoryEndTs = db.prepare( + `UPDATE member_name_history SET end_ts = ? WHERE member_id = ? AND name_type = ? AND end_ts IS NULL` + ) + + for (const msg of sortedMessages) { + const senderId = memberIdMap.get(msg.senderPlatformId) + if (senderId === undefined) { + skippedCount++ + continue + } + + insertMessage.run( + senderId, + msg.senderAccountName || null, + msg.senderGroupNickname || null, + msg.timestamp, + msg.type, + msg.content, + msg.replyToMessageId || null, + msg.platformMessageId || null + ) + messageCount++ + + const accountName = msg.senderAccountName + if (accountName) { + const tracker = accountNameTracker.get(msg.senderPlatformId) + if (!tracker) { + accountNameTracker.set(msg.senderPlatformId, { + currentName: accountName, + lastSeenTs: msg.timestamp, + }) + insertNameHistory.run(senderId, 'account_name', accountName, msg.timestamp, null) + } else if (tracker.currentName !== accountName) { + updateNameHistoryEndTs.run(msg.timestamp, senderId, 'account_name') + insertNameHistory.run(senderId, 'account_name', accountName, msg.timestamp, null) + tracker.currentName = accountName + tracker.lastSeenTs = msg.timestamp + } else { + tracker.lastSeenTs = msg.timestamp + } + } + + const groupNickname = msg.senderGroupNickname + if (groupNickname) { + const tracker = groupNicknameTracker.get(msg.senderPlatformId) + if (!tracker) { + groupNicknameTracker.set(msg.senderPlatformId, { + currentName: groupNickname, + lastSeenTs: msg.timestamp, + }) + insertNameHistory.run(senderId, 'group_nickname', groupNickname, msg.timestamp, null) + } else if (tracker.currentName !== groupNickname) { + updateNameHistoryEndTs.run(msg.timestamp, senderId, 'group_nickname') + insertNameHistory.run(senderId, 'group_nickname', groupNickname, msg.timestamp, null) + tracker.currentName = groupNickname + tracker.lastSeenTs = msg.timestamp + } else { + tracker.lastSeenTs = msg.timestamp + } + } + } + + for (const [platformId, tracker] of accountNameTracker.entries()) { + updateMemberAccountName.run(tracker.currentName, platformId) + } + for (const [platformId, tracker] of groupNicknameTracker.entries()) { + updateMemberGroupNickname.run(tracker.currentName, platformId) + } + }) + + return { messageCount, memberCount: members.length, skippedCount } +} diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts new file mode 100644 index 000000000..79f4432da --- /dev/null +++ b/packages/node-runtime/src/index.ts @@ -0,0 +1,427 @@ +/** + * @openchatlab/node-runtime + * + * Node.js 运行时适配器,提供 better-sqlite3 数据库适配器、 + * 路径管理、数据库连接管理等平台特定实现。 + */ + +export { BetterSqliteAdapter, openBetterSqliteDatabase } from './better-sqlite3-adapter' + +// Import data writing + perf logging + streaming importer + incremental importer +export { + writeParseResultToDb, + streamingImport, + analyzeNewImport, + streamParseFileInfo, + analyzeIncrementalImport, + incrementalImport, + ZipArchiveReader, + validateArchiveEntryName, + ArchiveImportError, + GoogleChatTakeoutResolver, + ArchiveImportSourceManager, +} from './import' +export type { + ImportMeta, + WriteParseResultStats, + SkipReasons, + ImportDiagnostics, + StreamImportResult, + ImportProgressCallback, + ImportLogger, + StreamImportDeps, + AnalyzeNewImportResult, + StreamParseFileInfoResult, + StreamParseFileInfoDeps, + ImportOptions, + IncrementalAnalyzeResult, + IncrementalImportResult, + IncrementalImportDeps, + ArchiveEntrySummary, + ArchiveEntryStreamOpener, + ArchiveEntryVisitor, + ZipArchiveReaderOptions, + PreparedImportChat, + PreparedImportSource, + MaterializedImport, + ArchiveResolver, +} from './import' +export { + LogLevel, + initPerfLog, + logPerf, + logPerfDetail, + resetPerfLog, + getCurrentLogFile, + logError, + logInfo, + getErrorCount, + logSummary, +} from './import' + +// FTS5 full-text search operations +export { hasFtsTable, createFtsTable, buildFtsIndex, rebuildFtsIndex, insertFtsEntries, searchByFts } from './fts' + +// AI Logger & Error formatting +export { AiLogger, extractErrorInfo, extractErrorStack, formatAIError } from './ai' +export type { FormatAIErrorOptions } from './ai' + +// Unified application logger (general + key-path + crash logs) +export { initAppLogger, appLogger } from './logging/app-logger' +export { + NodePathProvider, + applyPendingNodeDataDirMigrationIfNeeded, + getDefaultNodeUserDataDir, + getSystemLogsDir, + hasPendingElectronDataWarning, +} from './node-path-provider' +export { DatabaseManager } from './database-manager' +export { createJiebaNlpProvider } from './jieba-nlp-provider' +export { + applyPendingNodeDataDirMigration, + clearPendingNodeDataDirMigration, + copyDirMerge as copyDataDirMerge, + createNodeDataDirSwitch, + createPendingDataDirMigration, + getPendingNodeDataDirMigration, + isDirectoryEmptyOrMissing, + isExistingUserDataDir, + isUserDataDirSafeToUse, + runPendingDataDirMigration, +} from './data-dir-switch' +export type { + ApplyPendingNodeDataDirMigrationDeps, + CopyStats as DataDirCopyStats, + DataDirSwitchResult, + PendingDataDirMigration, + RunPendingDataDirMigrationDeps, + RunPendingDataDirMigrationResult, +} from './data-dir-switch' +export { + assertDataDirCompatible, + DataDirCompatibilityError, + raiseDataDirMinRuntimeVersion, + readDataDirCompatibilityMeta, +} from './data-dir-compat' +export { raiseChatDbCompatibilityGate } from './migrations/chat-db-migrations' +export type { + AssertDataDirCompatibilityOptions, + DataDirCompatibilityMeta, + RaiseDataDirCompatibilityInput, + RuntimeIdentity, + RuntimeKind, +} from './data-dir-compat' + +// NLP 分词引擎、词频统计、词库管理 +export { + initNlpDir, + getNlpDir, + getJieba, + clearJiebaInstance, + segment, + batchSegmentWithFrequency, + collectPosTagStats, + getPosTagDefinitions, + computeWordFrequency, + segmentText, + isDictDownloaded, + getDictList, + loadDictBuffer, + downloadDict, + deleteDict, + ensureDefaultDict, + tokenizeForFts, + tokenizeQueryForFts, +} from './nlp' + +// AI 助手/技能解析器 + 对话管理 +export type { AssistantConfig, AssistantSummary, SkillDef, SkillSummary } from './ai' +export { parseAssistantFile, serializeAssistant, parseSkillFile, extractSkillId } from './ai' +export { AIChatManager } from './ai' +export { countTokens, countMessagesTokens, initTokenizer } from './ai' + +// Assistant Manager +export { AssistantManager } from './ai' +export type { + AssistantInitResult, + AssistantSaveResult, + BuiltinAssistantInfo, + AssistantManagerFs, + AssistantManagerDeps, +} from './ai' + +// Compression +export type { CompressionConfig, CompressionResult, CompressionLogger, CompressionLlmAdapter } from './ai' +export { checkAndCompress, manualCompress, createCompressionLlmAdapter } from './ai' +export type { CreateCompressionLlmAdapterOptions } from './ai' + +// SkillManager +export { SkillManager } from './ai' +export { + CHART_CAPABILITY_ANALYSIS_TOOLS, + CHART_CAPABILITY_CORE_TOOLS, + CHART_CAPABILITY_SKILL_ID, + buildSkillMenuWithBuiltinChart, + getAllowedBuiltinToolsForChartAutoSkill, + getChartCapabilityAllowedBuiltinTools, + getChartCapabilitySkill, + getChartPlannerCapabilityForMessage, + getBuiltinChartSkill, + getSkillConfigWithBuiltinChart, + resolveChartRuntimeForRequest, + shouldOfferChartCapabilityForAnalyticalMessage, + shouldUseChartCapabilityForMessage, + CHART_SCHEMA_REQUIRED_MESSAGE, + createChartSchemaGateState, + wrapWithChartSchemaGate, +} from './ai' +export type { ChartSchemaGateState } from './ai' + +// SkillManagerCore +export { SkillManagerCore } from './ai' +export type { + SkillInitResult, + SkillManagerSaveResult, + BuiltinSkillInfo, + SkillManagerFs, + SkillManagerCoreDeps, +} from './ai' +export type { SkillManagerLogger, ActivateSkillToolOptions, ActivateSkillTool, ActivateSkillToolResult } from './ai' +export { createActivateSkillTool } from './ai' + +// Preprocessor +export type { + PreprocessConfig, + PreprocessableMessage, + DesensitizeRule, + DesensitizeRuleGroup, + TruncationStrategy, + PreprocessLogger, +} from './ai' +export { + preprocessMessages, + BUILTIN_DESENSITIZE_RULES, + DESENSITIZE_RULES_SCHEMA_VERSION, + applyDesensitizeRuleOverrides, + getDefaultRulesForLocale, + getRuleGroupsForLocale, + mergeRulesForLocale, + formatMessageCompact, + formatTimeRange, + formatToolResultAsText, + anonymizeMessageNames, + truncateFormattedMessages, + isChineseLocale, + i18nTexts, + t, + applyPreprocessingPipeline, +} from './ai' +export type { PreprocessingPipelineOptions, PreprocessingPipelineResult } from './ai' + +export type { AIChat, AIMessage, AIMessageRole, ContentBlock, TokenUsageData, AIChatManagerLogger } from './ai' + +// Agent Core +export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './ai' +export { DEFAULT_MAX_TOOL_ROUNDS, createLlmRouteDecider, decideRequestRoute, runAgentCore } from './ai' +export type { LlmRouteDecider, RequestRoute, RouteDecision, RouteDecisionSource, RouterInput } from './ai' +export { buildPlanGuidance, createAnalysisPlanner, createDataSnapshotFromOverview, createPlanContentBlock } from './ai' +export { buildSemanticSearchGuidance } from './ai' +export type { + AnalysisPlanIntent, + AnalysisPlanner, + AnalysisPlanStep, + AnalysisPlanSummary, + PlannerCapabilitySummary, + PlannerInput, + PlanContentBlock, + PlanDraftContentBlock, + ChatOverviewForSnapshot, +} from './ai' + +// Summary generation +export { + generateSessionSummary, + generateSessionSummaries, + checkSessionsCanGenerateSummary, + isValidMessage, + filterValidMessages, + splitIntoSegments, +} from './ai' +export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult, SummaryStrategy } from './ai' + +// LLM Config Store +export { LLMConfigStore, MAX_CONFIG_COUNT } from './ai' +export type { AIServiceConfig, AIConfigStore, ConfigStorage, LLMConfigStoreDeps } from './ai' + +// Custom Provider/Model Store +export { CustomProviderStore, CustomModelStore } from './ai' + +// Agent Event Handler +export { AgentEventHandler, estimateTokensFromText } from './ai' +export type { TokenUsage, AgentRuntimeStatus, AgentStreamChunk, EventHandlerConfig, EventHandlerContext } from './ai' + +// Agent Prompt Builder +export { buildSystemPrompt, createAiTranslate, aiLocales } from './ai' +export type { + BuildSystemPromptOptions, + DataSnapshot, + OwnerInfo, + MentionedMember, + SkillContext, + TranslateFn, +} from './ai' + +// LLM Model Builder +export { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from './ai' +export type { PiModelConfig, BuildPiModelOptions } from './ai' + +// Remote LLM API +export { fetchRemoteModels, validateApiKey } from './ai' +export type { RemoteModel, FetchRemoteModelsResult, RemoteApiOptions } from './ai' + +// Export engine +export { exportFilterResultToMarkdown, exportWithFormat } from './export' +export type { + ExportFilterParams, + ExportProgress, + ExportProgressCallback, + ExportWriter, + ExportDeps, + ExportResult, + ExportFormat, + FormatExportParams, + FormatExportResult, +} from './export' + +// Session cache (overview + members JSON file cache) +export { + getCachePath, + getCache, + setCache, + invalidateCache, + deleteSessionCache, + computeAndSetOverviewCache, + computeAndSetMembersCache, + getValidatedOverviewCache, + getDbFileVersion, + getOrComputeAnalysisCache, + CACHE_KEY_OVERVIEW, + CACHE_KEY_MEMBERS, +} from './cache' +export type { OverviewCache, MembersCache, MemberStat } from './cache' + +// Chat DB migrations +export { getChatDbMigrations } from './migrations' +export type { MigrationDeps } from './migrations' + +// Electron data migration (CLI first-run) + data path verification +export { migrateFromElectronIfNeeded, verifyCliDataPath, wasElectronUsed } from './migrations' +export type { ElectronMigrationResult } from './migrations' + +// Preferences manager (preferences.json) +export { PreferencesManager } from './preferences' +export type { + Preferences, + AIGlobalSettings, + AIPreprocessConfig, + WordFilterScheme, + KeywordTemplate, + ContextCompressionSettings, +} from './preferences' + +// Merger orchestration +export { checkConflictsFromSources, buildMergedOutput, serializeChatLabToJsonl } from './merger' +export type { + MergerDataSource, + MergerSourceMeta, + MergeSourceInfo, + ChatLabHeader, + ChatLabMeta, + ChatLabOutput, + MergeOrchestrationResult, +} from './merger' +export { + TempDbWriter, + TempDbReader, + exportSessionToJson, + deleteTempDatabase, + cleanupTempDatabases, + TEMP_DB_SCHEMA, +} from './merger/temp-db' +export type { TempDbMeta, ExportedSession } from './merger/temp-db' + +// Re-exports: @earendil-works/pi-agent-core & @earendil-works/pi-ai +export type { AgentTool, AgentToolResult } from './ai' +export { Type, completeSimple, streamSimple, runSimpleLlmStream } from './ai' +export type { LlmStreamChunk, RunSimpleLlmStreamOptions } from './ai' +export type { PiModel, PiApi, PiMessage, PiUsage, PiTextContent, PiAssistantMessage } from './ai' + +// Shared application services (session / member / index / summary / export / analytics) +export { AnalyticsService } from './services/analytics' +export * as sessionService from './services/session-service' +export * as memberService from './services/member-service' +export * as sessionIndexService from './services/session-index-service' +export * as summaryService from './services/summary-service' +export * as exportService from './services/export-service' +export * as ownerProfileService from './services/owner-profile-service' +export * as contactsService from './services/contacts' +export * as peopleRelationshipsService from './services/people/relationships' +// Semantic index (Phase 1 vector search) — independent of legacy ai/rag +export * as semanticIndex from './semantic-index' +export { + SemanticIndexService, + createSemanticIndexService, + defaultSemanticIndexConfig, + SemanticIndexConfigStore, + SEMANTIC_INDEX_CONFIG_FILE, + isSemanticIndexConfigured, + persistSemanticIndexConfig, + resolveSemanticIndexApiKeySet, + createSemanticIndexWorkerRuntimeClient, +} from './semantic-index' +export type { + SemanticIndexServiceOptions, + SemanticIndexRuntime, + SemanticIndexWorkerRuntimeClientOptions, + SemanticIndexSessionStatus, + SemanticSearchResult, + SemanticSearchReason, + SemanticSearchToolResult, + SemanticSearchToolSource, + SemanticSearchToolOptions, + SemanticIndexConfig, + SemanticIndexMode, +} from './semantic-index' + +export { + CONTACTS_ALGORITHM_VERSION, + PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, + MergeSessionCache, + createContactsService, + createPeopleRelationshipsService, + createDatabaseManagerAdapter, + pushImport, +} from './services' +export type { + SessionRuntimeAdapter, + AnalysisSessionDTO, + ListSessionsOptions, + MembersPaginatedDTO, + LlmConfig, + SummaryServiceDeps, + ApplyOwnerProfileReason, + ApplyOwnerProfileResult, + SetOwnerAndApplyProfileResult, + ContactsService, + ContactsServiceDeps, + ContactsServiceOptions, + PeopleRelationshipsService, + PeopleRelationshipsServiceDeps, + PeopleRelationshipsServiceOptions, + PushImportPayload, + PushImportResult, + PushImportOutcome, + PushImportMessage, + PushImportMember, + PushImportMeta, +} from './services' diff --git a/packages/node-runtime/src/jieba-nlp-provider.ts b/packages/node-runtime/src/jieba-nlp-provider.ts new file mode 100644 index 000000000..1bea62281 --- /dev/null +++ b/packages/node-runtime/src/jieba-nlp-provider.ts @@ -0,0 +1,23 @@ +/** + * JiebaNlpProvider — NlpProvider 实现(基于 @node-rs/jieba) + * + * 提供给 @openchatlab/core 的 getLanguagePreferenceAnalysis 使用。 + * 仅在 Node.js 环境中可用(Electron worker / CLI server)。 + * + * 使用 nlp/segmenter 的共享 getJieba() 实例,自动加载磁盘词库。 + */ + +import type { NlpProvider, PosTagResult } from '@openchatlab/core' +import { MEANINGFUL_POS_TAGS, isStopword } from '@openchatlab/core' +import { getJieba } from './nlp/segmenter' +import type { DictType } from '@openchatlab/core' + +export function createJiebaNlpProvider(dictType: DictType = 'default'): NlpProvider { + return { + tag(text: string): PosTagResult[] { + return getJieba(dictType).tag(text) + }, + isStopword, + meaningfulPosTags: MEANINGFUL_POS_TAGS, + } +} diff --git a/packages/node-runtime/src/logging/app-logger.test.ts b/packages/node-runtime/src/logging/app-logger.test.ts new file mode 100644 index 000000000..e629d622a --- /dev/null +++ b/packages/node-runtime/src/logging/app-logger.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { initAppLogger, appLogger } from './app-logger' + +function makeTempDir(): string { + const base = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(base, 'applog-')) +} + +function read(file: string): string { + return fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '' +} + +test('writes leveled lines with scope and extracts Error', () => { + const dir = makeTempDir() + delete process.env.CHATLAB_LOG_LEVEL + initAppLogger(dir) + appLogger.info('startup', 'app started') + appLogger.error('crash', 'boom', new Error('kaboom')) + + const content = read(path.join(dir, 'app.log')) + assert.match(content, /\[INFO\] \[startup\] app started/) + assert.match(content, /\[ERROR\] \[crash\] boom/) + assert.match(content, /kaboom/) + fs.rmSync(dir, { recursive: true, force: true }) +}) + +test('drops levels below threshold (INFO default skips DEBUG)', () => { + const dir = makeTempDir() + delete process.env.CHATLAB_LOG_LEVEL + initAppLogger(dir) + appLogger.debug('x', 'should be dropped') + appLogger.info('x', 'should be kept') + + const content = read(path.join(dir, 'app.log')) + assert.ok(!content.includes('should be dropped')) + assert.ok(content.includes('should be kept')) + fs.rmSync(dir, { recursive: true, force: true }) +}) + +test('CHATLAB_LOG_LEVEL=debug lets DEBUG through', () => { + const dir = makeTempDir() + process.env.CHATLAB_LOG_LEVEL = 'debug' + initAppLogger(dir) + appLogger.debug('x', 'verbose detail') + assert.ok(read(path.join(dir, 'app.log')).includes('verbose detail')) + delete process.env.CHATLAB_LOG_LEVEL + fs.rmSync(dir, { recursive: true, force: true }) +}) + +test('rotates to app.old.log when app.log exceeds 10MB', () => { + const dir = makeTempDir() + delete process.env.CHATLAB_LOG_LEVEL + initAppLogger(dir) + const logFile = path.join(dir, 'app.log') + const oldFile = path.join(dir, 'app.old.log') + + fs.writeFileSync(logFile, 'x'.repeat(10 * 1024 * 1024 + 1)) + appLogger.info('rot', 'after rotation') + + assert.ok(fs.existsSync(oldFile)) + assert.ok(fs.statSync(oldFile).size > 10 * 1024 * 1024) + const content = read(logFile) + assert.ok(content.includes('after rotation')) + assert.ok(fs.statSync(logFile).size < 1024) + fs.rmSync(dir, { recursive: true, force: true }) +}) + +test('creates nested logs dir on demand and never throws', () => { + const dir = makeTempDir() + initAppLogger(path.join(dir, 'a', 'b', 'c')) + assert.doesNotThrow(() => appLogger.info('x', 'nested')) + fs.rmSync(dir, { recursive: true, force: true }) +}) diff --git a/packages/node-runtime/src/logging/app-logger.ts b/packages/node-runtime/src/logging/app-logger.ts new file mode 100644 index 000000000..b6893ae68 --- /dev/null +++ b/packages/node-runtime/src/logging/app-logger.ts @@ -0,0 +1,127 @@ +/** + * Unified application logger (platform-agnostic, Node side). + * + * Shared by Electron main process, CLI and CLI Web runtime. Writes general + * application/diagnostic logs to a single rolling file `/app.log`. + * + * Scope of this logger: general app + key-path + crash logs. AI logs (AiLogger) + * and import perf logs keep their own per-scenario files. + */ + +import * as fs from 'fs' +import * as path from 'path' +import { extractErrorInfo, extractErrorStack } from '../ai/ai-logger' + +type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' + +const LEVEL_ORDER: Record = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 } + +// Rotate when app.log reaches this size; old content moves to app.old.log. +const MAX_SIZE_BYTES = 10 * 1024 * 1024 // 10MB + +class AppLogger { + private logFile: string + private oldLogFile: string + private threshold: LogLevel + + constructor(logsDir: string) { + this.logFile = path.join(logsDir, 'app.log') + this.oldLogFile = path.join(logsDir, 'app.old.log') + this.threshold = resolveThreshold() + } + + debug(scope: string, message: string, data?: unknown): void { + this.write('DEBUG', scope, message, data) + } + + info(scope: string, message: string, data?: unknown): void { + this.write('INFO', scope, message, data) + } + + warn(scope: string, message: string, data?: unknown): void { + this.write('WARN', scope, message, data) + } + + /** `data` may be an Error; its name/message/stack are extracted automatically. */ + error(scope: string, message: string, data?: unknown): void { + this.write('ERROR', scope, message, data) + } + + getLogPath(): string { + return this.logFile + } + + private write(level: LogLevel, scope: string, message: string, data?: unknown): void { + if (LEVEL_ORDER[level] < LEVEL_ORDER[this.threshold]) return + + let line = `[${new Date().toISOString()}] [${level}] [${scope}] ${message}` + + try { + const tail = formatData(data) + if (tail) line += `\n${tail}` + line += '\n' + ensureDir(path.dirname(this.logFile)) + this.rotateIfNeeded() + fs.appendFileSync(this.logFile, line, 'utf-8') + } catch (err) { + // Logging must never break the app; surface to console only. + console.error('[AppLogger] Failed to write log:', err) + } + } + + // ponytail: rename-based rotation, atomic, no rewrite, ~20MB total ceiling + private rotateIfNeeded(): void { + let size = 0 + try { + size = fs.statSync(this.logFile).size + } catch { + return // file doesn't exist yet + } + if (size < MAX_SIZE_BYTES) return + fs.renameSync(this.logFile, this.oldLogFile) // overwrites previous .old, atomic + } +} + +function resolveThreshold(): LogLevel { + const raw = (process.env.CHATLAB_LOG_LEVEL || '').toUpperCase() + if (raw === 'DEBUG' || raw === 'INFO' || raw === 'WARN' || raw === 'ERROR') return raw + return 'INFO' +} + +function formatData(data: unknown): string { + if (data === undefined) return '' + if (data instanceof Error) { + const info = extractErrorInfo(data) + const stack = extractErrorStack(data) + return JSON.stringify(info) + (stack ? `\n${stack}` : '') + } + if (typeof data === 'string') return data + try { + return JSON.stringify(data) + } catch { + return '[unserializable data]' + } +} + +function ensureDir(dir: string): void { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) +} + +let instance: AppLogger | null = null + +/** Initialize the singleton app logger. Call once per runtime at startup. */ +export function initAppLogger(logsDir: string): void { + instance = new AppLogger(logsDir) +} + +/** + * The shared logger. Safe to call before init: logs are dropped silently until + * `initAppLogger` runs (avoids ordering hazards at very early startup). + */ +export const appLogger = { + debug: (scope: string, message: string, data?: unknown) => instance?.debug(scope, message, data), + info: (scope: string, message: string, data?: unknown) => instance?.info(scope, message, data), + warn: (scope: string, message: string, data?: unknown) => instance?.warn(scope, message, data), + error: (scope: string, message: string, data?: unknown) => instance?.error(scope, message, data), + getLogPath: () => instance?.getLogPath() ?? null, +} diff --git a/packages/node-runtime/src/merger/__tests__/merger.test.ts b/packages/node-runtime/src/merger/__tests__/merger.test.ts new file mode 100644 index 000000000..7b6954682 --- /dev/null +++ b/packages/node-runtime/src/merger/__tests__/merger.test.ts @@ -0,0 +1,159 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { checkConflictsFromSources, buildMergedOutput, serializeChatLabToJsonl } from '../index' +import type { MergerDataSource, MergerSourceMeta } from '../index' +import type { MergerMember, MergerMessage } from '@openchatlab/core' + +function createMockSource( + meta: MergerSourceMeta, + members: MergerMember[], + messages: MergerMessage[] +): MergerDataSource { + return { + getMeta: () => meta, + getMembers: () => members, + getMessageCount: () => messages.length, + streamMessages: (_batchSize, callback) => callback(messages), + } +} + +describe('checkConflictsFromSources', () => { + it('detects conflicts from different data sources', () => { + const meta: MergerSourceMeta = { name: 'test', platform: 'qq', type: 'group' } + const source1 = createMockSource( + meta, + [{ platformId: 'u1' }], + [{ senderPlatformId: 'u1', timestamp: 100, type: 0, content: 'hello' }] + ) + const source2 = createMockSource( + meta, + [{ platformId: 'u1' }], + [{ senderPlatformId: 'u1', timestamp: 100, type: 0, content: 'world' }] + ) + + const result = checkConflictsFromSources([ + { source: source1, filename: 'a.txt' }, + { source: source2, filename: 'b.txt' }, + ]) + assert.equal(result.conflicts.length, 1) + }) + + it('no conflicts when messages are identical', () => { + const meta: MergerSourceMeta = { name: 'test', platform: 'qq', type: 'group' } + const source1 = createMockSource( + meta, + [{ platformId: 'u1' }], + [{ senderPlatformId: 'u1', timestamp: 100, type: 0, content: 'same' }] + ) + const source2 = createMockSource( + meta, + [{ platformId: 'u1' }], + [{ senderPlatformId: 'u1', timestamp: 100, type: 0, content: 'same' }] + ) + + const result = checkConflictsFromSources([ + { source: source1, filename: 'a.txt' }, + { source: source2, filename: 'b.txt' }, + ]) + assert.equal(result.conflicts.length, 0) + }) +}) + +describe('buildMergedOutput', () => { + it('merges from multiple sources and deduplicates', () => { + const meta: MergerSourceMeta = { name: 'chat', platform: 'qq', type: 'group' } + const source1 = createMockSource( + meta, + [{ platformId: 'u1', accountName: 'Alice' }], + [ + { senderPlatformId: 'u1', timestamp: 100, type: 0, content: 'a' }, + { senderPlatformId: 'u1', timestamp: 200, type: 0, content: 'b' }, + ] + ) + const source2 = createMockSource( + meta, + [ + { platformId: 'u1', accountName: 'Alice' }, + { platformId: 'u2', accountName: 'Bob' }, + ], + [ + { senderPlatformId: 'u1', timestamp: 100, type: 0, content: 'a' }, + { senderPlatformId: 'u2', timestamp: 300, type: 0, content: 'c' }, + ] + ) + + const result = buildMergedOutput( + [ + { source: source1, filename: 'file1.txt' }, + { source: source2, filename: 'file2.txt' }, + ], + 'TestMerge' + ) + + assert.ok(result.success) + assert.equal(result.chatLabData.messages.length, 3) + assert.equal(result.chatLabData.messages[0].timestamp, 100) + assert.equal(result.chatLabData.messages[1].timestamp, 200) + assert.equal(result.chatLabData.messages[2].timestamp, 300) + assert.equal(result.chatLabData.meta.name, 'TestMerge') + assert.equal(result.chatLabData.meta.platform, 'qq') + assert.equal(result.chatLabData.members.length, 2) + assert.equal(result.chatLabData.meta.sources.length, 2) + }) + + it('detects mixed platform when sources differ', () => { + const s1 = createMockSource( + { name: 'a', platform: 'qq', type: 'group' }, + [{ platformId: 'u1' }], + [{ senderPlatformId: 'u1', timestamp: 100, type: 0, content: 'x' }] + ) + const s2 = createMockSource( + { name: 'b', platform: 'wechat', type: 'group' }, + [{ platformId: 'u2' }], + [{ senderPlatformId: 'u2', timestamp: 200, type: 0, content: 'y' }] + ) + + const result = buildMergedOutput( + [ + { source: s1, filename: 'a.txt' }, + { source: s2, filename: 'b.txt' }, + ], + 'Cross' + ) + assert.equal(result.chatLabData.meta.platform, 'mixed') + }) +}) + +describe('serializeChatLabToJsonl', () => { + it('produces header, member, and message lines', () => { + const data = buildMergedOutput( + [ + { + source: createMockSource( + { name: 'test', platform: 'qq', type: 'group' }, + [{ platformId: 'u1' }], + [{ senderPlatformId: 'u1', timestamp: 100, type: 0, content: 'hi' }] + ), + filename: 'f.txt', + }, + ], + 'T' + ) + + const lines = [...serializeChatLabToJsonl(data.chatLabData)] + assert.ok(lines.length >= 3) + + const header = JSON.parse(lines[0]) + assert.equal(header._type, 'header') + assert.ok(header.chatlab) + assert.ok(header.meta) + + const member = JSON.parse(lines[1]) + assert.equal(member._type, 'member') + assert.equal(member.platformId, 'u1') + + const msg = JSON.parse(lines[2]) + assert.equal(msg._type, 'message') + assert.equal(msg.content, 'hi') + }) +}) diff --git a/packages/node-runtime/src/merger/index.ts b/packages/node-runtime/src/merger/index.ts new file mode 100644 index 000000000..dcfc1a647 --- /dev/null +++ b/packages/node-runtime/src/merger/index.ts @@ -0,0 +1,214 @@ +/** + * Merger orchestration — abstract data-source reading, conflict checking, + * merge assembly, and ChatLab output formatting. + * + * Decoupled from Electron-specific TempDbReader via MergerDataSource interface. + */ + +import { + getCollidingPlatformIds, + normalizePlatformId, + detectConflictsInMessages, + mergeMergerMembers, + generateMessageKey, + type MergerMember, + type MergerMessage, + type ConflictCheckResult, + type MergedMember, + type MergedMessage, +} from '@openchatlab/core' + +// ==================== Data source abstraction ==================== + +export interface MergerSourceMeta { + name: string + platform: string + type: string + groupId?: string + groupAvatar?: string +} + +/** + * Abstract data source for merger input. + * In Electron, this wraps TempDbReader. Other platforms can implement + * their own (e.g. reading from IndexedDB, REST API, etc.). + */ +export interface MergerDataSource { + getMeta(): MergerSourceMeta | null + getMembers(): MergerMember[] + getMessageCount(): number + streamMessages(batchSize: number, callback: (messages: MergerMessage[]) => void): void +} + +// ==================== Output types ==================== + +export interface MergeSourceInfo { + filename: string + platform: string + messageCount: number +} + +export interface ChatLabHeader { + version: string + exportedAt: number + generator: string + description: string +} + +export interface ChatLabMeta { + name: string + platform: string + type: string + sources: MergeSourceInfo[] + groupId?: string + groupAvatar?: string +} + +export interface ChatLabOutput { + chatlab: ChatLabHeader + meta: ChatLabMeta + members: MergedMember[] + messages: MergedMessage[] +} + +// ==================== Conflict checking ==================== + +export function checkConflictsFromSources( + dataSources: Array<{ source: MergerDataSource; filename: string }> +): ConflictCheckResult { + const allMessages: Array<{ msg: MergerMessage; source: string; platform: string }> = [] + + for (const { source, filename } of dataSources) { + const meta = source.getMeta() + const platform = meta?.platform || 'unknown' + source.streamMessages(10000, (messages) => { + for (const msg of messages) { + allMessages.push({ msg, source: filename, platform }) + } + }) + } + + return detectConflictsInMessages(allMessages) +} + +// ==================== Merge orchestration ==================== + +export interface MergeOrchestrationResult { + success: true + chatLabData: ChatLabOutput +} + +/** + * Build a merged ChatLabOutput from multiple data sources. + * Pure orchestration: reads data sources, calls core algorithms, + * assembles the output. Does NOT write to disk or import to DB. + */ +export function buildMergedOutput( + dataSources: Array<{ source: MergerDataSource; filename: string }>, + outputName: string +): MergeOrchestrationResult { + const metas = dataSources.map(({ source, filename }) => ({ + meta: source.getMeta(), + members: source.getMembers(), + filename, + source, + })) + + const collidingIds = getCollidingPlatformIds( + metas.map(({ meta, members }) => ({ + platform: meta?.platform || 'unknown', + members: members.map((m) => ({ platformId: m.platformId })), + })) + ) + + const memberMap = mergeMergerMembers( + metas.map(({ meta, members }) => ({ + platform: meta?.platform || 'unknown', + members, + })), + collidingIds + ) + + // Streaming dedup: process batches to avoid loading all into memory + const seenKeys = new Set() + const mergedMessages: MergedMessage[] = [] + + for (const { source, meta } of metas) { + const platform = meta?.platform || 'unknown' + source.streamMessages(10000, (messages) => { + for (const msg of messages) { + const nid = normalizePlatformId(msg.senderPlatformId, platform, collidingIds) + const key = generateMessageKey(msg.timestamp, nid, msg.content ?? null) + if (seenKeys.has(key)) continue + seenKeys.add(key) + mergedMessages.push({ + sender: nid, + accountName: msg.senderAccountName, + groupNickname: msg.senderGroupNickname, + timestamp: msg.timestamp, + type: msg.type, + content: msg.content, + }) + } + }) + } + + mergedMessages.sort((a, b) => a.timestamp - b.timestamp) + + const sources: MergeSourceInfo[] = dataSources.map(({ source, filename }) => ({ + filename, + platform: source.getMeta()?.platform || 'unknown', + messageCount: source.getMessageCount(), + })) + + const uniquePlatforms = [...new Set(metas.map(({ meta }) => meta?.platform || 'unknown'))] + const platform = uniquePlatforms.length === 1 ? uniquePlatforms[0] : 'mixed' + + const groupIds = new Set(metas.map(({ meta }) => meta?.groupId).filter(Boolean)) + const groupId = groupIds.size === 1 ? metas.find(({ meta }) => meta?.groupId)?.meta?.groupId : undefined + const groupAvatar = groupId + ? metas.filter(({ meta }) => meta?.groupId === groupId).pop()?.meta?.groupAvatar + : undefined + + const chatLabData: ChatLabOutput = { + chatlab: { + version: '0.0.1', + exportedAt: Math.floor(Date.now() / 1000), + generator: 'ChatLab Merge Tool', + description: `Merged from ${dataSources.length} files`, + }, + meta: { + name: outputName, + platform, + type: metas[0]?.meta?.type || 'group', + sources, + groupId, + groupAvatar, + }, + members: Array.from(memberMap.values()), + messages: mergedMessages, + } + + return { success: true, chatLabData } +} + +// ==================== JSONL serialization ==================== + +/** + * Serialize ChatLabOutput to JSONL lines (generator for streaming writes). + */ +export function* serializeChatLabToJsonl(data: ChatLabOutput): Generator { + yield JSON.stringify({ + _type: 'header', + chatlab: data.chatlab, + meta: data.meta, + }) + + for (const member of data.members) { + yield JSON.stringify({ _type: 'member', ...member }) + } + + for (const msg of data.messages) { + yield JSON.stringify({ _type: 'message', ...msg }) + } +} diff --git a/packages/node-runtime/src/merger/temp-db.ts b/packages/node-runtime/src/merger/temp-db.ts new file mode 100644 index 000000000..1c5f4f02a --- /dev/null +++ b/packages/node-runtime/src/merger/temp-db.ts @@ -0,0 +1,397 @@ +/** + * Temporary database manager for merge operations. + * + * Extracted from electron/main/merger/tempCache.ts. + * Uses DatabaseAdapter for platform-agnostic temp DB read/write. + * + * Callers provide a DatabaseAdapter factory; TempDbWriter/TempDbReader + * handle schema creation and streaming reads. + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { DatabaseAdapter } from '@openchatlab/core' +import type { ParsedMember, ParsedMessage } from '@openchatlab/shared-types' +import type { MergerDataSource, MergerSourceMeta } from './index' +import type { MergerMember, MergerMessage } from '@openchatlab/core' + +// ==================== Temp DB schema (simplified for merge preview) ==================== + +export const TEMP_DB_SCHEMA = ` + CREATE TABLE IF NOT EXISTS meta ( + name TEXT NOT NULL, + platform TEXT NOT NULL, + type TEXT NOT NULL, + group_id TEXT, + group_avatar TEXT, + owner_id TEXT + ); + + CREATE TABLE IF NOT EXISTS member ( + platform_id TEXT PRIMARY KEY, + account_name TEXT, + group_nickname TEXT, + avatar TEXT + ); + + CREATE TABLE IF NOT EXISTS message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_platform_id TEXT NOT NULL, + sender_account_name TEXT, + sender_group_nickname TEXT, + timestamp INTEGER NOT NULL, + type INTEGER NOT NULL, + content TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_message_ts ON message(timestamp); + CREATE INDEX IF NOT EXISTS idx_message_sender ON message(sender_platform_id); +` + +// ==================== TempDbWriter ==================== + +export class TempDbWriter { + private db: DatabaseAdapter + private memberSet = new Set() + private messageCount = 0 + + constructor(db: DatabaseAdapter) { + this.db = db + db.exec(TEMP_DB_SCHEMA) + db.exec('BEGIN TRANSACTION') + } + + writeMeta(meta: { + name: string + platform: string + type: string | number + groupId?: string + groupAvatar?: string + ownerId?: string + }): void { + this.db + .prepare('INSERT INTO meta (name, platform, type, group_id, group_avatar, owner_id) VALUES (?, ?, ?, ?, ?, ?)') + .run(meta.name, meta.platform, meta.type, meta.groupId || null, meta.groupAvatar || null, meta.ownerId || null) + } + + writeMembers(members: ParsedMember[]): void { + const insert = this.db.prepare( + 'INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)' + ) + for (const m of members) { + if (!this.memberSet.has(m.platformId)) { + this.memberSet.add(m.platformId) + insert.run(m.platformId, m.accountName || null, m.groupNickname || null, m.avatar || null) + } + } + } + + writeMessages(messages: ParsedMessage[]): void { + const insert = this.db.prepare( + `INSERT INTO message (sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content) + VALUES (?, ?, ?, ?, ?, ?)` + ) + const memberInsert = this.db.prepare( + 'INSERT OR IGNORE INTO member (platform_id, account_name, group_nickname, avatar) VALUES (?, ?, ?, ?)' + ) + for (const msg of messages) { + if (!this.memberSet.has(msg.senderPlatformId)) { + this.memberSet.add(msg.senderPlatformId) + memberInsert.run(msg.senderPlatformId, msg.senderAccountName || null, msg.senderGroupNickname || null, null) + } + insert.run( + msg.senderPlatformId, + msg.senderAccountName || null, + msg.senderGroupNickname || null, + msg.timestamp, + msg.type, + msg.content || null + ) + this.messageCount++ + } + } + + finish(): { messageCount: number; memberCount: number } { + this.db.exec('COMMIT') + const result = { messageCount: this.messageCount, memberCount: this.memberSet.size } + this.db.close() + return result + } + + abort(): void { + try { + this.db.exec('ROLLBACK') + } catch { + /* ignore */ + } + this.db.close() + } +} + +// ==================== TempDbReader ==================== + +export interface TempDbMeta { + name: string + platform: string + type: string + groupId?: string + groupAvatar?: string +} + +export class TempDbReader { + private db: DatabaseAdapter + + constructor(db: DatabaseAdapter) { + this.db = db + } + + getMeta(): TempDbMeta | null { + const row = this.db.prepare('SELECT * FROM meta LIMIT 1').get() as + | { name: string; platform: string; type: string; group_id: string | null; group_avatar: string | null } + | undefined + if (!row) return null + return { + name: row.name, + platform: row.platform, + type: row.type, + groupId: row.group_id || undefined, + groupAvatar: row.group_avatar || undefined, + } + } + + getMembers(): ParsedMember[] { + const rows = this.db.prepare('SELECT * FROM member').all() as Array<{ + platform_id: string + account_name: string | null + group_nickname: string | null + avatar: string | null + }> + return rows.map((r) => ({ + platformId: r.platform_id, + accountName: r.account_name || r.platform_id, + groupNickname: r.group_nickname || undefined, + avatar: r.avatar || undefined, + })) + } + + getMessageCount(): number { + const row = this.db.prepare('SELECT COUNT(*) as count FROM message').get() as { count: number } + return row.count + } + + streamMessages(batchSize: number, callback: (messages: ParsedMessage[]) => void): void { + const stmt = this.db.prepare(` + SELECT sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content + FROM message ORDER BY timestamp ASC LIMIT ? OFFSET ? + `) + + let offset = 0 + while (true) { + const rows = stmt.all(batchSize, offset) as Array<{ + sender_platform_id: string + sender_account_name: string | null + sender_group_nickname: string | null + timestamp: number + type: number + content: string | null + }> + + if (rows.length === 0) break + + const messages: ParsedMessage[] = rows.map((r) => ({ + senderPlatformId: r.sender_platform_id, + senderAccountName: r.sender_account_name || r.sender_platform_id, + senderGroupNickname: r.sender_group_nickname || undefined, + timestamp: r.timestamp, + type: r.type as ParsedMessage['type'], + content: r.content, + })) + + callback(messages) + offset += batchSize + } + } + + getAllMessages(): ParsedMessage[] { + const rows = this.db + .prepare( + `SELECT sender_platform_id, sender_account_name, sender_group_nickname, timestamp, type, content + FROM message ORDER BY timestamp ASC` + ) + .all() as Array<{ + sender_platform_id: string + sender_account_name: string | null + sender_group_nickname: string | null + timestamp: number + type: number + content: string | null + }> + + return rows.map((r) => ({ + senderPlatformId: r.sender_platform_id, + senderAccountName: r.sender_account_name || r.sender_platform_id, + senderGroupNickname: r.sender_group_nickname || undefined, + timestamp: r.timestamp, + type: r.type as ParsedMessage['type'], + content: r.content, + })) + } + + close(): void { + this.db.close() + } + + /** Wrap this reader as a MergerDataSource for use with merge algorithms. */ + toDataSource(): MergerDataSource { + return createDataSourceFromReader(this) + } +} + +function createDataSourceFromReader(reader: TempDbReader): MergerDataSource { + return { + getMeta(): MergerSourceMeta | null { + const meta = reader.getMeta() + if (!meta) return null + return { + name: meta.name, + platform: meta.platform, + type: meta.type, + groupId: meta.groupId, + groupAvatar: meta.groupAvatar, + } + }, + getMembers(): MergerMember[] { + return reader.getMembers().map((m) => ({ + platformId: m.platformId, + accountName: m.accountName, + groupNickname: m.groupNickname, + avatar: m.avatar, + })) + }, + getMessageCount(): number { + return reader.getMessageCount() + }, + streamMessages(batchSize: number, callback: (messages: MergerMessage[]) => void): void { + reader.streamMessages(batchSize, (messages) => { + callback( + messages.map((msg) => ({ + senderPlatformId: msg.senderPlatformId, + senderAccountName: msg.senderAccountName, + senderGroupNickname: msg.senderGroupNickname, + timestamp: msg.timestamp, + type: msg.type, + content: msg.content, + })) + ) + }) + }, + } +} + +// ==================== Session export ==================== + +export interface ExportedSession { + chatlab: { version: string; exportedAt: number; generator: string; description: string } + meta: { name: string; platform: string; type: string; groupId?: string; groupAvatar?: string } + members: Array<{ platformId: string; accountName: string; groupNickname?: string; avatar?: string }> + messages: Array<{ + sender: string + accountName: string + groupNickname?: string + timestamp: number + type: number + content: string | null + }> +} + +/** + * Export a session DB to a ChatLab JSON structure (in memory). + * Caller decides how to persist (write to file, send over HTTP, etc.). + */ +export function exportSessionToJson(db: DatabaseAdapter): ExportedSession { + const meta = db.prepare('SELECT * FROM meta').get() as + | { name: string; platform: string; type: string; group_id?: string; group_avatar?: string } + | undefined + + if (!meta) throw new Error('Cannot read session meta') + + const members = db.prepare('SELECT platform_id, account_name, group_nickname, avatar FROM member').all() as Array<{ + platform_id: string + account_name?: string + group_nickname?: string + avatar?: string + }> + + const messages = db + .prepare( + `SELECT m.platform_id as sender, msg.sender_account_name as accountName, + msg.sender_group_nickname as groupNickname, msg.ts as timestamp, msg.type, msg.content + FROM message msg JOIN member m ON msg.sender_id = m.id ORDER BY msg.ts` + ) + .all() as Array<{ + sender: string + accountName?: string + groupNickname?: string + timestamp: number + type: number + content?: string + }> + + return { + chatlab: { + version: '0.0.1', + exportedAt: Math.floor(Date.now() / 1000), + generator: 'ChatLab Export', + description: `Exported from session: ${meta.name}`, + }, + meta: { + name: meta.name, + platform: meta.platform, + type: meta.type, + groupId: meta.group_id, + groupAvatar: meta.group_avatar, + }, + members: members.map((m) => ({ + platformId: m.platform_id, + accountName: m.account_name || m.platform_id, + groupNickname: m.group_nickname || undefined, + avatar: m.avatar, + })), + messages: messages.map((msg) => ({ + sender: msg.sender, + accountName: msg.accountName || msg.sender, + groupNickname: msg.groupNickname || undefined, + timestamp: msg.timestamp, + type: msg.type, + content: msg.content ?? null, + })), + } +} + +// ==================== Filesystem cleanup helpers ==================== + +export function deleteTempDatabase(dbPath: string): void { + try { + if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath) + const walPath = dbPath + '-wal' + const shmPath = dbPath + '-shm' + if (fs.existsSync(walPath)) fs.unlinkSync(walPath) + if (fs.existsSync(shmPath)) fs.unlinkSync(shmPath) + } catch { + /* best-effort cleanup */ + } +} + +export function cleanupTempDatabases(tempDir: string): void { + try { + if (!fs.existsSync(tempDir)) return + const files = fs.readdirSync(tempDir) + for (const file of files) { + if (file.startsWith('merge_') && file.endsWith('.db')) { + deleteTempDatabase(path.join(tempDir, file)) + } + } + } catch { + /* best-effort */ + } +} diff --git a/packages/node-runtime/src/migrations/chat-db-migrations.ts b/packages/node-runtime/src/migrations/chat-db-migrations.ts new file mode 100644 index 000000000..0002914d3 --- /dev/null +++ b/packages/node-runtime/src/migrations/chat-db-migrations.ts @@ -0,0 +1,412 @@ +/** + * Chat session DB migration definitions (platform-agnostic). + * + * Extracted from electron/main/database/migrations.ts. + * Migration scripts use only DatabaseAdapter — no Electron or Node-specific APIs. + * Version 4 (FTS backfill) requires a tokenizer injected via MigrationDeps. + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import type { PathProvider } from '@openchatlab/core' +import type { Migration as CoreMigration } from '@openchatlab/core' +import { raiseDataDirMinRuntimeVersion, type RuntimeIdentity } from '../data-dir-compat' + +export interface MigrationDeps { + /** FTS tokenizer — needed by v4 migration for backfilling the FTS index */ + tokenizeForFts?: (content: string) => string | null +} + +export interface ChatDbCompatibilityRaise { + migrationVersion: number + minRuntimeVersion: string + dataCompatibilityVersion: number + reason: string + module: string +} + +export const CHAT_DB_COMPATIBILITY_RAISES: ChatDbCompatibilityRaise[] = [ + { + migrationVersion: 6, + minRuntimeVersion: '0.25.1', + dataCompatibilityVersion: 1, + reason: 'segment-schema', + module: 'chat-db-migration', + }, +] + +export function raiseChatDbCompatibilityGate(pathProvider: PathProvider, runtime: RuntimeIdentity): void { + for (const compatibilityRaise of CHAT_DB_COMPATIBILITY_RAISES) { + raiseDataDirMinRuntimeVersion(pathProvider, { + minRuntimeVersion: compatibilityRaise.minRuntimeVersion, + dataCompatibilityVersion: compatibilityRaise.dataCompatibilityVersion, + reason: compatibilityRaise.reason, + runtime, + module: compatibilityRaise.module, + }) + } +} + +/** + * Build the chat DB migration list. + * + * @param deps Optional dependencies (tokenizer for FTS backfill) + * @returns Array of migrations compatible with core `runMigrations` + */ +export function getChatDbMigrations(deps?: MigrationDeps): CoreMigration[] { + const hasColumn = (db: DatabaseAdapter, tableName: string, columnName: string): boolean => { + const tableInfo = db.pragma(`table_info(${tableName})`) as Array<{ name: string }> + return tableInfo.some((col) => col.name === columnName) + } + + const addColumnIfMissing = (db: DatabaseAdapter, tableName: string, columnName: string, definition: string): void => { + if (!hasColumn(db, tableName, columnName)) { + db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`) + } + } + + return [ + { + version: 1, + description: 'Add owner_id column to meta', + up: (db: DatabaseAdapter) => { + const tableInfo = db.pragma('table_info(meta)') as Array<{ name: string }> + if (!tableInfo.some((col) => col.name === 'owner_id')) { + db.exec('ALTER TABLE meta ADD COLUMN owner_id TEXT') + } + }, + }, + { + version: 2, + description: 'Add roles, reply_to_message_id, platform_message_id columns', + up: (db: DatabaseAdapter) => { + const memberTableInfo = db.pragma('table_info(member)') as Array<{ name: string }> + if (!memberTableInfo.some((col) => col.name === 'roles')) { + db.exec("ALTER TABLE member ADD COLUMN roles TEXT DEFAULT '[]'") + } + + const messageTableInfo = db.pragma('table_info(message)') as Array<{ name: string }> + + if (!messageTableInfo.some((col) => col.name === 'reply_to_message_id')) { + db.exec('ALTER TABLE message ADD COLUMN reply_to_message_id TEXT DEFAULT NULL') + } + + if (!messageTableInfo.some((col) => col.name === 'platform_message_id')) { + db.exec('ALTER TABLE message ADD COLUMN platform_message_id TEXT DEFAULT NULL') + } + + try { + db.exec('CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id)') + } catch { + // Index may already exist + } + }, + }, + { + version: 3, + description: 'Add chat_session and message_context tables', + up: (db: DatabaseAdapter) => { + db.exec(` + CREATE TABLE IF NOT EXISTS chat_session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ) + `) + + try { + db.exec('CREATE INDEX IF NOT EXISTS idx_session_time ON chat_session(start_ts, end_ts)') + } catch { + // Index may already exist + } + + db.exec(` + CREATE TABLE IF NOT EXISTS message_context ( + message_id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL, + topic_id INTEGER + ) + `) + + try { + db.exec('CREATE INDEX IF NOT EXISTS idx_context_session ON message_context(session_id)') + } catch { + // Index may already exist + } + + const tableInfo = db.pragma('table_info(meta)') as Array<{ name: string }> + if (!tableInfo.some((col) => col.name === 'session_gap_threshold')) { + db.exec('ALTER TABLE meta ADD COLUMN session_gap_threshold INTEGER') + } + }, + }, + { + version: 4, + description: 'Add FTS5 full-text search index', + up: (db: DatabaseAdapter) => { + const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='message_fts'").get() + if (hasTable) return + + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5( + content, + content='', + content_rowid=id + ) + `) + + const tokenize = deps?.tokenizeForFts + if (!tokenize) return + + const BATCH_SIZE = 5000 + const insertFts = db.prepare('INSERT INTO message_fts(rowid, content) VALUES (?, ?)') + + const countRow = db + .prepare("SELECT COUNT(*) as total FROM message WHERE type = 0 AND content IS NOT NULL AND content != ''") + .get() as { total: number } | undefined + + const total = countRow?.total ?? 0 + let offset = 0 + while (offset < total) { + const rows = db + .prepare( + `SELECT id, content FROM message + WHERE type = 0 AND content IS NOT NULL AND content != '' + ORDER BY id ASC LIMIT ? OFFSET ?` + ) + .all(BATCH_SIZE, offset) as Array<{ id: number; content: string }> + + if (rows.length === 0) break + + for (const row of rows) { + const tokens = tokenize(row.content) + if (tokens) { + insertFts.run(row.id, tokens) + } + } + + offset += BATCH_SIZE + } + }, + }, + { + version: 5, + description: 'Repair legacy member/message columns', + up: (db: DatabaseAdapter) => { + addColumnIfMissing(db, 'meta', 'group_id', 'TEXT') + addColumnIfMissing(db, 'meta', 'group_avatar', 'TEXT') + addColumnIfMissing(db, 'meta', 'owner_id', 'TEXT') + addColumnIfMissing(db, 'meta', 'session_gap_threshold', 'INTEGER') + + const memberHadName = hasColumn(db, 'member', 'name') + const memberHadNickname = hasColumn(db, 'member', 'nickname') + addColumnIfMissing(db, 'member', 'account_name', 'TEXT') + addColumnIfMissing(db, 'member', 'group_nickname', 'TEXT') + addColumnIfMissing(db, 'member', 'aliases', "TEXT DEFAULT '[]'") + addColumnIfMissing(db, 'member', 'avatar', 'TEXT') + addColumnIfMissing(db, 'member', 'roles', "TEXT DEFAULT '[]'") + + if (memberHadName) { + db.exec("UPDATE member SET account_name = COALESCE(NULLIF(account_name, ''), name)") + } + if (memberHadNickname) { + db.exec("UPDATE member SET group_nickname = COALESCE(NULLIF(group_nickname, ''), nickname)") + } + db.exec("UPDATE member SET aliases = COALESCE(aliases, '[]'), roles = COALESCE(roles, '[]')") + + db.exec(` + CREATE TABLE IF NOT EXISTS member_name_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_id INTEGER NOT NULL, + name_type TEXT DEFAULT 'account_name', + name TEXT NOT NULL, + start_ts INTEGER NOT NULL, + end_ts INTEGER, + FOREIGN KEY(member_id) REFERENCES member(id) + ) + `) + addColumnIfMissing(db, 'member_name_history', 'name_type', "TEXT DEFAULT 'account_name'") + addColumnIfMissing(db, 'message', 'sender_account_name', 'TEXT') + addColumnIfMissing(db, 'message', 'sender_group_nickname', 'TEXT') + addColumnIfMissing(db, 'message', 'reply_to_message_id', 'TEXT DEFAULT NULL') + addColumnIfMissing(db, 'message', 'platform_message_id', 'TEXT DEFAULT NULL') + + db.exec(` + CREATE TABLE IF NOT EXISTS chat_session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ) + `) + db.exec(` + CREATE TABLE IF NOT EXISTS message_context ( + message_id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL, + topic_id INTEGER + ) + `) + + db.exec('CREATE INDEX IF NOT EXISTS idx_message_platform_id ON message(platform_message_id)') + db.exec('CREATE INDEX IF NOT EXISTS idx_member_name_history_member_id ON member_name_history(member_id)') + db.exec('CREATE INDEX IF NOT EXISTS idx_session_time ON chat_session(start_ts, end_ts)') + db.exec('CREATE INDEX IF NOT EXISTS idx_context_session ON message_context(session_id)') + }, + }, + { + version: 6, + description: 'Rename chat_session index tables to segment terminology', + up: (db: DatabaseAdapter) => { + const hasTable = (tableName: string): boolean => { + const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?").get(tableName) as + | Record + | undefined + return !!row + } + + db.exec(` + DROP INDEX IF EXISTS idx_session_time; + DROP INDEX IF EXISTS idx_context_session; + `) + + const hasLegacyChatSession = hasTable('chat_session') + const hasSegment = hasTable('segment') + + if (hasLegacyChatSession && !hasSegment) { + db.exec('ALTER TABLE chat_session RENAME TO segment') + } else if (hasLegacyChatSession && hasSegment) { + db.exec(` + INSERT OR IGNORE INTO segment (id, start_ts, end_ts, message_count, is_manual, summary) + SELECT id, start_ts, end_ts, message_count, is_manual, summary + FROM chat_session; + DROP TABLE chat_session; + `) + } + + if (hasTable('message_context') && hasColumn(db, 'message_context', 'session_id')) { + db.exec('ALTER TABLE message_context RENAME COLUMN session_id TO segment_id') + } + + db.exec(` + CREATE TABLE IF NOT EXISTS segment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); + + CREATE TABLE IF NOT EXISTS message_context ( + message_id INTEGER PRIMARY KEY, + segment_id INTEGER NOT NULL, + topic_id INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_segment_time ON segment(start_ts, end_ts); + CREATE INDEX IF NOT EXISTS idx_context_segment ON message_context(segment_id); + `) + }, + }, + { + version: 7, + description: 'Repair fully missing segment message contexts', + up: (db: DatabaseAdapter) => { + const counts = db + .prepare( + `SELECT + (SELECT COUNT(*) FROM message) AS messageCount, + (SELECT COUNT(*) FROM segment) AS segmentCount, + (SELECT COUNT(*) FROM message_context) AS contextCount, + (SELECT COALESCE(SUM(message_count), 0) FROM segment) AS declaredMessageCount` + ) + .get() as + | { + messageCount: number + segmentCount: number + contextCount: number + declaredMessageCount: number + } + | undefined + + if (!counts || counts.messageCount === 0 || counts.segmentCount === 0 || counts.contextCount > 0) { + return + } + + if (counts.declaredMessageCount !== counts.messageCount) { + throw new Error( + 'Cannot safely repair missing message_context rows: segment message counts do not match message table' + ) + } + + const invalidRange = db + .prepare( + `WITH ordered_segments AS ( + SELECT + id, + start_ts, + end_ts, + LAG(end_ts) OVER (ORDER BY start_ts, id) AS previousEndTs + FROM segment + ) + SELECT COUNT(*) AS count + FROM ordered_segments + WHERE start_ts > end_ts OR (previousEndTs IS NOT NULL AND start_ts <= previousEndTs)` + ) + .get() as { count: number } | undefined + if ((invalidRange?.count ?? 0) > 0) { + throw new Error( + 'Cannot safely repair missing message_context rows: segment time ranges are invalid or overlapping' + ) + } + + const mismatchedSegments = db + .prepare( + `SELECT COUNT(*) AS count + FROM ( + SELECT s.id + FROM segment s + LEFT JOIN message m ON m.ts >= s.start_ts AND m.ts <= s.end_ts + GROUP BY s.id + HAVING COUNT(m.id) != s.message_count + )` + ) + .get() as { count: number } | undefined + if ((mismatchedSegments?.count ?? 0) > 0) { + throw new Error( + 'Cannot safely repair missing message_context rows: messages do not match segment time ranges' + ) + } + + const result = db + .prepare( + `INSERT INTO message_context (message_id, segment_id, topic_id) + SELECT m.id, s.id, NULL + FROM segment s + JOIN message m ON m.ts >= s.start_ts AND m.ts <= s.end_ts` + ) + .run() + if (result.changes !== counts.messageCount) { + throw new Error('Cannot safely repair missing message_context rows: rebuilt row count is inconsistent') + } + }, + }, + { + version: 8, + description: 'Add analysis tool performance indexes', + up: (db: DatabaseAdapter) => { + addColumnIfMissing(db, 'message', 'reply_to_message_id', 'TEXT DEFAULT NULL') + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_message_sender_ts ON message(sender_id, ts); + CREATE INDEX IF NOT EXISTS idx_message_type_ts ON message(type, ts); + CREATE INDEX IF NOT EXISTS idx_message_reply_to ON message(reply_to_message_id); + `) + }, + }, + ] +} diff --git a/packages/node-runtime/src/migrations/electron-data-migration.ts b/packages/node-runtime/src/migrations/electron-data-migration.ts new file mode 100644 index 000000000..5e82e5c76 --- /dev/null +++ b/packages/node-runtime/src/migrations/electron-data-migration.ts @@ -0,0 +1,254 @@ +/** + * Electron legacy data detection and migration for CLI/Node.js runtime. + * + * When a user who has been using the Electron desktop app installs the CLI, + * this module detects existing Electron data and repoints config.toml + * so that CLI sees the same databases and configuration. + * + * Migration behavior: + * 1. Detect Electron legacy data directory (platform-specific) + * 2. If databases exist there, write user_data_dir to config.toml pointing to old path + * 3. Copy system data (ai, settings, cache, logs, temp, nlp) from old path to ~/.chatlab/ + */ + +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { writeConfigField } from '@openchatlab/config' + +const SYSTEM_SUBDIRS = ['ai', 'settings', 'cache', 'logs', 'temp', 'nlp'] +const STORAGE_CONFIG_FILE = 'storage.json' + +interface StorageConfig { + dataDir?: string +} + +/** + * Get the Electron userData directory path (platform-specific, no Electron dependency). + * + * - macOS: ~/Library/Application Support/ChatLab/ + * - Windows: %APPDATA%/ChatLab/ + * - Linux: ~/.config/ChatLab/ + */ +function getElectronUserDataDir(): string { + const home = os.homedir() + switch (process.platform) { + case 'darwin': + return path.join(home, 'Library', 'Application Support', 'ChatLab') + case 'win32': { + const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming') + return path.join(appData, 'ChatLab') + } + default: + return path.join(home, '.config', 'ChatLab') + } +} + +function readElectronStorageConfig(electronUserData: string): StorageConfig { + const configPath = path.join(electronUserData, STORAGE_CONFIG_FILE) + if (!fs.existsSync(configPath)) return {} + try { + return JSON.parse(fs.readFileSync(configPath, 'utf-8')) as StorageConfig + } catch { + return {} + } +} + +/** + * Resolve the old Electron data directory, considering storage.json custom paths. + */ +function resolveElectronDataDir(): string { + const electronUserData = getElectronUserDataDir() + const storageConfig = readElectronStorageConfig(electronUserData) + if (storageConfig.dataDir && path.isAbsolute(storageConfig.dataDir)) { + return storageConfig.dataDir + } + return path.join(electronUserData, 'data') +} + +function hasDatabases(dir: string): boolean { + const dbDir = path.join(dir, 'databases') + if (!fs.existsSync(dbDir)) return false + try { + return fs.readdirSync(dbDir).some((f) => f.endsWith('.db')) + } catch { + return false + } +} + +function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }) + } +} + +function copyDirMerge(src: string, dest: string): { copied: number; skipped: number } { + const stats = { copied: 0, skipped: 0 } + if (!fs.existsSync(src)) return stats + + ensureDir(dest) + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(src, { withFileTypes: true }) + } catch { + return stats + } + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + try { + if (entry.isDirectory()) { + const sub = copyDirMerge(srcPath, destPath) + stats.copied += sub.copied + stats.skipped += sub.skipped + } else if (!fs.existsSync(destPath)) { + fs.copyFileSync(srcPath, destPath) + stats.copied++ + } else { + stats.skipped++ + } + } catch { + // Skip files that fail to copy + } + } + return stats +} + +function writeMigrationLog(logDir: string, message: string): void { + try { + ensureDir(logDir) + const logPath = path.join(logDir, 'app.log') + const ts = new Date().toISOString().replace('T', ' ').slice(0, 19) + fs.appendFileSync(logPath, `[${ts}] [MIGRATION] ${message}\n`, 'utf-8') + } catch { + // Silent on log failure + } +} + +export interface ElectronMigrationResult { + migrated: boolean + userDataDir?: string + systemDirsCopied?: number + /** true if Electron userData directory was found (indicating desktop app was used) */ + electronDetected: boolean + error?: string +} + +/** + * Check if the Electron desktop app was previously used on this machine. + * Examines the platform-specific Electron userData directory for signs of active use. + */ +export function wasElectronUsed(): boolean { + const electronUserData = getElectronUserDataDir() + if (!fs.existsSync(electronUserData)) return false + + // Electron creates these on active use (not just installation) + return ( + fs.existsSync(path.join(electronUserData, 'Preferences')) || + fs.existsSync(path.join(electronUserData, 'Local Storage')) || + fs.existsSync(path.join(electronUserData, 'Session Storage')) || + fs.existsSync(path.join(electronUserData, 'data')) + ) +} + +/** + * Verify that the resolved database directory actually contains databases. + * If the directory is empty but Electron was previously used, this indicates + * a misconfigured data path — the user's databases are somewhere else. + * + * @param databaseDir - The databases/ directory path (from PathProvider.getDatabaseDir()) + * + * Call this AFTER NodePathProvider is initialized, regardless of how + * the data directory was resolved (config.toml, env var, migration, or default). + */ +export function verifyCliDataPath(databaseDir: string): boolean { + if (fs.existsSync(databaseDir)) { + try { + if (fs.readdirSync(databaseDir).some((f) => f.endsWith('.db'))) return true + } catch { + // fall through + } + } + + // No databases found — only block if Electron was previously used + return !wasElectronUsed() +} + +/** + * Detect and migrate Electron legacy data for CLI first-run. + * + * Should be called when config.toml has no user_data_dir set (i.e. first run). + * + * Possible outcomes: + * - migrated=true: found Electron databases, config.toml updated + * - migrated=false, electronDetected=false: no Electron installation found + * - migrated=false, electronDetected=true: Electron was used but databases not found + * (user likely has a custom data directory that we can't locate) + */ +export function migrateFromElectronIfNeeded(systemDir: string): ElectronMigrationResult { + const electronDataDir = resolveElectronDataDir() + + if (!hasDatabases(electronDataDir)) { + return { migrated: false, electronDetected: wasElectronUsed() } + } + + console.log(`[Migration] Detected Electron data at: ${electronDataDir}`) + + try { + // Step 1: Point config.toml to the Electron data directory (databases stay in place) + writeConfigField('data', 'user_data_dir', electronDataDir) + + // Step 2: Copy system data from Electron dir to ~/.chatlab/ (merge, don't overwrite) + let totalCopied = 0 + let totalSkipped = 0 + const movedDirs: string[] = [] + + for (const subDir of SYSTEM_SUBDIRS) { + const srcDir = path.join(electronDataDir, subDir) + const destDir = path.join(systemDir, subDir) + if (!fs.existsSync(srcDir)) continue + + const { copied, skipped } = copyDirMerge(srcDir, destDir) + if (copied > 0 || skipped > 0) { + movedDirs.push(subDir) + totalCopied += copied + totalSkipped += skipped + console.log(`[Migration] ${subDir}: copied ${copied}, skipped ${skipped}`) + } + } + + // Step 3: Remove copied system dirs from old data path (databases stay) + for (const subDir of movedDirs) { + const srcDir = path.join(electronDataDir, subDir) + try { + fs.rmSync(srcDir, { recursive: true, force: true }) + } catch { + // Non-critical: old dir remains but won't cause issues + } + } + + const logsDir = path.join(systemDir, 'logs') + const summary = + `CLI first-run: detected Electron data at ${electronDataDir}, ` + + `pointed user_data_dir there, copied ${totalCopied} system files (${movedDirs.join(', ') || 'none'})` + writeMigrationLog(logsDir, summary) + console.log(`[Migration] ${summary}`) + + return { + migrated: true, + electronDetected: true, + userDataDir: electronDataDir, + systemDirsCopied: totalCopied, + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + console.error('[Migration] Electron data migration failed:', errorMsg) + try { + writeMigrationLog(path.join(systemDir, 'logs'), `Electron migration failed: ${errorMsg}`) + } catch { + // Ignore log failure + } + return { migrated: false, electronDetected: true, error: errorMsg } + } +} diff --git a/packages/node-runtime/src/migrations/index.ts b/packages/node-runtime/src/migrations/index.ts new file mode 100644 index 000000000..bbf939bda --- /dev/null +++ b/packages/node-runtime/src/migrations/index.ts @@ -0,0 +1,5 @@ +export { CHAT_DB_COMPATIBILITY_RAISES, getChatDbMigrations, raiseChatDbCompatibilityGate } from './chat-db-migrations' +export type { ChatDbCompatibilityRaise, MigrationDeps } from './chat-db-migrations' + +export { migrateFromElectronIfNeeded, verifyCliDataPath, wasElectronUsed } from './electron-data-migration' +export type { ElectronMigrationResult } from './electron-data-migration' diff --git a/packages/node-runtime/src/nlp/__tests__/fts-tokenizer.test.ts b/packages/node-runtime/src/nlp/__tests__/fts-tokenizer.test.ts new file mode 100644 index 000000000..076ac06b3 --- /dev/null +++ b/packages/node-runtime/src/nlp/__tests__/fts-tokenizer.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { tokenizeForFts, tokenizeQueryForFts } from '../fts-tokenizer' + +describe('tokenizeForFts', () => { + it('returns empty string for null/undefined/empty', () => { + assert.equal(tokenizeForFts(null), '') + assert.equal(tokenizeForFts(undefined), '') + assert.equal(tokenizeForFts(''), '') + assert.equal(tokenizeForFts(' '), '') + }) + + it('tokenizes text and lowercases tokens', () => { + const result = tokenizeForFts('Hello World') + assert.ok(result.length > 0) + assert.equal(result, result.toLowerCase()) + }) + + it('produces non-empty output for Chinese text', () => { + const result = tokenizeForFts('今天天气很好') + assert.ok(result.length > 0) + }) + + it('handles mixed CJK and Latin text', () => { + const result = tokenizeForFts('Hello 你好 World') + assert.ok(result.includes('hello')) + }) +}) + +describe('tokenizeQueryForFts', () => { + it('returns empty string for empty array', () => { + assert.equal(tokenizeQueryForFts([]), '') + }) + + it('returns empty string for array of whitespace', () => { + assert.equal(tokenizeQueryForFts([' ', '']), '') + }) + + it('wraps single simple keyword in quotes', () => { + const result = tokenizeQueryForFts(['hello']) + assert.ok(result.includes('"hello"')) + }) + + it('joins multiple keywords with OR', () => { + const result = tokenizeQueryForFts(['hello', 'world']) + assert.ok(result.includes('OR')) + }) + + it('handles Chinese keywords', () => { + const result = tokenizeQueryForFts(['今天开心']) + assert.ok(result.length > 0) + }) +}) diff --git a/packages/node-runtime/src/nlp/dict-manager.ts b/packages/node-runtime/src/nlp/dict-manager.ts new file mode 100644 index 000000000..2fb27f899 --- /dev/null +++ b/packages/node-runtime/src/nlp/dict-manager.ts @@ -0,0 +1,164 @@ +/** + * 词库管理器(Node.js 实现) + * + * 接收 nlpDir 参数而非依赖 Electron app 模块。 + * Electron 和 Server 各自传入自己的 nlpDir 路径。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import { createHash } from 'crypto' +import type { DictInfo } from '@openchatlab/core' +import { clearJiebaInstance } from './segmenter' + +const DICT_DOWNLOAD_URL_BASE = 'https://chatlab.fun/assets/nlp' +const DICT_SHA256: Record = { + 'zh-CN': '139519822fe8ab9e10d9d07e68ea0451045380aedaf54ecc51e2a28c6b42a13f', + 'zh-TW': 'a63ec7e388f16f1b486dcd948a9f1a3b492be5d9b6bdab786a95e59966786dfd', +} + +const AVAILABLE_DICTS: Array<{ id: string; label: string; locale: string }> = [ + { id: 'zh-CN', label: '简体中文', locale: 'zh-CN' }, + { id: 'zh-TW', label: '繁體中文', locale: 'zh-TW' }, +] + +function getDictFilePath(nlpDir: string, dictId: string): string { + return path.join(nlpDir, `${dictId}.dict`) +} + +export function isDictDownloaded(nlpDir: string, dictId: string): boolean { + return fs.existsSync(getDictFilePath(nlpDir, dictId)) +} + +export function getDictList(nlpDir: string): DictInfo[] { + return AVAILABLE_DICTS.map((d) => { + const filePath = getDictFilePath(nlpDir, d.id) + const downloaded = fs.existsSync(filePath) + let fileSize: number | undefined + if (downloaded) { + try { + fileSize = fs.statSync(filePath).size + } catch { + /* ignore */ + } + } + return { ...d, downloaded, fileSize } + }) +} + +export function loadDictBuffer(nlpDir: string, dictId: string): Buffer | null { + const filePath = getDictFilePath(nlpDir, dictId) + if (!fs.existsSync(filePath)) return null + try { + return fs.readFileSync(filePath) + } catch (error) { + console.error(`[NLP DictManager] Failed to read dict file: ${filePath}`, error) + return null + } +} + +export async function downloadDict( + nlpDir: string, + dictId: string, + onProgress?: (percent: number) => void +): Promise<{ success: boolean; error?: string }> { + if (!AVAILABLE_DICTS.find((d) => d.id === dictId)) { + return { success: false, error: `Unknown dict: ${dictId}` } + } + + fs.mkdirSync(nlpDir, { recursive: true }) + const url = `${DICT_DOWNLOAD_URL_BASE}/${dictId}.dict` + const filePath = getDictFilePath(nlpDir, dictId) + const tmpPath = filePath + '.tmp' + + try { + const response = await fetch(url, { signal: AbortSignal.timeout(120_000) }) + if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`) + + const total = Number(response.headers.get('content-length') || 0) + let buffer: Buffer + + if (!response.body) { + buffer = Buffer.from(await response.arrayBuffer()) + } else { + const reader = response.body.getReader() + const chunks: Buffer[] = [] + let loaded = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + const chunk = Buffer.from(value) + chunks.push(chunk) + loaded += chunk.length + if (total > 0 && onProgress) onProgress(Math.round((loaded / total) * 100)) + } + buffer = Buffer.concat(chunks) + } + + const MIN_DICT_SIZE = 1_000_000 + if (buffer.length < MIN_DICT_SIZE) { + return { success: false, error: `Downloaded file is invalid (${buffer.length} bytes)` } + } + const head = buffer.subarray(0, 50).toString('utf-8').trim() + if (head.startsWith(' { + if (isDictDownloaded(nlpDir, 'zh-CN')) return + console.log('[NLP DictManager] zh-CN dict not found, starting background download...') + const result = await downloadDict(nlpDir, 'zh-CN') + if (result.success) { + console.log('[NLP DictManager] zh-CN dict auto-downloaded successfully') + } else { + console.warn('[NLP DictManager] zh-CN dict auto-download failed:', result.error) + } +} diff --git a/packages/node-runtime/src/nlp/fts-tokenizer.ts b/packages/node-runtime/src/nlp/fts-tokenizer.ts new file mode 100644 index 000000000..9775240da --- /dev/null +++ b/packages/node-runtime/src/nlp/fts-tokenizer.ts @@ -0,0 +1,75 @@ +/** + * FTS5 tokenizer — shared implementation. + * + * Unlike NLP word-frequency analysis, FTS tokenization keeps all tokens + * (no POS filtering, no stopwords) to maximize recall in search scenarios. + * + * Uses jieba for Chinese (naturally handles mixed CJK/Latin text), + * falls back to whitespace split when jieba is unavailable. + */ + +import { getJieba } from './segmenter' + +export function tokenizeForFts(text: string | null | undefined): string { + if (!text || text.trim().length === 0) return '' + + try { + const jieba = getJieba() + const tokens = jieba.cut(text, false) + return tokens + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 0) + .join(' ') + } catch { + return fallbackTokenize(text) + } +} + +function fallbackTokenize(text: string): string { + return text + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 0) + .join(' ') +} + +function escapeToken(token: string): string { + return `"${token.replace(/"/g, '""')}"` +} + +/** + * Convert a list of user search keywords into an FTS5 MATCH expression. + * + * - Single keyword: tokens joined with AND (all must appear) + * - Multiple keywords: groups joined with OR (any match) + */ +export function tokenizeQueryForFts(keywords: string[]): string { + if (keywords.length === 0) return '' + + const groups = keywords + .map((kw) => { + const trimmed = kw.trim() + if (!trimmed) return '' + + try { + const jieba = getJieba() + const tokens = jieba + .cut(trimmed, false) + .map((t) => t.trim().toLowerCase()) + .filter((t) => t.length > 0) + + if (tokens.length === 0) return '' + if (tokens.length === 1) return escapeToken(tokens[0]) + return tokens.map(escapeToken).join(' ') + } catch { + const simple = trimmed.toLowerCase().trim() + return simple ? escapeToken(simple) : '' + } + }) + .filter((g) => g.length > 0) + + if (groups.length === 0) return '' + if (groups.length === 1) return groups[0] + + return groups.map((g) => (g.includes(' ') ? `(${g})` : g)).join(' OR ') +} diff --git a/packages/node-runtime/src/nlp/index.ts b/packages/node-runtime/src/nlp/index.ts new file mode 100644 index 000000000..38a2de2f8 --- /dev/null +++ b/packages/node-runtime/src/nlp/index.ts @@ -0,0 +1,34 @@ +/** + * NLP 模块(Node.js 实现) + * + * 分词引擎、词频统计、词库管理的 Node.js 实现。 + * 平台无关的类型和数据从 @openchatlab/core 导出。 + */ + +// 分词引擎 +export { + initNlpDir, + getNlpDir, + getJieba, + clearJiebaInstance, + segment, + batchSegmentWithFrequency, + collectPosTagStats, + getPosTagDefinitions, +} from './segmenter' + +// 词频统计 +export { computeWordFrequency, segmentText } from './word-frequency' + +// FTS tokenizer +export { tokenizeForFts, tokenizeQueryForFts } from './fts-tokenizer' + +// 词库管理 +export { + isDictDownloaded, + getDictList, + loadDictBuffer, + downloadDict, + deleteDict, + ensureDefaultDict, +} from './dict-manager' diff --git a/packages/node-runtime/src/nlp/segmenter.test.ts b/packages/node-runtime/src/nlp/segmenter.test.ts new file mode 100644 index 000000000..370a7cb5b --- /dev/null +++ b/packages/node-runtime/src/nlp/segmenter.test.ts @@ -0,0 +1,89 @@ +/** + * batchSegmentChineseWithStats 等价性测试 + * + * 优化(C):中文 meaningful/custom 模式下用单遍分词同时产出词频与词性统计。 + * 必须与旧实现 batchSegmentWithFrequency + collectPosTagStats 的组合结果完全一致, + * 否则会改变词云与词性分布的展示行为。 + * + * 运行:node --import tsx --test packages/node-runtime/src/nlp/segmenter.test.ts + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { batchSegmentChineseWithStats, batchSegmentWithFrequency, collectPosTagStats } from './segmenter' + +const TEXTS = [ + '今天天气很好,我们一起去公园散步聊天', + '公园里有很多人在散步,天气真的非常好', + '我喜欢在周末去公园看书喝咖啡', + '北京的秋天天气最舒服,适合出去走走', + '聊天的时候要注意倾听对方说话', + '咖啡和书是我周末的最爱', +] + +function assertMapEqual(actual: Map, expected: Map, label: string): void { + assert.equal(actual.size, expected.size, `${label}: size mismatch`) + for (const [key, value] of expected) { + assert.equal(actual.get(key), value, `${label}: value mismatch for "${key}"`) + } +} + +describe('batchSegmentChineseWithStats 与旧两遍分词等价', () => { + it('meaningful 模式:词频/总数/去重数/词性统计均一致', () => { + const opts = { + minLength: 2, + minCount: 1, + topN: 100, + posFilterMode: 'meaningful' as const, + enableStopwords: true, + dictType: 'default' as const, + } + const combined = batchSegmentChineseWithStats(TEXTS, 'zh-CN', opts) + const freqOnly = batchSegmentWithFrequency(TEXTS, 'zh-CN', opts) + const posOnly = collectPosTagStats(TEXTS, 2, true, 'default') + + assertMapEqual(combined.words, freqOnly.words, 'words') + assert.equal(combined.uniqueWords, freqOnly.uniqueWords, 'uniqueWords') + assert.equal(combined.totalWords, freqOnly.totalWords, 'totalWords') + assertMapEqual(combined.posTagStats, posOnly, 'posTagStats') + }) + + it('custom 模式 + 排除词 + minCount:结果一致', () => { + const opts = { + minLength: 2, + minCount: 2, + topN: 50, + posFilterMode: 'custom' as const, + customPosTags: ['n', 'v', 'a'], + enableStopwords: true, + dictType: 'default' as const, + excludeWords: ['公园'], + } + const combined = batchSegmentChineseWithStats(TEXTS, 'zh-CN', opts) + const freqOnly = batchSegmentWithFrequency(TEXTS, 'zh-CN', opts) + const posOnly = collectPosTagStats(TEXTS, 2, true, 'default') + + assertMapEqual(combined.words, freqOnly.words, 'words') + assert.equal(combined.uniqueWords, freqOnly.uniqueWords, 'uniqueWords') + assert.equal(combined.totalWords, freqOnly.totalWords, 'totalWords') + // 词性统计不受 allowedTags / excludeWords 影响,覆盖全部有效词 + assertMapEqual(combined.posTagStats, posOnly, 'posTagStats') + assert.ok(!combined.words.has('公园'), '排除词应被剔除') + }) + + it('topN 截断只影响展示数量,不影响 totalWords/uniqueWords', () => { + const base = { + minLength: 2, + minCount: 1, + posFilterMode: 'meaningful' as const, + enableStopwords: true, + dictType: 'default' as const, + } + const full = batchSegmentChineseWithStats(TEXTS, 'zh-CN', { ...base, topN: 100 }) + const limited = batchSegmentChineseWithStats(TEXTS, 'zh-CN', { ...base, topN: 3 }) + + assert.ok(limited.words.size <= 3) + assert.equal(limited.totalWords, full.totalWords) + assert.equal(limited.uniqueWords, full.uniqueWords) + }) +}) diff --git a/packages/node-runtime/src/nlp/segmenter.ts b/packages/node-runtime/src/nlp/segmenter.ts new file mode 100644 index 000000000..753f0d0a7 --- /dev/null +++ b/packages/node-runtime/src/nlp/segmenter.ts @@ -0,0 +1,312 @@ +/** + * 分词引擎(Node.js 实现,依赖 @node-rs/jieba) + * + * 中文使用 jieba,英文/日语使用 Intl.Segmenter。 + * 类型、停用词、文本处理工具来自 @openchatlab/core。 + */ + +import * as fs from 'fs' +import * as path from 'path' +import { createRequire } from 'module' +import type { + SupportedLocale, + PosFilterMode, + DictType, + SegmentOptions, + BatchSegmentOptions, + BatchSegmentResult, +} from '@openchatlab/core' +import { MEANINGFUL_POS_TAGS, isStopword, cleanText, isValidWord } from '@openchatlab/core' + +interface JiebaInstance { + cut: (text: string, hmm?: boolean) => string[] + tag: (text: string) => Array<{ tag: string; word: string }> +} + +let _nlpDir: string | null = null +const jiebaInstances = new Map() +const require = createRequire(import.meta.url) + +/** + * 设置自定义词库目录路径(由应用初始化时调用) + */ +export function initNlpDir(nlpDir: string): void { + _nlpDir = nlpDir +} + +export function getNlpDir(): string | null { + return _nlpDir +} + +function existsDictOnDisk(dictId: string): boolean { + if (!_nlpDir) return false + return fs.existsSync(path.join(_nlpDir, `${dictId}.dict`)) +} + +function tryLoadDictFromDisk(dictId: string): Buffer | null { + if (!_nlpDir) return null + const dictPath = path.join(_nlpDir, `${dictId}.dict`) + if (!fs.existsSync(dictPath)) return null + try { + return fs.readFileSync(dictPath) + } catch { + return null + } +} + +/** + * 获取 Jieba 实例(支持多词库) + */ +export function getJieba(dictType: DictType = 'default'): JiebaInstance { + const effectiveType = dictType === 'default' ? 'zh-CN' : dictType + const cached = jiebaInstances.get(effectiveType) + if (cached) { + if (existsDictOnDisk(effectiveType)) return cached + jiebaInstances.delete(effectiveType) + console.log(`[NLP] jieba cache invalidated (dict missing): ${effectiveType}`) + } + + try { + const { Jieba } = require('@node-rs/jieba') + let instance: JiebaInstance + const diskDict = tryLoadDictFromDisk(effectiveType) + if (diskDict) { + instance = Jieba.withDict(diskDict) + console.log(`[NLP] jieba dict loaded: ${effectiveType} (${diskDict.length} bytes)`) + } else { + instance = new Jieba() + console.warn(`[NLP] jieba dict missing: ${effectiveType}, fallback to built-in tokenizer`) + } + jiebaInstances.set(effectiveType, instance) + return instance + } catch (error) { + console.error(`[NLP] Failed to load jieba module (dict=${effectiveType}):`, error) + throw new Error(`jieba 模块加载失败 (${effectiveType})`) + } +} + +/** + * 清除指定词库的缓存实例(词库更新后调用) + */ +export function clearJiebaInstance(dictType: DictType): void { + jiebaInstances.delete(dictType) + console.log(`[NLP] jieba instance cleared: ${dictType}`) +} + +function segmentChinese( + text: string, + options: { posFilterMode?: PosFilterMode; customPosTags?: string[]; dictType?: DictType } = {} +): string[] { + const { posFilterMode = 'meaningful', customPosTags, dictType = 'default' } = options + const cleaned = cleanText(text) + if (!cleaned) return [] + + try { + const jieba = getJieba(dictType) + if (posFilterMode === 'all') return jieba.cut(cleaned, false) + const tagged = jieba.tag(cleaned) + const allowedTags = posFilterMode === 'custom' && customPosTags ? new Set(customPosTags) : MEANINGFUL_POS_TAGS + return tagged.filter((item) => allowedTags.has(item.tag)).map((item) => item.word) + } catch (error) { + console.error('[NLP] Chinese segmentation failed:', error) + try { + return getJieba('default').cut(cleanText(text), false) + } catch { + return cleaned.split('') + } + } +} + +function segmentEnglish(text: string): string[] { + const cleaned = cleanText(text) + if (!cleaned) return [] + try { + const segmenter = new Intl.Segmenter('en', { granularity: 'word' }) + return [...segmenter.segment(cleaned)].filter((s) => s.isWordLike).map((s) => s.segment.toLowerCase()) + } catch { + return cleaned + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length > 0) + } +} + +function segmentJapanese(text: string): string[] { + const cleaned = cleanText(text) + if (!cleaned) return [] + try { + const segmenter = new Intl.Segmenter('ja', { granularity: 'word' }) + return [...segmenter.segment(cleaned)].filter((s) => s.isWordLike).map((s) => s.segment) + } catch { + return cleaned.split('').filter((ch) => ch.trim().length > 0) + } +} + +/** + * 通用分词入口 + */ +export function segment(text: string, locale: SupportedLocale, options: SegmentOptions = {}): string[] { + const { + minLength, + posFilterMode = 'meaningful', + customPosTags, + enableStopwords = true, + dictType = 'default', + } = options + const isChinese = locale.startsWith('zh') + const isJapanese = locale === 'ja-JP' + const defaultMinLength = isChinese || isJapanese ? 2 : 3 + const effectiveMinLength = minLength ?? defaultMinLength + + let words: string[] + if (isChinese) { + words = segmentChinese(text, { posFilterMode, customPosTags, dictType }) + } else if (isJapanese) { + words = segmentJapanese(text) + } else { + words = segmentEnglish(text) + } + + return words.filter((word) => isValidWord(word, locale, effectiveMinLength, enableStopwords, isStopword)) +} + +/** + * 批量分词并统计词频 + */ +export function batchSegmentWithFrequency( + texts: string[], + locale: SupportedLocale, + options: BatchSegmentOptions = {} +): BatchSegmentResult { + const { + minLength, + minCount = 2, + topN = 100, + posFilterMode, + customPosTags, + enableStopwords, + dictType, + excludeWords, + } = options + const wordFrequency = new Map() + const excludeSet = excludeWords?.length ? new Set(excludeWords.map((w) => w.toLowerCase())) : null + + for (const text of texts) { + const words = segment(text, locale, { minLength, posFilterMode, customPosTags, enableStopwords, dictType }) + for (const word of words) { + if (excludeSet && excludeSet.has(word.toLowerCase())) continue + wordFrequency.set(word, (wordFrequency.get(word) || 0) + 1) + } + } + + const filtered = new Map() + let totalWords = 0 + for (const [word, count] of wordFrequency) { + if (count >= minCount) { + filtered.set(word, count) + totalWords += count + } + } + + const sorted = [...filtered.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN) + return { words: new Map(sorted), uniqueWords: filtered.size, totalWords } +} + +/** + * 中文单遍分词:一次 jieba.tag 同时产出词频与词性统计。 + * + * 仅适用于中文 meaningful/custom 模式(all 模式走 jieba.cut,无词性)。 + * 输出与 `batchSegmentWithFrequency` + `collectPosTagStats` 组合完全一致, + * 用于消除对同一批文本重复分词的开销。 + */ +export function batchSegmentChineseWithStats( + texts: string[], + locale: SupportedLocale, + options: BatchSegmentOptions = {} +): BatchSegmentResult & { posTagStats: Map } { + const { + minLength, + minCount = 2, + topN = 100, + posFilterMode = 'meaningful', + customPosTags, + enableStopwords = true, + dictType = 'default', + excludeWords, + } = options + + const effectiveMinLength = minLength ?? 2 + const allowedTags = posFilterMode === 'custom' && customPosTags ? new Set(customPosTags) : MEANINGFUL_POS_TAGS + const excludeSet = excludeWords?.length ? new Set(excludeWords.map((w) => w.toLowerCase())) : null + + const wordFrequency = new Map() + const posTagStats = new Map() + + try { + const jieba = getJieba(dictType) + for (const text of texts) { + const cleaned = cleanText(text) + if (!cleaned) continue + for (const { tag, word } of jieba.tag(cleaned)) { + if (!isValidWord(word, locale, effectiveMinLength, enableStopwords, isStopword)) continue + // 词性统计覆盖全部有效词(与 collectPosTagStats 一致) + posTagStats.set(tag, (posTagStats.get(tag) || 0) + 1) + // 词频仅统计命中允许词性的词(与 batchSegmentWithFrequency 一致) + if (!allowedTags.has(tag)) continue + if (excludeSet && excludeSet.has(word.toLowerCase())) continue + wordFrequency.set(word, (wordFrequency.get(word) || 0) + 1) + } + } + } catch (error) { + console.error('[NLP] Chinese single-pass segmentation failed:', error) + } + + const filtered = new Map() + let totalWords = 0 + for (const [word, count] of wordFrequency) { + if (count >= minCount) { + filtered.set(word, count) + totalWords += count + } + } + + const sorted = [...filtered.entries()].sort((a, b) => b[1] - a[1]).slice(0, topN) + return { words: new Map(sorted), uniqueWords: filtered.size, totalWords, posTagStats } +} + +/** + * 收集文本的词性统计 + */ +export function collectPosTagStats( + texts: string[], + minWordLength: number = 2, + enableStopwords: boolean = true, + dictType: DictType = 'default' +): Map { + const posStats = new Map() + try { + const jieba = getJieba(dictType) + for (const text of texts) { + const cleaned = cleanText(text) + if (!cleaned) continue + const tagged = jieba.tag(cleaned) + for (const item of tagged) { + if (!isValidWord(item.word, 'zh-CN', minWordLength, enableStopwords, isStopword)) continue + posStats.set(item.tag, (posStats.get(item.tag) || 0) + 1) + } + } + } catch (error) { + console.error('[NLP] Failed to collect POS stats:', error) + } + return posStats +} + +import { POS_TAG_DEFINITIONS } from '@openchatlab/core' +import type { PosTagInfo } from '@openchatlab/core' + +/** + * 获取所有词性标签信息 + */ +export function getPosTagDefinitions(): PosTagInfo[] { + return POS_TAG_DEFINITIONS +} diff --git a/packages/node-runtime/src/nlp/word-frequency.ts b/packages/node-runtime/src/nlp/word-frequency.ts new file mode 100644 index 000000000..8b236e53e --- /dev/null +++ b/packages/node-runtime/src/nlp/word-frequency.ts @@ -0,0 +1,130 @@ +/** + * 词频统计(Node.js 实现) + * + * 从数据库查询消息,调用分词引擎进行词频统计。 + * 供 Electron worker 和 Server HTTP 路由共用。 + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import type { + SupportedLocale, + DictType, + WordFrequencyParams, + WordFrequencyResult, + PosTagStat, + BatchSegmentResult, +} from '@openchatlab/core' +import { segment, batchSegmentWithFrequency, batchSegmentChineseWithStats, collectPosTagStats } from './segmenter' + +function buildMessageQuery( + timeFilter?: { startTs?: number; endTs?: number }, + memberId?: number +): { clause: string; params: unknown[] } { + const conditions: string[] = [] + const params: unknown[] = [] + + if (timeFilter?.startTs !== undefined) { + conditions.push('msg.ts >= ?') + params.push(timeFilter.startTs) + } + if (timeFilter?.endTs !== undefined) { + conditions.push('msg.ts <= ?') + params.push(timeFilter.endTs) + } + if (memberId !== undefined && memberId !== null) { + conditions.push('msg.sender_id = ?') + params.push(memberId) + } + + conditions.push("COALESCE(m.account_name, '') != '系统消息'") + conditions.push('msg.type = 0') + conditions.push('msg.content IS NOT NULL') + conditions.push("TRIM(msg.content) != ''") + + return { + clause: ` WHERE ${conditions.join(' AND ')}`, + params, + } +} + +/** + * 从数据库计算词频统计 + */ +export function computeWordFrequency(db: DatabaseAdapter, params: WordFrequencyParams): WordFrequencyResult { + const { + locale, + timeFilter, + memberId, + topN = 100, + minWordLength, + minCount = 2, + posFilterMode = 'meaningful', + customPosTags, + enableStopwords = true, + dictType = 'default', + excludeWords, + } = params + + const { clause, params: filterParams } = buildMessageQuery(timeFilter, memberId) + + const messages = db + .prepare(`SELECT msg.content FROM message msg JOIN member m ON msg.sender_id = m.id${clause}`) + .all(...filterParams) as Array<{ content: string }> + + if (messages.length === 0) { + return { words: [], totalWords: 0, totalMessages: 0, uniqueWords: 0 } + } + + const texts = messages.map((m) => m.content) + + const segmentOptions = { + minLength: minWordLength, + minCount, + topN, + posFilterMode, + customPosTags, + enableStopwords, + dictType: dictType as DictType, + excludeWords, + } + + let posTagStats: PosTagStat[] | undefined + let result: BatchSegmentResult + + // 中文 meaningful/custom 模式下,词频与词性统计可在一次分词内同时产出,避免重复分词。 + if (locale.startsWith('zh') && posFilterMode !== 'all') { + const combined = batchSegmentChineseWithStats(texts, locale as SupportedLocale, segmentOptions) + result = { words: combined.words, uniqueWords: combined.uniqueWords, totalWords: combined.totalWords } + posTagStats = [...combined.posTagStats.entries()].map(([tag, count]) => ({ tag, count })) + } else { + if (locale.startsWith('zh')) { + const posStatsMap = collectPosTagStats(texts, minWordLength ?? 2, enableStopwords, dictType as DictType) + posTagStats = [...posStatsMap.entries()].map(([tag, count]) => ({ tag, count })) + } + result = batchSegmentWithFrequency(texts, locale as SupportedLocale, segmentOptions) + } + + let topNTotalWords = 0 + for (const count of result.words.values()) topNTotalWords += count + + const words = [...result.words.entries()].map(([word, count]) => ({ + word, + count, + percentage: topNTotalWords > 0 ? Math.round((count / topNTotalWords) * 10000) / 100 : 0, + })) + + return { + words, + totalWords: result.totalWords, + totalMessages: messages.length, + uniqueWords: result.uniqueWords, + posTagStats, + } +} + +/** + * 单文本分词 + */ +export function segmentText(text: string, locale: SupportedLocale, minLength?: number): string[] { + return segment(text, locale, { minLength }) +} diff --git a/packages/node-runtime/src/node-path-provider.ts b/packages/node-runtime/src/node-path-provider.ts new file mode 100644 index 000000000..ae2fedd31 --- /dev/null +++ b/packages/node-runtime/src/node-path-provider.ts @@ -0,0 +1,162 @@ +/** + * PathProvider 的 Node.js 独立实现 + * + * 为 CLI / npm 服务版提供路径管理,不依赖 Electron。 + * + * 目录分为两类: + * - 系统数据:固定在 ~/.chatlab/,存放配置、日志、缓存、AI 数据等 + * - 用户数据:可配置位置,存放聊天记录数据库等核心资产 + * + * 用户数据目录(userDataDir)解析优先级: + * 1. 构造函数传入的 userDataDir 参数 + * 2. CHATLAB_DATA_DIR 环境变量 + * 3. ~/.chatlab/config.toml → [data] user_data_dir + * 4. 平台默认路径 + */ + +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import type { PathProvider } from '@openchatlab/core' +import { writeConfigField, loadConfig } from '@openchatlab/config' +import { migrateFromElectronIfNeeded } from './migrations/electron-data-migration' +import { applyPendingNodeDataDirMigration } from './data-dir-switch' + +const SYSTEM_DIR = path.join(os.homedir(), '.chatlab') + +/** + * Set after resolveUserDataDir() detects Electron installation but cannot find databases. + * CLI should check this and block execution with instructions. + */ +let _pendingElectronDataWarning = false + +export function hasPendingElectronDataWarning(): boolean { + return _pendingElectronDataWarning +} + +export function applyPendingNodeDataDirMigrationIfNeeded(): { success: boolean; skipped?: boolean; error?: string } { + if (process.env.CHATLAB_DATA_DIR) return { success: true, skipped: true } + return applyPendingNodeDataDirMigration(SYSTEM_DIR) +} + +export class NodePathProvider implements PathProvider { + private systemDir: string + private userDataDir: string + + constructor(userDataDir?: string) { + this.systemDir = SYSTEM_DIR + this.userDataDir = userDataDir || resolveUserDataDir() + } + + getSystemDir(): string { + return this.systemDir + } + + getUserDataDir(): string { + return this.userDataDir + } + + getDatabaseDir(): string { + return path.join(this.userDataDir, 'databases') + } + + getVectorDir(): string { + return path.join(this.userDataDir, 'vector') + } + + getAiDataDir(): string { + return path.join(this.systemDir, 'ai') + } + + getSettingsDir(): string { + return path.join(this.systemDir, 'settings') + } + + getCacheDir(): string { + return path.join(this.systemDir, 'cache') + } + + getTempDir(): string { + return path.join(this.systemDir, 'temp') + } + + getLogsDir(): string { + return path.join(this.systemDir, 'logs') + } + + getDownloadsDir(): string { + return path.join(os.homedir(), 'Downloads') + } + + /** + * 确保系统目录和用户数据目录都存在 + */ + ensureAllDirs(): void { + const dirs = [ + this.systemDir, + this.userDataDir, + this.getDatabaseDir(), + this.getVectorDir(), + this.getAiDataDir(), + this.getSettingsDir(), + this.getCacheDir(), + this.getTempDir(), + this.getLogsDir(), + ] + for (const dir of dirs) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + } + + const markerPath = path.join(this.userDataDir, '.chatlab') + if (!fs.existsSync(markerPath)) { + fs.writeFileSync(markerPath, 'ChatLab Data Directory', 'utf-8') + } + } +} + +function resolveUserDataDir(): string { + const envDir = process.env.CHATLAB_DATA_DIR + if (envDir) { + return expandHome(envDir) + } + + const config = loadConfig() + if (config.data.user_data_dir) { + return expandHome(config.data.user_data_dir) + } + + // First run: check for existing Electron desktop data before using default + const result = migrateFromElectronIfNeeded(SYSTEM_DIR) + if (result.migrated && result.userDataDir) { + return result.userDataDir + } + + const defaultDir = getDefaultNodeUserDataDir() + + if (result.electronDetected) { + // Electron was used but we couldn't find databases — likely a custom data directory. + // Don't persist default to config.toml so migration retries on next startup. + _pendingElectronDataWarning = true + return defaultDir + } + + writeConfigField('data', 'user_data_dir', defaultDir) + return defaultDir +} + +export function getDefaultNodeUserDataDir(): string { + return path.join(os.homedir(), '.chatlab', 'data') +} + +export function getSystemLogsDir(): string { + return path.join(SYSTEM_DIR, 'logs') +} + +function expandHome(filePath: string): string { + if (filePath.startsWith('~/') || filePath === '~') { + return path.join(os.homedir(), filePath.slice(1)) + } + return filePath +} diff --git a/packages/node-runtime/src/preferences.test.ts b/packages/node-runtime/src/preferences.test.ts new file mode 100644 index 000000000..dc93cec1a --- /dev/null +++ b/packages/node-runtime/src/preferences.test.ts @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { PreferencesManager } from './preferences' + +function createTempSystemDir(): string { + return mkdtempSync(join(tmpdir(), 'chatlab-preferences-')) +} + +function readPreferencesFile(systemDir: string): any { + return JSON.parse(readFileSync(join(systemDir, 'preferences.json'), 'utf-8')) +} + +test('migrates old built-in desensitize rules out of preferences with a backup', () => { + const systemDir = createTempSystemDir() + try { + writeFileSync( + join(systemDir, 'preferences.json'), + JSON.stringify({ + aiPreprocessConfig: { + desensitizeRules: [ + { + id: 'api_key_prefix', + label: 'API Key', + pattern: 'sk-[A-Za-z0-9]+', + replacement: '[API Key]', + enabled: false, + builtin: true, + locales: [], + }, + { + id: 'custom_staff_id', + label: 'Staff ID', + pattern: 'EMP-\\d+', + replacement: '[Staff ID]', + enabled: true, + builtin: false, + locales: [], + }, + ], + }, + }) + ) + + const prefs = new PreferencesManager(systemDir).load() + const saved = readPreferencesFile(systemDir) + const backupsDir = join(systemDir, 'backups') + + assert.equal(prefs.aiPreprocessConfig.desensitizeRulesSchemaVersion, 2) + assert.deepEqual(prefs.aiPreprocessConfig.desensitizeBuiltinRuleOverrides, { + api_key_prefix: false, + }) + assert.deepEqual( + prefs.aiPreprocessConfig.desensitizeRules.map((rule) => rule.id), + ['custom_staff_id'] + ) + assert.equal(saved.aiPreprocessConfig.desensitizeRules[0].id, 'custom_staff_id') + assert.deepEqual(saved.aiPreprocessConfig.desensitizeBuiltinRuleOverrides, { + api_key_prefix: false, + }) + assert.equal( + saved.aiPreprocessConfig.desensitizeRules.some((rule: any) => rule.builtin), + false + ) + assert.equal(existsSync(backupsDir), true) + assert.equal( + readdirSync(backupsDir).some((name) => name.startsWith('preferences-pre-desensitize-groups-')), + true + ) + } finally { + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('sanitizes built-in desensitize rule bodies on save', () => { + const systemDir = createTempSystemDir() + try { + const manager = new PreferencesManager(systemDir) + const result = manager.save({ + aiPreprocessConfig: { + dataCleaning: true, + mergeConsecutive: true, + mergeWindowSeconds: 180, + blacklistKeywords: [], + denoise: true, + desensitize: true, + anonymizeNames: false, + desensitizeRulesSchemaVersion: 2, + desensitizeBuiltinRuleOverrides: { + api_key_prefix: true, + url: false, + }, + desensitizeRules: [ + { + id: 'api_key_prefix', + label: 'API Key', + pattern: 'sk-[A-Za-z0-9]+', + replacement: '[API Key]', + enabled: true, + builtin: true, + locales: [], + }, + { + id: 'custom_staff_id', + label: 'Staff ID', + pattern: 'EMP-\\d+', + replacement: '[Staff ID]', + enabled: true, + builtin: false, + locales: [], + }, + ], + }, + }) + + const saved = readPreferencesFile(systemDir) + + assert.equal(result.success, true) + assert.deepEqual(saved.aiPreprocessConfig.desensitizeBuiltinRuleOverrides, { + api_key_prefix: true, + url: false, + }) + assert.deepEqual( + saved.aiPreprocessConfig.desensitizeRules.map((rule: any) => rule.id), + ['custom_staff_id'] + ) + assert.equal(JSON.stringify(saved).includes('sk-[A-Za-z0-9]+'), false) + } finally { + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('persists thinkingLevels across save/load cycle', () => { + const systemDir = createTempSystemDir() + try { + const manager = new PreferencesManager(systemDir) + + const result = manager.save({ thinkingLevels: { 'cfg1:model-a': 'high' } }) + assert.equal(result.success, true) + + // Disk must contain the value (regression: mergeDefaults whitelist must not strip it) + const saved = readPreferencesFile(systemDir) + assert.equal(saved.thinkingLevels?.['cfg1:model-a'], 'high') + + // Cache-invalidated reload must return the persisted value + manager.invalidateCache() + const loaded = manager.load() + assert.equal(loaded.thinkingLevels['cfg1:model-a'], 'high') + } finally { + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('merges thinkingLevels across multiple saves without losing earlier entries', () => { + const systemDir = createTempSystemDir() + try { + const manager = new PreferencesManager(systemDir) + + manager.save({ thinkingLevels: { 'cfg1:model-a': 'high' } }) + manager.save({ thinkingLevels: { 'cfg2:model-b': 'off' } }) + + manager.invalidateCache() + const loaded = manager.load() + assert.equal(loaded.thinkingLevels['cfg1:model-a'], 'high') + assert.equal(loaded.thinkingLevels['cfg2:model-b'], 'off') + } finally { + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('loads thinkingLevels as empty object when field is absent in legacy preferences file', () => { + const systemDir = createTempSystemDir() + try { + // Write a legacy preferences.json that has no thinkingLevels field + writeFileSync( + join(systemDir, 'preferences.json'), + JSON.stringify({ aiGlobalSettings: { maxMessagesPerRequest: 500 } }) + ) + + const loaded = new PreferencesManager(systemDir).load() + assert.deepEqual(loaded.thinkingLevels, {}) + // Existing fields must survive the migration + assert.equal(loaded.aiGlobalSettings.maxMessagesPerRequest, 500) + assert.equal(loaded.aiGlobalSettings.chartAutoMode, 'suggest') + } finally { + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('replaces built-in desensitize overrides with an empty map on save', () => { + const systemDir = createTempSystemDir() + try { + const manager = new PreferencesManager(systemDir) + assert.equal( + manager.save({ + aiPreprocessConfig: { + dataCleaning: true, + mergeConsecutive: true, + mergeWindowSeconds: 180, + blacklistKeywords: [], + denoise: true, + desensitize: true, + anonymizeNames: false, + desensitizeRulesSchemaVersion: 2, + desensitizeBuiltinRuleOverrides: { + api_key_prefix: false, + }, + desensitizeRules: [], + }, + }).success, + true + ) + + assert.equal( + manager.save({ + aiPreprocessConfig: { + dataCleaning: true, + mergeConsecutive: true, + mergeWindowSeconds: 180, + blacklistKeywords: [], + denoise: true, + desensitize: true, + anonymizeNames: false, + desensitizeRulesSchemaVersion: 2, + desensitizeBuiltinRuleOverrides: {}, + desensitizeRules: [], + }, + }).success, + true + ) + + const saved = readPreferencesFile(systemDir) + + assert.deepEqual(saved.aiPreprocessConfig.desensitizeBuiltinRuleOverrides, {}) + } finally { + rmSync(systemDir, { recursive: true, force: true }) + } +}) diff --git a/packages/node-runtime/src/preferences.ts b/packages/node-runtime/src/preferences.ts new file mode 100644 index 000000000..e612c1090 --- /dev/null +++ b/packages/node-runtime/src/preferences.ts @@ -0,0 +1,266 @@ +/** + * PreferencesManager — manages ~/.chatlab/preferences.json + * + * Stores complex user preferences that cannot fit into config.toml + * (arrays, nested objects) and need cross-client consistency. + */ + +import * as fs from 'fs' +import * as path from 'path' +import { DESENSITIZE_RULES_SCHEMA_VERSION } from './ai/preprocessor' +import type { + Preferences, + AIGlobalSettings, + AIPreprocessConfig, + WordFilterScheme, + KeywordTemplate, + ContextCompressionSettings, + DesensitizeRule, + FilterHistoryItem, +} from '@openchatlab/shared-types' + +export type { + Preferences, + AIGlobalSettings, + AIPreprocessConfig, + WordFilterScheme, + KeywordTemplate, + ContextCompressionSettings, + DesensitizeRule, + FilterHistoryItem, +} + +const DEFAULTS: Preferences = { + pinnedSessionIds: [], + aiPreprocessConfig: { + dataCleaning: true, + mergeConsecutive: true, + mergeWindowSeconds: 180, + blacklistKeywords: [], + denoise: true, + desensitize: true, + desensitizeRulesSchemaVersion: DESENSITIZE_RULES_SCHEMA_VERSION, + desensitizeBuiltinRuleOverrides: {}, + desensitizeRules: [], + anonymizeNames: false, + }, + aiGlobalSettings: { + maxMessagesPerRequest: 1000, + exportFormat: 'markdown', + sqlExportFormat: 'csv', + enableAutoSkill: true, + chartAutoMode: 'suggest', + searchContextBefore: 2, + searchContextAfter: 2, + contextCompression: { + enabled: true, + tokenThresholdPercent: 75, + bufferSizePercent: 20, + maxToolResultPercent: 50, + }, + }, + customKeywordTemplates: [], + deletedPresetTemplateIds: [], + wordFilter: { + schemes: [], + defaultSchemeId: null, + sessionSchemeOverrides: {}, + }, + filterHistory: [], + thinkingLevels: {}, + ownerProfilesByPlatform: {}, + ownerPromptDismissedSessionIds: [], +} + +export class PreferencesManager { + private filePath: string + private cache: Preferences | null = null + + constructor(systemDir: string) { + this.filePath = path.join(systemDir, 'preferences.json') + } + + load(): Preferences { + if (this.cache) return this.cache + + try { + if (fs.existsSync(this.filePath)) { + const raw = fs.readFileSync(this.filePath, 'utf-8') + const parsed = JSON.parse(raw) as Partial + const migrated = this.migrateLegacyDesensitizeRules(parsed, raw) + this.cache = this.mergeDefaults(migrated.preferences) + if (migrated.changed) { + this.writePreferences(this.cache) + } + return this.cache + } + } catch (err) { + console.warn('[Preferences] Failed to load preferences.json:', err) + } + + this.cache = { ...DEFAULTS } + return this.cache + } + + save(partial: Partial): { success: boolean; error?: string } { + try { + const current = this.load() + const merged = this.deepMerge( + current as unknown as Record, + partial as unknown as Record + ) + if (Object.prototype.hasOwnProperty.call(partial.aiPreprocessConfig ?? {}, 'desensitizeBuiltinRuleOverrides')) { + const mergedAiPreprocessConfig = merged.aiPreprocessConfig as Record + mergedAiPreprocessConfig.desensitizeBuiltinRuleOverrides = + partial.aiPreprocessConfig?.desensitizeBuiltinRuleOverrides + } + this.cache = this.mergeDefaults(merged as unknown as Partial) + + this.writePreferences(this.cache) + return { success: true } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + console.error('[Preferences] Failed to save:', msg) + return { success: false, error: msg } + } + } + + getFilePath(): string { + return this.filePath + } + + invalidateCache(): void { + this.cache = null + } + + private mergeDefaults(partial: Partial): Preferences { + return { + pinnedSessionIds: partial.pinnedSessionIds ?? DEFAULTS.pinnedSessionIds, + aiPreprocessConfig: partial.aiPreprocessConfig + ? this.normalizeAiPreprocessConfig(partial.aiPreprocessConfig) + : { ...DEFAULTS.aiPreprocessConfig }, + aiGlobalSettings: partial.aiGlobalSettings + ? { + ...DEFAULTS.aiGlobalSettings, + ...partial.aiGlobalSettings, + contextCompression: { + ...DEFAULTS.aiGlobalSettings.contextCompression, + ...(partial.aiGlobalSettings.contextCompression ?? {}), + }, + } + : { ...DEFAULTS.aiGlobalSettings }, + customKeywordTemplates: partial.customKeywordTemplates ?? DEFAULTS.customKeywordTemplates, + deletedPresetTemplateIds: partial.deletedPresetTemplateIds ?? DEFAULTS.deletedPresetTemplateIds, + wordFilter: partial.wordFilter ? { ...DEFAULTS.wordFilter, ...partial.wordFilter } : { ...DEFAULTS.wordFilter }, + filterHistory: partial.filterHistory ?? DEFAULTS.filterHistory, + thinkingLevels: partial.thinkingLevels ?? DEFAULTS.thinkingLevels, + ownerProfilesByPlatform: partial.ownerProfilesByPlatform ?? DEFAULTS.ownerProfilesByPlatform, + ownerPromptDismissedSessionIds: partial.ownerPromptDismissedSessionIds ?? DEFAULTS.ownerPromptDismissedSessionIds, + } + } + + private normalizeAiPreprocessConfig(partial: Partial | undefined): AIPreprocessConfig { + const defaults = DEFAULTS.aiPreprocessConfig + const rules = Array.isArray(partial?.desensitizeRules) ? partial.desensitizeRules : [] + const customRules = rules.filter((rule) => !rule.builtin) + return { + dataCleaning: partial?.dataCleaning ?? defaults.dataCleaning, + mergeConsecutive: partial?.mergeConsecutive ?? defaults.mergeConsecutive, + mergeWindowSeconds: partial?.mergeWindowSeconds ?? defaults.mergeWindowSeconds, + blacklistKeywords: partial?.blacklistKeywords ?? defaults.blacklistKeywords, + denoise: partial?.denoise ?? defaults.denoise, + desensitize: partial?.desensitize ?? defaults.desensitize, + desensitizeRulesSchemaVersion: DESENSITIZE_RULES_SCHEMA_VERSION, + desensitizeBuiltinRuleOverrides: this.normalizeBooleanMap(partial?.desensitizeBuiltinRuleOverrides), + desensitizeRules: customRules, + anonymizeNames: partial?.anonymizeNames ?? defaults.anonymizeNames, + } + } + + private normalizeBooleanMap(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + const result: Record = {} + for (const [key, flag] of Object.entries(value as Record)) { + if (typeof flag === 'boolean') result[key] = flag + } + return result + } + + private migrateLegacyDesensitizeRules( + partial: Partial, + rawContent: string + ): { preferences: Partial; changed: boolean } { + const rules = partial.aiPreprocessConfig?.desensitizeRules + if (!Array.isArray(rules) || !rules.some((rule) => rule.builtin)) { + return { preferences: partial, changed: false } + } + + this.backupLegacyPreferences(rawContent) + + const legacyBuiltinRuleOverrides = rules + .filter((rule) => rule.builtin && typeof rule.enabled === 'boolean') + .reduce>((overrides, rule) => { + overrides[rule.id] = rule.enabled + return overrides + }, {}) + const existingOverrides = this.normalizeBooleanMap(partial.aiPreprocessConfig?.desensitizeBuiltinRuleOverrides) + + return { + preferences: { + ...partial, + aiPreprocessConfig: { + ...partial.aiPreprocessConfig, + desensitizeRulesSchemaVersion: DESENSITIZE_RULES_SCHEMA_VERSION, + desensitizeBuiltinRuleOverrides: { + ...legacyBuiltinRuleOverrides, + ...existingOverrides, + }, + desensitizeRules: rules.filter((rule) => !rule.builtin), + }, + } as Partial, + changed: true, + } + } + + private backupLegacyPreferences(rawContent: string): void { + const backupDir = path.join(path.dirname(this.filePath), 'backups') + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }) + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const backupPath = path.join(backupDir, `preferences-pre-desensitize-groups-${timestamp}.json`) + fs.writeFileSync(backupPath, rawContent, 'utf-8') + console.info(`[Preferences] Backed up legacy desensitize rules to ${backupPath}`) + } + + private writePreferences(preferences: Preferences): void { + const dir = path.dirname(this.filePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(this.filePath, JSON.stringify(preferences, null, 2), 'utf-8') + } + + /** + * Deep merge: arrays are replaced (not concatenated), objects are merged. + */ + private deepMerge(base: Record, override: Record): Record { + const result = { ...base } + for (const [key, value] of Object.entries(override)) { + if (value === undefined) continue + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof base[key] === 'object' && + base[key] !== null && + !Array.isArray(base[key]) + ) { + result[key] = this.deepMerge(base[key] as Record, value as Record) + } else { + result[key] = value + } + } + return result + } +} diff --git a/packages/node-runtime/src/semantic-index/chat-db/adapters.test.ts b/packages/node-runtime/src/semantic-index/chat-db/adapters.test.ts new file mode 100644 index 000000000..cddf6b247 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/chat-db/adapters.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { CHAT_DB_SCHEMA, FTS_TABLE_SCHEMA } from '@openchatlab/core' +import { openBetterSqliteDatabase, type BetterSqliteAdapter } from '../../better-sqlite3-adapter' +import { buildFtsIndex } from '../../fts' +import { createChatDbMessageSource } from './message-source' +import { createChatDbMessageRangeReader } from './message-range-reader' +import { createChatDbFtsSearcher, extractFtsKeywords } from './fts-searcher' + +function makeChatDb(): { db: BetterSqliteAdapter; dir: string } { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-chatdb-')) + const db = openBetterSqliteDatabase(path.join(dir, 'chat.db')) + db.exec(CHAT_DB_SCHEMA) + db.exec(FTS_TABLE_SCHEMA) + + db.exec(` + INSERT INTO member (id, platform_id, account_name, group_nickname) VALUES + (1, 'p1', '张三', '群里的张三'), + (2, 'p2', '李四', NULL); + INSERT INTO message (id, sender_id, ts, type, content) VALUES + (1, 1, 1000, 0, '今天我们讨论一下项目排期'), + (2, 2, 1010, 0, '好的,我觉得需要先确认需求'), + (3, 1, 1020, 1, NULL), + (4, 2, 1030, 0, '排期可以放到下周一'); + `) + buildFtsIndex(db) + return { db, dir } +} + +test('message source returns all messages ordered with ms timestamps and resolved sender', () => { + const { db } = makeChatDb() + const source = createChatDbMessageSource(db, { title: '测试群', kind: 'group' }) + + assert.equal(source.countMessages(), 4) + const messages = source.readAllMessages() + assert.equal(messages.length, 4) + assert.deepEqual( + messages.map((m) => m.id), + [1, 2, 3, 4] + ) + // ts 秒 -> 毫秒 + assert.equal(messages[0].ts, 1000 * 1000) + // group_nickname 优先于 account_name + assert.equal(messages[0].senderName, '群里的张三') + // 无 group_nickname 时回退 account_name + assert.equal(messages[1].senderName, '李四') + // 非文本消息保留在序列中 + assert.equal(messages[2].type, 1) + assert.equal(messages[2].content, null) + db.close() +}) + +test('range reader returns inclusive id range including non-text messages', () => { + const { db } = makeChatDb() + const reader = createChatDbMessageRangeReader(db) + + const range = reader.readRange(2, 3) + assert.deepEqual( + range.map((m) => m.id), + [2, 3] + ) + // 非文本 content 归一为空串 + assert.equal(range[1].content, '') + assert.equal(range[0].ts, 1010 * 1000) + db.close() +}) + +test('fts searcher maps natural-language query to ranked message hits', () => { + const { db } = makeChatDb() + const fts = createChatDbFtsSearcher(db) + + const hits = fts.search('排期', 10) + assert.ok(hits.some((h) => h.id === 1)) + assert.ok(hits.some((h) => h.id === 4)) + assert.ok(!hits.some((h) => h.id === 2)) + // ts 应为毫秒(chat DB ts 为秒,fts-searcher 乘 1000 转换) + assert.ok(hits.every((h) => h.ts > 0)) + db.close() +}) + +test('fts searcher returns empty for blank query', () => { + const { db } = makeChatDb() + const fts = createChatDbFtsSearcher(db) + assert.deepEqual(fts.search(' ', 10), []) + db.close() +}) + +test('extractFtsKeywords tokenizes and dedupes regardless of segmentation granularity', () => { + const keywords = extractFtsKeywords('需求 需求') + assert.ok(keywords.length > 0) + // 去重:无重复 token + assert.equal(new Set(keywords).size, keywords.length) + // 重复输入去重后与单次输入 token 集合一致 + assert.deepEqual(new Set(keywords), new Set(extractFtsKeywords('需求'))) +}) diff --git a/packages/node-runtime/src/semantic-index/chat-db/fts-searcher.ts b/packages/node-runtime/src/semantic-index/chat-db/fts-searcher.ts new file mode 100644 index 000000000..7f7914e77 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/chat-db/fts-searcher.ts @@ -0,0 +1,48 @@ +/** + * 聊天库 FTS 检索适配器 + * + * 把自然语言 query 用 jieba 分词成关键词,作为多关键词传入 searchByFts + * (多关键词以 OR 组合,最大化召回;精度由 dense 路与 RRF 融合保证)。 + * 返回按 FTS rank 排序的 message_id。 + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import { searchByFts } from '../../fts' +import { getJieba } from '../../nlp/segmenter' +import type { FtsSearcher } from '../retrieval/hybrid-search' + +/** 自然语言 query -> 去重关键词列表 */ +export function extractFtsKeywords(query: string): string[] { + const trimmed = query.trim() + if (!trimmed) return [] + try { + const tokens = getJieba() + .cut(trimmed, false) + .map((t) => t.trim()) + .filter((t) => t.length > 0) + return [...new Set(tokens)] + } catch { + return [...new Set(trimmed.split(/\s+/).filter((t) => t.length > 0))] + } +} + +export function createChatDbFtsSearcher(db: DatabaseAdapter): FtsSearcher { + return { + search(query: string, topN: number): Array<{ id: number; ts: number }> { + const keywords = extractFtsKeywords(query) + if (keywords.length === 0) return [] + const { rowids } = searchByFts(db, keywords, topN, 0) + if (rowids.length === 0) return [] + const placeholders = rowids.map(() => '?').join(', ') + const tsRows = db.prepare(`SELECT id, ts FROM message WHERE id IN (${placeholders})`).all(...rowids) as Array<{ + id: number + ts: number + }> + const tsById = new Map(tsRows.map((r) => [r.id, r.ts * 1000])) + return rowids.flatMap((id) => { + const ts = tsById.get(id) + return ts !== undefined ? [{ id, ts }] : [] + }) + }, + } +} diff --git a/packages/node-runtime/src/semantic-index/chat-db/message-range-reader.ts b/packages/node-runtime/src/semantic-index/chat-db/message-range-reader.ts new file mode 100644 index 000000000..18912296f --- /dev/null +++ b/packages/node-runtime/src/semantic-index/chat-db/message-range-reader.ts @@ -0,0 +1,55 @@ +/** + * 聊天库消息区间读取适配器 + * + * 为证据组装提供 parent 边界消息的闭区间原文。使用 (ts, id) 复合边界: + * - ts 范围只精确到秒,同一秒可有多条消息;若只用 ts 边界,会纳入同秒的相邻 parent 消息 + * - (ts, id) 复合条件可精确到行,保证不跨 parent 越界 + * - 同时支持回填旧消息(高 id 但低 ts)后的正确读取 + * 结果按 ts, id 升序返回(与 chunker 排序一致)。证据保留全部消息(含非文本)。 + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import type { EvidenceMessage, MessageRangeReader } from '../retrieval/evidence' + +const SELECT_RANGE = ` + WITH b AS ( + SELECT (SELECT ts FROM message WHERE id = ?) AS s_ts, + (SELECT ts FROM message WHERE id = ?) AS e_ts + ) + SELECT msg.id AS id, + msg.ts AS ts, + msg.content AS content, + msg.sender_id AS senderId, + m.platform_id AS senderPlatformId, + COALESCE(msg.sender_group_nickname, msg.sender_account_name, m.group_nickname, m.account_name, m.platform_id) AS senderName + FROM message msg + JOIN member m ON msg.sender_id = m.id, b + WHERE (msg.ts > b.s_ts OR (msg.ts = b.s_ts AND msg.id >= ?)) + AND (msg.ts < b.e_ts OR (msg.ts = b.e_ts AND msg.id <= ?)) + ORDER BY msg.ts ASC, msg.id ASC +` + +interface RangeRow { + id: number + ts: number + content: string | null + senderId: number | null + senderPlatformId: string | null + senderName: string | null +} + +export function createChatDbMessageRangeReader(db: DatabaseAdapter): MessageRangeReader { + return { + readRange(startId: number, endId: number): EvidenceMessage[] { + const rows = db.prepare(SELECT_RANGE).all(startId, endId, startId, endId) as unknown as RangeRow[] + return rows.map((r) => ({ + id: r.id, + senderName: r.senderName ?? '', + content: r.content ?? '', + ts: r.ts * 1000, + senderId: r.senderId ?? undefined, + senderPlatformId: r.senderPlatformId ?? undefined, + })) + }, + } +} diff --git a/packages/node-runtime/src/semantic-index/chat-db/message-source.ts b/packages/node-runtime/src/semantic-index/chat-db/message-source.ts new file mode 100644 index 000000000..e35f4d3b7 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/chat-db/message-source.ts @@ -0,0 +1,51 @@ +/** + * 聊天库消息来源适配器 + * + * 把聊天库的 message/member 表桥接为 warmup runner 需要的 SemanticMessageSource。 + * + * 时间单位转换:聊天库 message.ts 以秒存储,而 chunker 按毫秒计算时间 gap + * (gapMs = parentGapSeconds * 1000),故此处统一乘 1000 转为毫秒。 + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import type { ChunkSource } from '../chunker' +import type { SemanticMessageSource } from '../warmup/runner' + +const SELECT_ALL_MESSAGES = ` + SELECT msg.id AS id, + msg.ts AS ts, + msg.type AS type, + msg.content AS content, + COALESCE(msg.sender_group_nickname, msg.sender_account_name, m.group_nickname, m.account_name, m.platform_id) AS senderName + FROM message msg + JOIN member m ON msg.sender_id = m.id + ORDER BY msg.ts ASC, msg.id ASC +` + +interface MessageRow { + id: number + ts: number + type: number + content: string | null + senderName: string | null +} + +export function createChatDbMessageSource(db: DatabaseAdapter, source: ChunkSource): SemanticMessageSource { + return { + getSource: () => source, + countMessages: () => { + const row = db.prepare('SELECT COUNT(*) AS c FROM message').get() as { c: number } | undefined + return row?.c ?? 0 + }, + readAllMessages: () => { + const rows = db.prepare(SELECT_ALL_MESSAGES).all() as unknown as MessageRow[] + return rows.map((r) => ({ + id: r.id, + senderName: r.senderName ?? '', + content: r.content, + ts: r.ts * 1000, + type: r.type, + })) + }, + } +} diff --git a/packages/node-runtime/src/semantic-index/chunker-config.test.ts b/packages/node-runtime/src/semantic-index/chunker-config.test.ts new file mode 100644 index 000000000..b002d387d --- /dev/null +++ b/packages/node-runtime/src/semantic-index/chunker-config.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + CHUNKER_VERSION, + DEFAULT_CHUNKER_CONFIG, + computeChunkerConfigHash, + computeDbPathHash, + deriveParentId, + type ChunkerConfig, +} from './chunker-config' + +test('computeChunkerConfigHash is deterministic for equal configs', () => { + const a = computeChunkerConfigHash(DEFAULT_CHUNKER_CONFIG) + const b = computeChunkerConfigHash({ ...DEFAULT_CHUNKER_CONFIG }) + assert.equal(a, b) + assert.match(a, /^[0-9a-f]{64}$/) +}) + +test('computeChunkerConfigHash is independent of object key order', () => { + const reordered: ChunkerConfig = { + semanticVoidSkipThreshold: DEFAULT_CHUNKER_CONFIG.semanticVoidSkipThreshold, + overlapMessages: DEFAULT_CHUNKER_CONFIG.overlapMessages, + childHardMaxTokens: DEFAULT_CHUNKER_CONFIG.childHardMaxTokens, + childHardMaxMessages: DEFAULT_CHUNKER_CONFIG.childHardMaxMessages, + childSoftMaxMessages: DEFAULT_CHUNKER_CONFIG.childSoftMaxMessages, + childTargetMaxChars: DEFAULT_CHUNKER_CONFIG.childTargetMaxChars, + childTargetMinChars: DEFAULT_CHUNKER_CONFIG.childTargetMinChars, + parentMaxTokens: DEFAULT_CHUNKER_CONFIG.parentMaxTokens, + parentGapSeconds: DEFAULT_CHUNKER_CONFIG.parentGapSeconds, + } + assert.equal(computeChunkerConfigHash(reordered), computeChunkerConfigHash(DEFAULT_CHUNKER_CONFIG)) +}) + +test('computeChunkerConfigHash changes when any field changes', () => { + const base = computeChunkerConfigHash(DEFAULT_CHUNKER_CONFIG) + const keys: (keyof ChunkerConfig)[] = [ + 'parentGapSeconds', + 'parentMaxTokens', + 'childTargetMinChars', + 'childTargetMaxChars', + 'childSoftMaxMessages', + 'childHardMaxMessages', + 'childHardMaxTokens', + 'overlapMessages', + 'semanticVoidSkipThreshold', + ] + for (const key of keys) { + const mutated = { ...DEFAULT_CHUNKER_CONFIG, [key]: DEFAULT_CHUNKER_CONFIG[key] + 1 } + assert.notEqual(computeChunkerConfigHash(mutated), base, `hash should change when ${key} changes`) + } +}) + +test('computeDbPathHash is a stable 16-char hex prefix and path-sensitive', () => { + const h1 = computeDbPathHash('/data/a.db') + const h2 = computeDbPathHash('/data/a.db') + const h3 = computeDbPathHash('/data/b.db') + assert.equal(h1, h2) + assert.notEqual(h1, h3) + assert.match(h1, /^[0-9a-f]{16}$/) +}) + +test('deriveParentId embeds range, gap, version and config hash', () => { + const id = deriveParentId({ + startMessageId: 100, + endMessageId: 220, + gapSeconds: 1800, + chunkerVersion: CHUNKER_VERSION, + chunkerConfigHash: 'cfgabc', + }) + assert.equal(id, `parent:100:220:1800:${CHUNKER_VERSION}:cfgabc`) +}) + +test('deriveParentId differs when config hash differs', () => { + const common = { startMessageId: 1, endMessageId: 9, gapSeconds: 1800, chunkerVersion: CHUNKER_VERSION } + assert.notEqual( + deriveParentId({ ...common, chunkerConfigHash: 'x' }), + deriveParentId({ ...common, chunkerConfigHash: 'y' }) + ) +}) diff --git a/packages/node-runtime/src/semantic-index/chunker-config.ts b/packages/node-runtime/src/semantic-index/chunker-config.ts new file mode 100644 index 000000000..cb509bac6 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/chunker-config.ts @@ -0,0 +1,125 @@ +/** + * Chunker 版本、运行时参数与 hash 派生(纯函数) + * + * 设计依据 chunking-decision-final.md 第 7/8/9/11 节: + * - chunker_version 是代码常量,算法/格式语义变化时 bump,触发全量重建。 + * - chunker_config_hash 由运行时参数生成,参数变化只影响当前索引。 + * - parent_id 必须包含 config hash,避免错误复用旧 parent 缓存。 + * - db_path_hash 用于在 embedding_index.db 中区分不同聊天库。 + */ + +import { createHash } from 'crypto' + +/** + * Chunker 算法/格式语义版本。 + * + * bump 条件(见文档第 11 节): + * - header 模板结构变化。 + * - 切片算法语义变化。 + * - 语义真空过滤规则本身变化。 + */ +export const CHUNKER_VERSION = 'v1.2' + +/** Phase 1 固定策略标识 */ +export const STRATEGY_ID = 'balanced' + +/** 运行时 chunker 参数(进入 chunker_config_hash) */ +export interface ChunkerConfig { + /** parent 时间 gap 阈值(秒),兜底 1800 */ + parentGapSeconds: number + /** parent token 硬上限 */ + parentMaxTokens: number + /** child 目标最小有效字符:达到消息数软上限时仍需满足此下限才关闭,避免 header 主导的过短 chunk */ + childTargetMinChars: number + /** child 目标最大有效字符 */ + childTargetMaxChars: number + /** child 消息数软上限:消息数达到且有效字符达到 min 时关闭(应对高频短消息群聊) */ + childSoftMaxMessages: number + /** child 消息数硬上限:消息数达到即强制关闭,即使有效字符不足 min */ + childHardMaxMessages: number + /** child token 硬上限 */ + childHardMaxTokens: number + /** overlap 保留末尾消息条数 */ + overlapMessages: number + /** 过滤语义真空后有效字符低于该值的 chunk 跳过索引 */ + semanticVoidSkipThreshold: number +} + +/** + * Phase 1 默认 chunker 参数(见决策表)。 + * 短 chunk + 多候选策略:缩短 child 窗口提升语义定位精度,配合检索侧更高 max_results + * 与 evidence/token 预算控制最终注入量。 + */ +export const DEFAULT_CHUNKER_CONFIG: ChunkerConfig = { + parentGapSeconds: 1800, + parentMaxTokens: 2000, + childTargetMinChars: 120, + childTargetMaxChars: 360, + childSoftMaxMessages: 12, + childHardMaxMessages: 20, + childHardMaxTokens: 1200, + overlapMessages: 2, + semanticVoidSkipThreshold: 20, +} + +function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf-8').digest('hex') +} + +/** + * 运行时参数 hash。显式按固定字段顺序序列化,确保与传入对象的键顺序无关。 + */ +export function computeChunkerConfigHash(config: ChunkerConfig): string { + const canonical = JSON.stringify({ + parentGapSeconds: config.parentGapSeconds, + parentMaxTokens: config.parentMaxTokens, + childTargetMinChars: config.childTargetMinChars, + childTargetMaxChars: config.childTargetMaxChars, + childSoftMaxMessages: config.childSoftMaxMessages, + childHardMaxMessages: config.childHardMaxMessages, + childHardMaxTokens: config.childHardMaxTokens, + overlapMessages: config.overlapMessages, + semanticVoidSkipThreshold: config.semanticVoidSkipThreshold, + }) + return sha256Hex(canonical) +} + +/** 对话稳定标识(sessionId 或相关稳定字符串)的 SHA256 前缀,用于区分不同聊天库的 chunk */ +export function computeDbPathHash(stableKey: string): string { + return sha256Hex(stableKey).slice(0, 16) +} + +/** + * 组合全局唯一 chunk_id。chunker 输出模型无关的 localChunkId,写入存储时由 + * db_path_hash + model_id 组合成跨对话、跨模型唯一的 chunk_id。 + */ +export function composeChunkId(dbPathHash: string, modelId: string, localChunkId: string): string { + return `${dbPathHash}:${modelId}:${localChunkId}` +} + +/** + * 稳定派生 parent id。包含 config hash,参数变化后 parent id 也变化。 + * 格式:`parent:${start}:${end}:${gap}:${version}:${configHash}` + */ +export function deriveParentId(params: { + startMessageId: number + endMessageId: number + gapSeconds: number + chunkerVersion: string + chunkerConfigHash: string +}): string { + return `parent:${params.startMessageId}:${params.endMessageId}:${params.gapSeconds}:${params.chunkerVersion}:${params.chunkerConfigHash}` +} + +/** + * 从 parent id 解析 parent 的消息 id 边界,用于证据扩展时限定"不跨 parent"。 + * 格式不匹配时返回 null。 + */ +export function parseParentBounds(parentId: string): { startMessageId: number; endMessageId: number } | null { + const segments = parentId.split(':') + if (segments.length < 3 || segments[0] !== 'parent') return null + const startMessageId = Number(segments[1]) + const endMessageId = Number(segments[2]) + if (!Number.isFinite(startMessageId) || !Number.isFinite(endMessageId)) return null + return { startMessageId, endMessageId } +} diff --git a/packages/node-runtime/src/semantic-index/chunker.test.ts b/packages/node-runtime/src/semantic-index/chunker.test.ts new file mode 100644 index 000000000..6778511f5 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/chunker.test.ts @@ -0,0 +1,182 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { chunkMessages, isSemanticVoid, type ChunkMessageInput, type ChunkSource } from './chunker' +import { DEFAULT_CHUNKER_CONFIG, type ChunkerConfig } from './chunker-config' + +const MINUTE = 60_000 + +const groupSource: ChunkSource = { title: '家庭群', kind: 'group' } +const privateSource: ChunkSource = { title: '张三', kind: 'private' } + +// 小参数,便于用短消息触发切分 +const smallConfig: ChunkerConfig = { + ...DEFAULT_CHUNKER_CONFIG, + parentGapSeconds: 600, + parentMaxTokens: 100000, + childTargetMinChars: 10, + childTargetMaxChars: 30, + childHardMaxTokens: 100000, + overlapMessages: 2, + semanticVoidSkipThreshold: 5, +} + +function msg(id: number, content: string | null, minute: number, sender = '张三', type?: number): ChunkMessageInput { + return { id, content, ts: minute * MINUTE, senderName: sender, type } +} + +test('isSemanticVoid detects fillers, placeholders, empty and void types', () => { + assert.equal(isSemanticVoid(msg(1, '好的', 0)), true) + assert.equal(isSemanticVoid(msg(1, '嗯嗯', 0)), true) + assert.equal(isSemanticVoid(msg(1, '哈哈哈', 0)), true) + assert.equal(isSemanticVoid(msg(1, '在吗?', 0)), true) + assert.equal(isSemanticVoid(msg(1, '[图片]', 0)), true) + assert.equal(isSemanticVoid(msg(1, ' ', 0)), true) + assert.equal(isSemanticVoid(msg(1, '某条系统通知', 0, '张三', 80)), true) + assert.equal(isSemanticVoid(msg(1, '这周末一起去看房,下午两点在地铁站集合', 0)), false) +}) + +test('time gap splits messages into separate parents', () => { + const messages = [ + msg(1, '第一段话题:周末出游计划讨论得差不多了', 0), + msg(2, '大家记得带身份证和雨具', 1), + // 大间隔 -> 新 parent + msg(3, '第二段完全不同的话题:报税材料准备情况', 100), + msg(4, '记得把发票扫描件发我邮箱', 101), + ] + const result = chunkMessages({ messages, source: groupSource, config: smallConfig }) + const parentIds = new Set(result.chunks.map((c) => c.parentId)) + assert.equal(parentIds.size, 2) + assert.equal(result.parentCount, 2) +}) + +test('child chunks split by effective chars and overlap by trailing messages', () => { + const messages = Array.from({ length: 6 }, (_, i) => msg(i + 1, '一二三四五六七八九十', i * 0.1)) + const result = chunkMessages({ messages, source: groupSource, config: smallConfig }) + + assert.ok(result.chunks.length >= 2, 'should split into multiple children') + + const first = result.chunks[0] + const second = result.chunks[1] + // overlap:第二个 child 的起始消息应落在第一个 child 末尾 overlapMessages 条之内 + const firstTailIds = new Set([first.endMessageId]) + assert.ok(second.startMessageId <= first.endMessageId, 'second child should start within first child range (overlap)') + assert.ok(firstTailIds.size > 0) +}) + +test('child closes at soft message cap once min effective chars met', () => { + const cfg: ChunkerConfig = { + ...DEFAULT_CHUNKER_CONFIG, + parentGapSeconds: 100000, + parentMaxTokens: 100000, + childTargetMaxChars: 100000, + childHardMaxTokens: 100000, + childTargetMinChars: 8, + childSoftMaxMessages: 4, + childHardMaxMessages: 100, + overlapMessages: 0, + semanticVoidSkipThreshold: 1, + } + const messages = Array.from({ length: 8 }, (_, i) => msg(i + 1, `话题内容${i}`, 0)) + const result = chunkMessages({ messages, source: groupSource, config: cfg }) + assert.equal(result.chunks.length, 2) + assert.ok( + result.chunks.every((c) => c.messageCount === 4), + 'each child should close exactly at the soft message cap' + ) +}) + +test('child force-closes at hard message cap even below min effective chars', () => { + const cfg: ChunkerConfig = { + ...DEFAULT_CHUNKER_CONFIG, + parentGapSeconds: 100000, + parentMaxTokens: 100000, + childTargetMaxChars: 100000, + childHardMaxTokens: 100000, + childTargetMinChars: 50, + childSoftMaxMessages: 4, + childHardMaxMessages: 6, + overlapMessages: 0, + semanticVoidSkipThreshold: 1, + } + // 每条仅 2 个有效字符,4 条时(8)未达 min(50),soft 不触发,靠 hard(6) 强制关闭 + const messages = Array.from({ length: 12 }, (_, i) => msg(i + 1, '甲乙', 0)) + const result = chunkMessages({ messages, source: groupSource, config: cfg }) + assert.equal(result.chunks.length, 2) + assert.ok( + result.chunks.every((c) => c.messageCount === 6), + 'each child should be force-closed at the hard message cap' + ) +}) + +test('semantic void messages are excluded from embedding body, header and effective chars', () => { + const messages = [ + msg(1, '我们讨论一下下周的装修进度安排', 0, '张三'), + msg(2, '嗯嗯', 0, '李四'), + msg(3, '好的', 0, '王五'), + msg(4, '主要是水电改造和地板铺设两块', 0, '张三'), + ] + const result = chunkMessages({ messages, source: groupSource, config: smallConfig }) + const chunk = result.chunks[0] + + assert.ok(!chunk.embeddingInput.includes('嗯嗯'), 'void content must not enter embedding body') + assert.ok(!chunk.embeddingInput.includes('好的')) + assert.ok(chunk.embeddingInput.includes('装修进度')) + // header 参与者只含非真空消息发送者 + assert.ok(chunk.embeddingInput.includes('张三')) + assert.ok(!chunk.embeddingInput.includes('李四')) + assert.ok(!chunk.embeddingInput.includes('王五')) + // messageCount 含真空消息(范围覆盖),effectiveChars 不含 + assert.equal(chunk.messageCount, 4) +}) + +test('chunks below skip threshold are skipped, all-void input yields no chunks', () => { + const allVoid = [msg(1, '嗯', 0), msg(2, '好的', 0), msg(3, '哈哈', 0)] + const result = chunkMessages({ messages: allVoid, source: groupSource, config: smallConfig }) + assert.equal(result.chunks.length, 0) + assert.ok(result.skippedCount >= 1) +}) + +test('group header contains source/time/participants; private header omits participants', () => { + const groupMessages = [msg(1, '这是一段足够长的群聊内容用于生成 header 测试', 0, '张三')] + const group = chunkMessages({ messages: groupMessages, source: groupSource, config: smallConfig }) + assert.match(group.chunks[0].embeddingInput, /\[来源\] 家庭群(群聊)/) + assert.match(group.chunks[0].embeddingInput, /\[时间范围\]/) + assert.match(group.chunks[0].embeddingInput, /\[参与者\]/) + + const privateMessages = [msg(1, '这是一段足够长的私聊内容用于生成 header 测试', 0, '张三')] + const priv = chunkMessages({ messages: privateMessages, source: privateSource, config: smallConfig }) + assert.match(priv.chunks[0].embeddingInput, /\[来源\] 张三(私聊)/) + assert.doesNotMatch(priv.chunks[0].embeddingInput, /\[参与者\]/) +}) + +test('hashes are deterministic and sensitive to content vs embedding-input changes', () => { + const messages = [msg(1, '一个足够长的用于哈希稳定性测试的聊天内容片段', 0, '张三')] + const a = chunkMessages({ messages, source: groupSource, config: smallConfig }).chunks[0] + const b = chunkMessages({ messages, source: groupSource, config: smallConfig }).chunks[0] + assert.equal(a.rawContentHash, b.rawContentHash) + assert.equal(a.embeddingInputHash, b.embeddingInputHash) + + // 改消息内容 -> raw 与 embedding hash 都变 + const changed = chunkMessages({ + messages: [msg(1, '一个足够长的用于哈希稳定性测试的不同聊天内容片段', 0, '张三')], + source: groupSource, + config: smallConfig, + }).chunks[0] + assert.notEqual(changed.rawContentHash, a.rawContentHash) + assert.notEqual(changed.embeddingInputHash, a.embeddingInputHash) + + // 只改来源(影响 header/embedding 输入),raw 不变、embedding hash 变 + const otherSource = chunkMessages({ + messages, + source: { title: '工作群', kind: 'group' }, + config: smallConfig, + }).chunks[0] + assert.equal(otherSource.rawContentHash, a.rawContentHash) + assert.notEqual(otherSource.embeddingInputHash, a.embeddingInputHash) +}) + +test('empty input yields empty result', () => { + const result = chunkMessages({ messages: [], source: groupSource, config: smallConfig }) + assert.equal(result.chunks.length, 0) + assert.equal(result.parentCount, 0) +}) diff --git a/packages/node-runtime/src/semantic-index/chunker.ts b/packages/node-runtime/src/semantic-index/chunker.ts new file mode 100644 index 000000000..78ebf1948 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/chunker.ts @@ -0,0 +1,336 @@ +/** + * Parent/child chunker(纯函数) + * + * 实现 chunking-decision-final.md 第 7/8/9/10 节的 Hierarchical Balanced Chunking: + * - parent:按时间 gap 粗分,超 token 估算上限再拆 sub-parent。 + * - child:parent 内按有效字符滑动,overlap 保留末尾若干条消息。 + * - 语义真空消息:不计有效字符、不进 header、不进 embedding body,但保留在范围与 + * raw_content_hash 中(证据可读性)。 + * - header:来源 + 时间范围 + 参与者(Contextual Retrieval)。 + * + * 本模块与具体模型/维度无关;全局 chunk_id 由 warmup 层用 db_path_hash + model_id + * 组合本模块输出的 localChunkId 得到。 + */ + +import { createHash } from 'crypto' +import { MessageType } from '@openchatlab/shared-types' +import { + CHUNKER_VERSION, + DEFAULT_CHUNKER_CONFIG, + computeChunkerConfigHash, + deriveParentId, + type ChunkerConfig, +} from './chunker-config' +import { estimateTokens } from './tokens' + +export interface ChunkMessageInput { + id: number + senderName: string + content: string | null + /** 毫秒时间戳 */ + ts: number + /** 平台消息类型(用于识别语义真空类型) */ + type?: number +} + +export interface ChunkSource { + /** 对话名称(群名或私聊对端名) */ + title: string + kind: 'group' | 'private' +} + +export interface ChunkMessagesInput { + messages: ChunkMessageInput[] + source: ChunkSource + config?: ChunkerConfig +} + +export interface ChildChunk { + /** 模型无关的稳定本地 id;全局 chunk_id 由 warmup 组合 db_path_hash + model_id */ + localChunkId: string + parentId: string + startMessageId: number + endMessageId: number + startTs: number + endTs: number + /** 范围内消息总数(含语义真空) */ + messageCount: number + /** 非真空消息有效字符数 */ + effectiveChars: number + rawContentHash: string + embeddingInputHash: string + /** 送入 embedding 模型的最终文本(header + 非真空 body) */ + embeddingInput: string +} + +export interface ChunkResult { + chunkerVersion: string + chunkerConfigHash: string + chunks: ChildChunk[] + skippedCount: number + parentCount: number +} + +// ==================== 语义真空判定 ==================== + +/** + * 语义真空消息类型(canonical MessageType):媒体/占位/系统类,自身不携带可嵌入文本。 + * TEXT(0) 与 LINK/SHARE/REPLY/FORWARD 等携带文本的类型不在此集合,由内容规则进一步判定。 + */ +const VOID_MESSAGE_TYPES = new Set([ + MessageType.IMAGE, + MessageType.VOICE, + MessageType.VIDEO, + MessageType.FILE, + MessageType.EMOJI, + MessageType.LOCATION, + MessageType.RED_PACKET, + MessageType.TRANSFER, + MessageType.POKE, + MessageType.CALL, + MessageType.CONTACT, + MessageType.SYSTEM, + MessageType.RECALL, +]) + +/** 占位类标签(与平台导出格式匹配,保持原始语言不变) */ +const VOID_TEXT_PATTERNS = [ + '[图片]', + '[语音]', + '[视频]', + '[文件]', + '[表情]', + '[动画表情]', + '[位置]', + '[名片]', + '[红包]', + '[转账]', + '[撤回消息]', +] + +const VOID_REVOKE_PATTERNS = ['撤回了一条消息'] + +/** 短填充语集合(语义真空),变更需 bump CHUNKER_VERSION */ +const VOID_FILLERS = new Set([ + '好', + '好的', + '好滴', + '好呀', + '嗯', + '嗯嗯', + '嗯呢', + '哦', + '噢', + '额', + '呃', + '哈', + '哈哈', + '哈哈哈', + '呵呵', + '在', + '在吗', + '在么', + '收到', + '行', + '行吧', + '可以', + '是', + '是的', + '对', + '对的', + 'ok', + 'okay', + '👍', + '👌', + '🙏', +]) + +/** 归一化用于填充语匹配:去首尾空白、去末尾标点、英文小写 */ +function normalizeFiller(content: string): string { + return content + .trim() + .toLowerCase() + .replace(/[。.!!??、,,~~\s]+$/u, '') +} + +export function isSemanticVoid(message: ChunkMessageInput): boolean { + if (message.type !== undefined && VOID_MESSAGE_TYPES.has(message.type)) return true + + const content = (message.content ?? '').trim() + if (content.length === 0) return true + + if (VOID_TEXT_PATTERNS.includes(content)) return true + if (VOID_REVOKE_PATTERNS.some((p) => content.includes(p))) return true + + return VOID_FILLERS.has(normalizeFiller(content)) +} + +// ==================== 文本与 token 估算 ==================== + +function effectiveContentOf(message: ChunkMessageInput): string { + return isSemanticVoid(message) ? '' : (message.content ?? '').trim() +} + +function sumEffectiveChars(messages: ChunkMessageInput[]): number { + let total = 0 + for (const m of messages) total += effectiveContentOf(m).length + return total +} + +function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf-8').digest('hex') +} + +// ==================== header / body ==================== + +function pad2(n: number): string { + return String(n).padStart(2, '0') +} + +function formatDateTime(ts: number): string { + const d = new Date(ts) + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` +} + +function formatTime(ts: number): string { + const d = new Date(ts) + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}` +} + +function formatTimeRange(startTs: number, endTs: number): string { + const start = new Date(startTs) + const end = new Date(endTs) + const sameDay = + start.getFullYear() === end.getFullYear() && + start.getMonth() === end.getMonth() && + start.getDate() === end.getDate() + return `${formatDateTime(startTs)} - ${sameDay ? formatTime(endTs) : formatDateTime(endTs)}` +} + +function buildEmbeddingInput(source: ChunkSource, messages: ChunkMessageInput[]): string { + const effective = messages.filter((m) => !isSemanticVoid(m)) + const startTs = messages[0].ts + const endTs = messages[messages.length - 1].ts + + const lines: string[] = [] + const kindLabel = source.kind === 'group' ? '群聊' : '私聊' + lines.push(`[来源] ${source.title}(${kindLabel})`) + lines.push(`[时间范围] ${formatTimeRange(startTs, endTs)}`) + if (source.kind === 'group') { + const participants = [...new Set(effective.map((m) => m.senderName))] + lines.push(`[参与者] ${participants.join('、')}`) + } + lines.push('') + for (const m of effective) { + lines.push(`${m.senderName}: ${(m.content ?? '').trim()}`) + } + return lines.join('\n') +} + +function rawContentHashOf(messages: ChunkMessageInput[]): string { + return sha256Hex(messages.map((m) => `${m.id}:${m.content ?? ''}`).join('\n')) +} + +// ==================== parent / child 切分 ==================== + +function segmentParents(messages: ChunkMessageInput[], config: ChunkerConfig): ChunkMessageInput[][] { + const parents: ChunkMessageInput[][] = [] + let current: ChunkMessageInput[] = [] + const gapMs = config.parentGapSeconds * 1000 + + for (const m of messages) { + if (current.length === 0) { + current.push(m) + continue + } + const prev = current[current.length - 1] + const overGap = m.ts - prev.ts > gapMs + const overTokens = estimateTokens(current.map((x) => (x.content ?? '').trim()).join('\n')) >= config.parentMaxTokens + if (overGap || overTokens) { + parents.push(current) + current = [m] + } else { + current.push(m) + } + } + if (current.length > 0) parents.push(current) + return parents +} + +function buildChildDrafts(parent: ChunkMessageInput[], config: ChunkerConfig): ChunkMessageInput[][] { + const drafts: ChunkMessageInput[][] = [] + let current: ChunkMessageInput[] = [] + let newCount = 0 + + const closeAndSeed = () => { + drafts.push(current) + const seed = config.overlapMessages > 0 ? current.slice(-config.overlapMessages) : [] + current = [...seed] + newCount = 0 + } + + for (const m of parent) { + current.push(m) + newCount++ + const effectiveChars = sumEffectiveChars(current) + const messageCount = current.length + const overChars = effectiveChars >= config.childTargetMaxChars + const overTokens = estimateTokens(current.map((x) => effectiveContentOf(x)).join('\n')) >= config.childHardMaxTokens + // 消息数软上限:高频短消息群聊里,攒够消息数且有效字符达标即关闭,避免单 chunk 混入过多消息 + const overSoftMessages = messageCount >= config.childSoftMaxMessages && effectiveChars >= config.childTargetMinChars + // 消息数硬上限:即使有效字符不足 min 也强制关闭,避免极端短消息无限堆积 + const overHardMessages = messageCount >= config.childHardMaxMessages + if (overChars || overTokens || overSoftMessages || overHardMessages) closeAndSeed() + } + + // flush:仅当还有新消息(避免重复上一个 child 的 overlap 尾巴) + if (current.length > 0 && (newCount > 0 || drafts.length === 0)) { + drafts.push(current) + } + return drafts +} + +export function chunkMessages(input: ChunkMessagesInput): ChunkResult { + const config = input.config ?? DEFAULT_CHUNKER_CONFIG + const chunkerConfigHash = computeChunkerConfigHash(config) + + const parents = segmentParents(input.messages, config) + const chunks: ChildChunk[] = [] + let skippedCount = 0 + + for (const parent of parents) { + const parentId = deriveParentId({ + startMessageId: parent[0].id, + endMessageId: parent[parent.length - 1].id, + gapSeconds: config.parentGapSeconds, + chunkerVersion: CHUNKER_VERSION, + chunkerConfigHash, + }) + + for (const draft of buildChildDrafts(parent, config)) { + const effectiveChars = sumEffectiveChars(draft) + if (effectiveChars < config.semanticVoidSkipThreshold) { + skippedCount++ + continue + } + const startMessageId = draft[0].id + const endMessageId = draft[draft.length - 1].id + const embeddingInput = buildEmbeddingInput(input.source, draft) + chunks.push({ + localChunkId: `${parentId}#${startMessageId}-${endMessageId}`, + parentId, + startMessageId, + endMessageId, + startTs: draft[0].ts, + endTs: draft[draft.length - 1].ts, + messageCount: draft.length, + effectiveChars, + rawContentHash: rawContentHashOf(draft), + embeddingInputHash: sha256Hex(embeddingInput), + embeddingInput, + }) + } + } + + return { chunkerVersion: CHUNKER_VERSION, chunkerConfigHash, chunks, skippedCount, parentCount: parents.length } +} diff --git a/packages/node-runtime/src/semantic-index/config.test.ts b/packages/node-runtime/src/semantic-index/config.test.ts new file mode 100644 index 000000000..9ce492593 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/config.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { + SemanticIndexConfigStore, + canRunSemanticIndex, + defaultSemanticIndexConfig, + resolveModelId, + type SemanticIndexConfig, +} from './config' + +function tempConfigPath(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-si-config-')) + return path.join(dir, 'ai', 'semantic-index-config.json') +} + +test('default config is enabled with no model preselected', () => { + const store = new SemanticIndexConfigStore(tempConfigPath()) + const config = store.get() + assert.equal(config.mode, 'local') + assert.equal(config.enabled, true) + assert.equal(config.local.modelId, '') + assert.equal(config.api, null) +}) + +test('isConfigured is false until a model is chosen', () => { + const store = new SemanticIndexConfigStore(tempConfigPath()) + assert.equal(store.isConfigured(), false) + // 仅切换全局开关不算已选模型 + store.setEnabled(false) + assert.equal(store.isConfigured(), false) + assert.equal(store.isEnabled(), false) + // 选择本地模型后才算已配置 + store.set({ ...store.get(), enabled: true, local: { modelId: 'local-test' } }) + assert.equal(store.isConfigured(), true) + assert.equal(store.isEnabled(), true) +}) + +test('canRunSemanticIndex requires both enabled switch and explicit model config', () => { + assert.equal(canRunSemanticIndex(defaultSemanticIndexConfig()), false) + assert.equal( + canRunSemanticIndex({ + ...defaultSemanticIndexConfig(), + local: { modelId: 'local-test' }, + }), + true + ) + assert.equal( + canRunSemanticIndex({ + ...defaultSemanticIndexConfig(), + enabled: false, + local: { modelId: 'local-test' }, + }), + false + ) + assert.equal( + canRunSemanticIndex({ + ...defaultSemanticIndexConfig(), + mode: 'api', + api: { baseUrl: 'https://api.example.com/v1', model: 'text-embed' }, + }), + true + ) +}) + +test('old config without enabled field defaults to enabled', () => { + const filePath = tempConfigPath() + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, JSON.stringify({ version: 1, mode: 'local', local: { modelId: 'm' }, api: null })) + const store = new SemanticIndexConfigStore(filePath) + assert.equal(store.isEnabled(), true) + assert.equal(store.isConfigured(), true) +}) + +test('set then get roundtrips and creates directory', () => { + const store = new SemanticIndexConfigStore(tempConfigPath()) + const next: SemanticIndexConfig = { + version: 1, + enabled: true, + mode: 'api', + local: { modelId: 'x' }, + api: { baseUrl: 'https://api.example.com/v1', model: 'text-embed', authProfile: 'p1', dim: 1024 }, + searchMaxResults: 5, + } + store.set(next) + const loaded = store.get() + assert.equal(loaded.mode, 'api') + assert.equal(loaded.api?.baseUrl, 'https://api.example.com/v1') + assert.equal(loaded.api?.model, 'text-embed') + assert.equal(loaded.api?.authProfile, 'p1') +}) + +test('resolveModelId reflects local model id', () => { + const config = { ...defaultSemanticIndexConfig(), local: { modelId: 'local-test' } } + assert.equal(resolveModelId(config), 'local-test') +}) + +test('resolveModelId for api combines baseUrl and model', () => { + const config: SemanticIndexConfig = { + version: 1, + enabled: true, + mode: 'api', + local: { modelId: 'x' }, + api: { baseUrl: 'https://h/v1', model: 'm1' }, + searchMaxResults: 5, + } + assert.equal(resolveModelId(config), 'api:https://h/v1#m1') +}) + +test('changing only api key (authProfile) keeps model identity stable (no rebuild)', () => { + const a: SemanticIndexConfig = { + version: 1, + enabled: true, + mode: 'api', + local: { modelId: 'x' }, + api: { baseUrl: 'https://h/v1', model: 'm1', authProfile: 'p1' }, + searchMaxResults: 5, + } + const b: SemanticIndexConfig = { ...a, api: { ...a.api!, authProfile: 'p2' } } + assert.equal(resolveModelId(a), resolveModelId(b)) +}) + +test('changing api model changes identity (rebuild needed)', () => { + const a: SemanticIndexConfig = { + version: 1, + enabled: true, + mode: 'api', + local: { modelId: 'x' }, + api: { baseUrl: 'https://h/v1', model: 'm1' }, + searchMaxResults: 5, + } + const b: SemanticIndexConfig = { ...a, api: { ...a.api!, model: 'm2' } } + assert.notEqual(resolveModelId(a), resolveModelId(b)) +}) + +test('malformed config file falls back to default', () => { + const filePath = tempConfigPath() + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, '{ not json') + const store = new SemanticIndexConfigStore(filePath) + assert.equal(store.get().mode, 'local') +}) diff --git a/packages/node-runtime/src/semantic-index/config.ts b/packages/node-runtime/src/semantic-index/config.ts new file mode 100644 index 000000000..827051eb4 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/config.ts @@ -0,0 +1,184 @@ +/** + * 语义索引独立配置 + * + * 存储位置:~/.chatlab/ai/semantic-index-config.json(独立于聊天 LLM 的 provider/model 管理)。 + * API Key 不落本配置,只保存 auth-profiles.json 中的 authProfile 引用。 + * + * Phase 1 只有一个"当前向量配置":local 或 OpenAI-compatible。 + * + * 重建信号:模型身份(resolveModelId)变化即需要重建。 + * - 换本地模型 / API baseUrl / API 模型名 -> 身份变化 -> 已启用对话索引需重建。 + * - 只改 API Key(authProfile 指向内容)-> 身份不变 -> 不重建。 + * chunk 以模型身份作为 model_id 分区,新配置重建完成前自然查不到旧索引。 + */ + +import fs from 'node:fs' +import path from 'node:path' + +export type SemanticIndexMode = 'local' | 'api' + +export interface SemanticIndexLocalConfig { + /** 本地模型 profile 的 modelId */ + modelId: string +} + +export interface SemanticIndexApiConfig { + baseUrl: string + model: string + /** auth-profiles.json 中的 profile 引用(只存引用,不存 key) */ + authProfile?: string + /** API 模型维度;未知时运行时由返回向量长度确定 */ + dim?: number +} + +export interface SemanticIndexConfig { + version: number + /** 全局功能开关:关闭后不暴露 AI 检索工具、不建立/检索索引(已有索引数据保留) */ + enabled: boolean + mode: SemanticIndexMode + local: SemanticIndexLocalConfig + api: SemanticIndexApiConfig | null + /** AI 单次语义检索默认返回片段数(范围 5-15) */ + searchMaxResults: number +} + +/** setConfig 入参:searchMaxResults / enabled 可省略(缺省由 normalize 填充默认值) */ +export type SemanticIndexConfigInput = Omit & { + searchMaxResults?: number + enabled?: boolean +} + +export const SEMANTIC_INDEX_CONFIG_VERSION = 1 + +/** 单次检索默认片段数与用户可配置范围 */ +export const SEARCH_MAX_RESULTS_DEFAULT = 10 +export const SEARCH_MAX_RESULTS_MIN = 5 +export const SEARCH_MAX_RESULTS_MAX = 15 +/** LLM 单次检索片段数硬上限(高于用户可配置范围,仍受证据/token 预算约束) */ +export const SEARCH_MAX_RESULTS_HARD_CAP = 20 + +export function clampSearchMaxResults(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return SEARCH_MAX_RESULTS_DEFAULT + return Math.max(SEARCH_MAX_RESULTS_MIN, Math.min(SEARCH_MAX_RESULTS_MAX, Math.floor(value))) +} + +/** + * 默认配置:功能开启,但不预选任何向量模型(modelId 为空 = 未选择)。 + * 用户必须在设置中显式选择模型后才视为已配置(isConfigured)。 + */ +export function defaultSemanticIndexConfig(): SemanticIndexConfig { + return { + version: SEMANTIC_INDEX_CONFIG_VERSION, + enabled: true, + mode: 'local', + local: { modelId: '' }, + api: null, + searchMaxResults: SEARCH_MAX_RESULTS_DEFAULT, + } +} + +/** + * 当前配置的模型身份。作为 chunk 的 model_id 分区键与重建信号。 + */ +export function resolveModelId(config: SemanticIndexConfig): string { + if (config.mode === 'api' && config.api) { + return `api:${config.api.baseUrl}#${config.api.model}` + } + return config.local.modelId +} + +/** 用户是否已显式选择向量模型(本地有 modelId,或 API 已填 baseUrl+model)。 */ +export function isSemanticIndexConfigured(config: SemanticIndexConfig): boolean { + if (config.mode === 'local') return config.local.modelId.trim().length > 0 + return !!config.api && config.api.baseUrl.trim().length > 0 && config.api.model.trim().length > 0 +} + +/** 是否允许建立/检索索引:必须同时开启全局开关并完成模型配置。 */ +export function canRunSemanticIndex(config: SemanticIndexConfig): boolean { + return config.enabled && isSemanticIndexConfigured(config) +} + +/** Local Ollama's OpenAI-compatible endpoint does not require authentication. */ +export function isKeylessSemanticIndexApiBaseUrl(baseUrl: string | undefined): boolean { + if (!baseUrl) return false + try { + const url = new URL(baseUrl) + const host = url.hostname.toLowerCase() + const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1' + const port = url.port || (url.protocol === 'https:' ? '443' : '80') + return isLoopback && port === '11434' + } catch { + return false + } +} + +function normalizeConfig(raw: Partial | null | undefined): SemanticIndexConfig { + const base = defaultSemanticIndexConfig() + if (!raw || typeof raw !== 'object') return base + const mode: SemanticIndexMode = raw.mode === 'api' ? 'api' : 'local' + const api = raw.api + ? { + baseUrl: raw.api.baseUrl ?? '', + model: raw.api.model ?? '', + authProfile: raw.api.authProfile, + dim: raw.api.dim, + } + : null + return { + version: SEMANTIC_INDEX_CONFIG_VERSION, + // 旧配置无 enabled 字段时默认开启,保证既有用户功能不被意外关闭 + enabled: raw.enabled ?? true, + mode, + local: { modelId: raw.local?.modelId ?? base.local.modelId }, + api, + searchMaxResults: SEARCH_MAX_RESULTS_DEFAULT, + } +} + +export class SemanticIndexConfigStore { + private filePath: string + + constructor(filePath: string) { + this.filePath = filePath + } + + /** 用户是否已显式选择向量模型(本地有 modelId,或 API 已填 baseUrl+model)。未选择时为 false。 */ + isConfigured(): boolean { + return isSemanticIndexConfigured(this.get()) + } + + isEnabled(): boolean { + return this.get().enabled + } + + canRun(): boolean { + return canRunSemanticIndex(this.get()) + } + + /** 仅更新全局开关,保持已选模型与其余配置不变 */ + setEnabled(enabled: boolean): SemanticIndexConfig { + return this.set({ ...this.get(), enabled }) + } + + get(): SemanticIndexConfig { + if (!fs.existsSync(this.filePath)) return defaultSemanticIndexConfig() + try { + const raw = JSON.parse(fs.readFileSync(this.filePath, 'utf-8')) as Partial + return normalizeConfig(raw) + } catch { + return defaultSemanticIndexConfig() + } + } + + set(config: SemanticIndexConfigInput): SemanticIndexConfig { + const normalized = normalizeConfig(config) + const dir = path.dirname(this.filePath) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(this.filePath, JSON.stringify(normalized, null, 2), 'utf-8') + return normalized + } + + resolveModelId(): string { + return resolveModelId(this.get()) + } +} diff --git a/packages/node-runtime/src/semantic-index/embedder-factory.test.ts b/packages/node-runtime/src/semantic-index/embedder-factory.test.ts new file mode 100644 index 000000000..ac52aa9af --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedder-factory.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createEmbedder } from './embedder-factory' +import { QWEN3_PROFILE } from './embedding/profiles' +import type { SemanticIndexConfig } from './config' +import type { FetchFn } from './embedding/api' +import type { LocalPipelineFactory } from './embedding/local' + +const localConfig: SemanticIndexConfig = { + version: 1, + enabled: true, + mode: 'local', + local: { modelId: QWEN3_PROFILE.modelId }, + api: null, + searchMaxResults: 5, +} + +test('builds local provider from profile with injected pipeline factory', async () => { + let seenProxyUrl: string | undefined + const fakeFactory: LocalPipelineFactory = async () => async (texts) => + texts.map(() => new Array(QWEN3_PROFILE.dim).fill(0.1)) + const fakeFactoryWithCapture: LocalPipelineFactory = async (params) => { + seenProxyUrl = params.modelDownloadProxyUrl + return fakeFactory(params) + } + const embedder = createEmbedder(localConfig, { + localPipelineFactory: fakeFactoryWithCapture, + modelDownloadProxyUrl: 'http://127.0.0.1:7890', + }) + assert.equal(embedder.modelId, QWEN3_PROFILE.modelId) + assert.equal(embedder.dim, QWEN3_PROFILE.dim) + const [vector] = await embedder.embedDocuments(['hello']) + assert.equal(vector.length, QWEN3_PROFILE.dim) + assert.equal(seenProxyUrl, 'http://127.0.0.1:7890') +}) + +test('throws for unknown local model', () => { + const config: SemanticIndexConfig = { ...localConfig, local: { modelId: 'does-not-exist' } } + assert.throws(() => createEmbedder(config), /unknown local embedding model/) +}) + +test('builds api provider and resolves key via authProfile', async () => { + const config: SemanticIndexConfig = { + version: 1, + enabled: true, + mode: 'api', + local: { modelId: 'x' }, + api: { baseUrl: 'https://api.example.com/v1', model: 'embed-1', authProfile: 'profileA' }, + searchMaxResults: 5, + } + + let usedKey = '' + const fetchFn: FetchFn = async (_url, init) => { + const headers = init.headers as Record + usedKey = headers.Authorization + return { + ok: true, + status: 200, + text: async () => '', + json: async () => ({ data: [{ index: 0, embedding: [0.1, 0.2, 0.3] }] }), + } + } + + const embedder = createEmbedder(config, { + resolveApiKey: (_provider, authProfile) => (authProfile === 'profileA' ? 'secret-key' : ''), + fetchFn, + }) + const [vector] = await embedder.embedDocuments(['hi']) + assert.equal(vector.length, 3) + assert.equal(usedKey, 'Bearer secret-key') +}) + +test('throws when api config incomplete', () => { + const config: SemanticIndexConfig = { + version: 1, + enabled: true, + mode: 'api', + local: { modelId: 'x' }, + api: { baseUrl: '', model: '' }, + searchMaxResults: 5, + } + assert.throws(() => createEmbedder(config), /API config incomplete/) +}) diff --git a/packages/node-runtime/src/semantic-index/embedder-factory.ts b/packages/node-runtime/src/semantic-index/embedder-factory.ts new file mode 100644 index 000000000..9f96d35ad --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedder-factory.ts @@ -0,0 +1,53 @@ +/** + * Embedder 工厂 + * + * 从语义索引配置构造 EmbeddingProvider: + * - local:按 modelId 取静态 profile,注入本地模型缓存目录。 + * - api:OpenAI-compatible,API Key 从 auth-profiles.json 按 authProfile 引用解析。 + * + * resolveApiKey / pipelineFactory / fetchFn 均可注入,便于单测不联网、不下载模型。 + */ + +import { resolveApiKey as defaultResolveApiKey } from '@openchatlab/config' +import { OpenAICompatibleEmbeddingProvider, type FetchFn } from './embedding/api' +import { LocalEmbeddingProvider, type LocalPipelineFactory } from './embedding/local' +import { getLocalProfileByModelId } from './embedding/profiles' +import type { EmbeddingProvider } from './embedding/types' +import type { SemanticIndexConfig } from './config' + +export interface EmbedderFactoryDeps { + /** 本地模型目录,例如 ~/.chatlab/ai/models/semantic-index */ + modelsCacheDir?: string + /** Optional HTTP(S) proxy URL used only for downloading local embedding model files. */ + modelDownloadProxyUrl?: string + /** auth-profiles 解析,默认走 @openchatlab/config */ + resolveApiKey?: (provider: string, authProfile?: string) => string + /** 本地 pipeline 工厂注入(测试用,不下载模型) */ + localPipelineFactory?: LocalPipelineFactory + /** API fetch 注入(测试用,不联网) */ + fetchFn?: FetchFn +} + +export function createEmbedder(config: SemanticIndexConfig, deps: EmbedderFactoryDeps = {}): EmbeddingProvider { + if (config.mode === 'api') { + if (!config.api || !config.api.baseUrl || !config.api.model) { + throw new Error('semantic index API config incomplete: baseUrl and model are required') + } + const resolve = deps.resolveApiKey ?? defaultResolveApiKey + const apiKey = resolve('', config.api.authProfile) + return new OpenAICompatibleEmbeddingProvider( + { baseUrl: config.api.baseUrl, apiKey, model: config.api.model }, + { fetchFn: deps.fetchFn } + ) + } + + const profile = getLocalProfileByModelId(config.local.modelId) + if (!profile) { + throw new Error(`unknown local embedding model: ${config.local.modelId}`) + } + return new LocalEmbeddingProvider(profile, { + cacheDir: deps.modelsCacheDir, + modelDownloadProxyUrl: deps.modelDownloadProxyUrl, + pipelineFactory: deps.localPipelineFactory, + }) +} diff --git a/packages/node-runtime/src/semantic-index/embedding/api.ts b/packages/node-runtime/src/semantic-index/embedding/api.ts new file mode 100644 index 000000000..c14c30f5d --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedding/api.ts @@ -0,0 +1,96 @@ +/** + * OpenAI-compatible embedding API provider + * + * Phase 1 只承诺 OpenAI-compatible /embeddings 调用路径,配置仅 baseUrl/apiKey/model, + * 不暴露 dimensions(只记录 provider 实际返回维度)。API 模式 query 与 document 一致, + * 不加本地模型的 queryInstruction。 + * + * fetch 可注入,单元测试验证请求结构与响应解析,不联网。 + */ + +import type { EmbeddingProvider } from './types' + +/** API embedding 默认一次提交 8 个 chunk,兼容更多 OpenAI-compatible 服务的批量上限。 */ +export const API_DOCUMENT_BATCH_SIZE = 8 + +export interface OpenAICompatibleConfig { + baseUrl: string + apiKey: string + model: string + /** 单文本 token 上限提示,默认 8192 */ + maxTokens?: number +} + +interface EmbeddingResponse { + data: Array<{ index: number; embedding: number[] }> +} + +export type FetchFn = ( + url: string, + init: { method: string; headers: Record; body: string } +) => Promise<{ ok: boolean; status: number; text(): Promise; json(): Promise }> + +const defaultFetchFn: FetchFn = (url, init) => fetch(url, init) as unknown as ReturnType + +function buildEmbeddingsUrl(baseUrl: string): string { + const trimmed = baseUrl.replace(/\/+$/, '') + return trimmed.endsWith('/embeddings') ? trimmed : `${trimmed}/embeddings` +} + +export class OpenAICompatibleEmbeddingProvider implements EmbeddingProvider { + readonly modelId: string + readonly maxTokens: number + readonly documentBatchSize = API_DOCUMENT_BATCH_SIZE + + private config: OpenAICompatibleConfig + private fetchFn: FetchFn + private url: string + private knownDim = 0 + + constructor(config: OpenAICompatibleConfig, options: { fetchFn?: FetchFn } = {}) { + this.config = config + this.fetchFn = options.fetchFn ?? defaultFetchFn + this.modelId = config.model + this.maxTokens = config.maxTokens ?? 8192 + this.url = buildEmbeddingsUrl(config.baseUrl) + } + + /** 实际返回维度;首次成功调用后才确定,调用前为 0 */ + get dim(): number { + return this.knownDim + } + + async embedDocuments(texts: string[]): Promise { + if (texts.length === 0) return [] + const headers: Record = { + 'Content-Type': 'application/json', + } + if (this.config.apiKey.trim()) headers.Authorization = `Bearer ${this.config.apiKey}` + + const response = await this.fetchFn(this.url, { + method: 'POST', + headers, + body: JSON.stringify({ model: this.config.model, input: texts }), + }) + + if (!response.ok) { + await response.text().catch(() => '') // consume body to avoid connection leak + throw new Error(`Embedding API request failed: ${response.status}`) + } + + const payload = (await response.json()) as EmbeddingResponse + const sorted = [...payload.data].sort((a, b) => a.index - b.index) + const vectors = sorted.map((item) => Float32Array.from(item.embedding)) + if (vectors.length > 0) this.knownDim = vectors[0].length + return vectors + } + + async embedQuery(text: string): Promise { + const [vector] = await this.embedDocuments([text]) + return vector + } + + async preload(): Promise { + // API mode: no local model to download + } +} diff --git a/packages/node-runtime/src/semantic-index/embedding/index.ts b/packages/node-runtime/src/semantic-index/embedding/index.ts new file mode 100644 index 000000000..7568e3db9 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedding/index.ts @@ -0,0 +1,17 @@ +/** + * Embedding provider 模块 + */ + +export type { EmbeddingProvider, EmbeddingPooling } from './types' +export { + QWEN3_PROFILE, + BGE_BASE_PROFILE, + getLocalProfilesForLocale, + getLocalProfileByModelId, + type LocalEmbeddingProfile, +} from './profiles' +export { applyQueryInstruction, clampTextChars } from './text' +export { LocalEmbeddingProvider } from './local' +export type { LocalPipelineFactory, FeatureExtractFn, LocalEmbeddingProviderOptions } from './local' +export { OpenAICompatibleEmbeddingProvider } from './api' +export type { OpenAICompatibleConfig, FetchFn } from './api' diff --git a/packages/node-runtime/src/semantic-index/embedding/local.ts b/packages/node-runtime/src/semantic-index/embedding/local.ts new file mode 100644 index 000000000..fbbf1362b --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedding/local.ts @@ -0,0 +1,142 @@ +/** + * 本地 ONNX embedding provider(@huggingface/transformers) + * + * 关键约束(P0-3):Qwen3 必须 batch=1,batch 会污染 last_token embedding;本地 CPU + * 上 batch 也无吞吐收益。profile.maxBatchSize 控制单次推理的最大文本数。 + * + * pipeline 工厂可注入,单元测试用 fake 工厂验证 batch 切分与 query instruction,不下载模型; + * 真实模型加载在 env-gated smoke 测试中验证。 + */ + +import type { EmbeddingProvider } from './types' +import type { LocalEmbeddingProfile } from './profiles' +import { applyQueryInstruction, clampTextChars } from './text' + +type UndiciFetch = (typeof import('undici'))['fetch'] +type UndiciRequestInit = NonNullable[1]> +const SOCKS_PROXY_PROTOCOLS = new Set(['socks:', 'socks4:', 'socks5:']) +export const LOCAL_ONNX_SESSION_OPTIONS = { intraOpNumThreads: 1, interOpNumThreads: 1 } as const + +/** 一次特征抽取调用:输入文本数组,返回每条文本的向量(number[][]) */ +export type FeatureExtractFn = ( + texts: string[], + options: { pooling: LocalEmbeddingProfile['pooling']; normalize: boolean } +) => Promise + +/** pipeline 工厂:按 modelId/dtype/cacheDir 构造特征抽取器 */ +export type LocalPipelineFactory = (params: { + modelId: string + dtype?: 'fp32' | 'q8' + cacheDir?: string + modelDownloadProxyUrl?: string + sessionOptions?: typeof LOCAL_ONNX_SESSION_OPTIONS +}) => Promise + +export async function createProxyFetch(proxyUrl: string): Promise { + const parsed = new URL(proxyUrl) + if (SOCKS_PROXY_PROTOCOLS.has(parsed.protocol)) { + throw new Error( + `SOCKS proxy is not supported for local embedding model downloads: ${parsed.protocol}//${parsed.host}` + ) + } + const { fetch: undiciFetch, ProxyAgent } = await import('undici') + const dispatcher = new ProxyAgent(proxyUrl) + return ((input, init) => { + const nextInit: UndiciRequestInit = { ...(init as UndiciRequestInit | undefined), dispatcher } + return undiciFetch(input as Parameters[0], nextInit) as unknown as ReturnType + }) as typeof fetch +} + +const defaultPipelineFactory: LocalPipelineFactory = async ({ modelId, dtype, cacheDir, modelDownloadProxyUrl }) => { + const transformers = await import('@huggingface/transformers') + if (cacheDir) { + transformers.env.cacheDir = cacheDir + transformers.env.allowRemoteModels = true + } + if (modelDownloadProxyUrl) { + transformers.env.fetch = await createProxyFetch(modelDownloadProxyUrl) + } + const extractor = await transformers.pipeline('feature-extraction', modelId, { + ...(dtype ? { dtype } : {}), + session_options: LOCAL_ONNX_SESSION_OPTIONS, + }) + return async (texts, options) => { + const out = await extractor(texts, { pooling: options.pooling, normalize: options.normalize }) + return out.tolist() as number[][] + } +} + +export interface LocalEmbeddingProviderOptions { + cacheDir?: string + modelDownloadProxyUrl?: string + pipelineFactory?: LocalPipelineFactory +} + +export class LocalEmbeddingProvider implements EmbeddingProvider { + readonly modelId: string + readonly dim: number + readonly maxTokens: number + readonly documentBatchSize: number + + private profile: LocalEmbeddingProfile + private options: LocalEmbeddingProviderOptions + private extractorPromise: Promise | null = null + + constructor(profile: LocalEmbeddingProfile, options: LocalEmbeddingProviderOptions = {}) { + this.profile = profile + this.options = options + this.modelId = profile.modelId + this.dim = profile.dim + this.maxTokens = profile.maxTokens + this.documentBatchSize = profile.maxBatchSize ?? 1 + } + + private getExtractor(): Promise { + if (!this.extractorPromise) { + const factory = this.options.pipelineFactory ?? defaultPipelineFactory + this.extractorPromise = factory({ + modelId: this.profile.modelId, + dtype: this.profile.dtype, + cacheDir: this.options.cacheDir, + modelDownloadProxyUrl: this.options.modelDownloadProxyUrl, + sessionOptions: LOCAL_ONNX_SESSION_OPTIONS, + }).catch((error) => { + this.extractorPromise = null + throw error + }) + } + return this.extractorPromise + } + + private toFloat32(vector: number[]): Float32Array { + if (vector.length !== this.profile.dim) { + throw new Error(`model ${this.profile.modelId} returned dim ${vector.length}, expected ${this.profile.dim}`) + } + return Float32Array.from(vector) + } + + async embedDocuments(texts: string[]): Promise { + if (texts.length === 0) return [] + const extractor = await this.getExtractor() + const batchSize = this.profile.maxBatchSize ?? texts.length + const clamped = texts.map((t) => clampTextChars(t, this.profile.maxTextChars)) + + const result: Float32Array[] = [] + for (let i = 0; i < clamped.length; i += batchSize) { + const batch = clamped.slice(i, i + batchSize) + const vectors = await extractor(batch, { pooling: this.profile.pooling, normalize: this.profile.normalize }) + for (const v of vectors) result.push(this.toFloat32(v)) + } + return result + } + + async embedQuery(text: string): Promise { + const withInstruction = applyQueryInstruction(this.profile.queryInstruction, text) + const [vector] = await this.embedDocuments([withInstruction]) + return vector + } + + async preload(): Promise { + await this.getExtractor() + } +} diff --git a/packages/node-runtime/src/semantic-index/embedding/profiles.test.ts b/packages/node-runtime/src/semantic-index/embedding/profiles.test.ts new file mode 100644 index 000000000..d062fffde --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedding/profiles.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { BGE_BASE_PROFILE, QWEN3_PROFILE, getLocalProfileByModelId, getLocalProfilesForLocale } from './profiles' +import { applyQueryInstruction, clampTextChars } from './text' + +test('BGE base zh profile uses cls pooling and 768 dim', () => { + assert.equal(BGE_BASE_PROFILE.modelId, 'Xenova/bge-base-zh-v1.5') + assert.equal(BGE_BASE_PROFILE.dim, 768) + assert.equal(BGE_BASE_PROFILE.pooling, 'cls') + assert.equal(BGE_BASE_PROFILE.normalize, true) + assert.equal(BGE_BASE_PROFILE.dtype, 'fp32') + assert.ok(BGE_BASE_PROFILE.queryInstruction.length > 0) +}) + +test('Qwen3 profile enforces batch=1 and last_token pooling', () => { + assert.equal(QWEN3_PROFILE.modelId, 'onnx-community/Qwen3-Embedding-0.6B-ONNX') + assert.equal(QWEN3_PROFILE.dim, 1024) + assert.equal(QWEN3_PROFILE.pooling, 'last_token') + assert.equal(QWEN3_PROFILE.normalize, true) + assert.equal(QWEN3_PROFILE.dtype, 'q8') + assert.equal(QWEN3_PROFILE.maxBatchSize, 1) + assert.equal(QWEN3_PROFILE.maxTextChars, 2400) +}) + +test('locale registry exposes BGE base only to Chinese UI', () => { + const zh = getLocalProfilesForLocale('zh-CN') + assert.deepEqual( + zh.map((p) => p.modelId), + [QWEN3_PROFILE.modelId, BGE_BASE_PROFILE.modelId] + ) + + for (const locale of ['en-US', 'ja-JP']) { + const profiles = getLocalProfilesForLocale(locale) + assert.deepEqual( + profiles.map((p) => p.modelId), + [QWEN3_PROFILE.modelId] + ) + } +}) + +test('getLocalProfileByModelId resolves known models and null otherwise', () => { + assert.equal(getLocalProfileByModelId(QWEN3_PROFILE.modelId)?.dim, 1024) + assert.equal(getLocalProfileByModelId(BGE_BASE_PROFILE.modelId)?.dim, 768) + assert.equal(getLocalProfileByModelId('unknown/model'), null) +}) + +test('applyQueryInstruction prefixes query, no-op on empty instruction', () => { + assert.equal(applyQueryInstruction('指令:', '查询内容'), '指令:查询内容') + assert.equal(applyQueryInstruction(' ', '查询内容'), '查询内容') +}) + +test('clampTextChars truncates only when over the limit', () => { + assert.equal(clampTextChars('abcdef', 3), 'abc') + assert.equal(clampTextChars('abc', 10), 'abc') + assert.equal(clampTextChars('abc'), 'abc') +}) diff --git a/packages/node-runtime/src/semantic-index/embedding/profiles.ts b/packages/node-runtime/src/semantic-index/embedding/profiles.ts new file mode 100644 index 000000000..ae49abb36 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedding/profiles.ts @@ -0,0 +1,76 @@ +/** + * 本地 embedding 模型 profile(静态定义,单一事实来源) + * + * 取值依据 chunking-decision-final.md 第 6.2/6.3 节与 P0-2/P0-3 验证结论。 + * 这些 profile 决定 chunk 的 dim/pooling/normalize,变化会触发索引重建。 + */ + +import type { EmbeddingPooling } from './types' + +export interface LocalEmbeddingProfile { + /** 用户展示名 */ + displayName: string + /** transformers.js 下载源 modelId */ + modelId: string + architecture: 'bert' | 'qwen3' + dim: number + /** 单文本 token 上限(ChatLab profile cap,非模型真实上限) */ + maxTokens: number + /** 单文本字符上限(可选护栏) */ + maxTextChars?: number + pooling: EmbeddingPooling + normalize: boolean + /** transformers.js dtype;BGE 用 fp32,Qwen3 用 q8 */ + dtype?: 'fp32' | 'q8' + /** 固定 batch 上限;Qwen3 必须为 1(P0-3:batch 污染 last_token) */ + maxBatchSize?: number + /** query 前缀指令;document 不加 */ + queryInstruction: string + /** 近似下载体积(MB),用于 UI 提示 */ + approxDownloadMB: number +} + +/** Qwen3-Embedding-0.6B:通用本地推荐模型,必须 batch=1(P0-3) */ +export const QWEN3_PROFILE: LocalEmbeddingProfile = { + displayName: 'Qwen3 Embedding 0.6B', + modelId: 'onnx-community/Qwen3-Embedding-0.6B-ONNX', + architecture: 'qwen3', + dim: 1024, + maxTokens: 8192, + maxTextChars: 2400, + pooling: 'last_token', + normalize: true, + dtype: 'q8', + maxBatchSize: 1, + queryInstruction: 'Given a chat history search query, retrieve relevant conversation messages that answer the query', + approxDownloadMB: 593, +} + +/** BGE base zh v1.5:中文轻量入口,比 small 强、比 Qwen3 轻(cls pooling,512 token 上限) */ +export const BGE_BASE_PROFILE: LocalEmbeddingProfile = { + displayName: 'BGE base zh', + modelId: 'Xenova/bge-base-zh-v1.5', + architecture: 'bert', + dim: 768, + maxTokens: 512, + pooling: 'cls', + normalize: true, + dtype: 'fp32', + queryInstruction: '为这个句子生成表示以用于检索相关文章:', + approxDownloadMB: 390, +} + +const ALL_LOCAL_PROFILES: LocalEmbeddingProfile[] = [QWEN3_PROFILE, BGE_BASE_PROFILE] + +/** + * 按 UI 语言返回可选本地模型列表。 + * Qwen3 为各语言通用推荐;BGE base zh 仅中文 UI 提供(中文轻量替代)。 + */ +export function getLocalProfilesForLocale(locale: string): LocalEmbeddingProfile[] { + if (locale.startsWith('zh')) return [QWEN3_PROFILE, BGE_BASE_PROFILE] + return [QWEN3_PROFILE] +} + +export function getLocalProfileByModelId(modelId: string): LocalEmbeddingProfile | null { + return ALL_LOCAL_PROFILES.find((p) => p.modelId === modelId) ?? null +} diff --git a/packages/node-runtime/src/semantic-index/embedding/providers.test.ts b/packages/node-runtime/src/semantic-index/embedding/providers.test.ts new file mode 100644 index 000000000..a2268734f --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedding/providers.test.ts @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createProxyFetch, LocalEmbeddingProvider, type LocalPipelineFactory } from './local' +import { OpenAICompatibleEmbeddingProvider, type FetchFn } from './api' +import { QWEN3_PROFILE, type LocalEmbeddingProfile } from './profiles' + +// 无 maxBatchSize 的本地 profile,用于验证「不限批量时一次性 embed 全部文本」的分支 +const NO_BATCH_PROFILE: LocalEmbeddingProfile = { ...QWEN3_PROFILE, maxBatchSize: undefined, dim: 8 } + +// 返回固定 dim 的 fake 抽取器,并记录每次调用的 batch 文本 +function makeFakeFactory(dim: number, calls: string[][]): LocalPipelineFactory { + return async () => async (texts) => { + calls.push(texts) + return texts.map((_, i) => Array.from({ length: dim }, (_, j) => (i + j) % 7)) + } +} + +test('Qwen3 local provider embeds one text per call (batch=1)', async () => { + const calls: string[][] = [] + const provider = new LocalEmbeddingProvider(QWEN3_PROFILE, { pipelineFactory: makeFakeFactory(1024, calls) }) + + const vectors = await provider.embedDocuments(['a', 'b', 'c']) + assert.equal(vectors.length, 3) + assert.equal(vectors[0].length, 1024) + // batch=1:三条文本应分三次调用,每次一条 + assert.equal(calls.length, 3) + assert.ok(calls.every((c) => c.length === 1)) +}) + +test('local provider limits ONNX runtime threads by default', async () => { + let capturedSessionOptions: unknown + const provider = new LocalEmbeddingProvider(QWEN3_PROFILE, { + pipelineFactory: async (params) => { + capturedSessionOptions = params.sessionOptions + return async (texts) => texts.map(() => Array.from({ length: QWEN3_PROFILE.dim }, () => 0.1)) + }, + }) + + await provider.preload() + + assert.deepEqual(capturedSessionOptions, { intraOpNumThreads: 1, interOpNumThreads: 1 }) +}) + +test('local provider without maxBatchSize embeds all texts in a single call', async () => { + const calls: string[][] = [] + const provider = new LocalEmbeddingProvider(NO_BATCH_PROFILE, { pipelineFactory: makeFakeFactory(8, calls) }) + + const vectors = await provider.embedDocuments(['a', 'b', 'c']) + assert.equal(vectors.length, 3) + assert.equal(vectors[0].length, 8) + assert.equal(calls.length, 1) + assert.equal(calls[0].length, 3) +}) + +test('local provider prepends query instruction for embedQuery', async () => { + const calls: string[][] = [] + const provider = new LocalEmbeddingProvider(QWEN3_PROFILE, { pipelineFactory: makeFakeFactory(1024, calls) }) + + await provider.embedQuery('北京天气') + assert.equal(calls.length, 1) + assert.equal(calls[0][0], `Instruct: ${QWEN3_PROFILE.queryInstruction}\nQuery: 北京天气`) +}) + +test('local provider clamps document text to maxTextChars', async () => { + const calls: string[][] = [] + const provider = new LocalEmbeddingProvider(QWEN3_PROFILE, { pipelineFactory: makeFakeFactory(1024, calls) }) + + const long = 'x'.repeat(QWEN3_PROFILE.maxTextChars! + 500) + await provider.embedDocuments([long]) + assert.equal(calls[0][0].length, QWEN3_PROFILE.maxTextChars) +}) + +test('local provider throws when returned dim mismatches profile', async () => { + const provider = new LocalEmbeddingProvider(QWEN3_PROFILE, { pipelineFactory: makeFakeFactory(256, []) }) + await assert.rejects(() => provider.embedDocuments(['a']), /dim/i) +}) + +test('local provider retries pipeline creation after a failed preload', async () => { + let attempts = 0 + const provider = new LocalEmbeddingProvider(QWEN3_PROFILE, { + pipelineFactory: async () => { + attempts++ + if (attempts === 1) throw new Error('temporary network failure') + return async (texts) => texts.map(() => Array.from({ length: QWEN3_PROFILE.dim }, () => 0.1)) + }, + }) + + await assert.rejects(() => provider.preload(), /temporary network failure/) + await provider.preload() + + assert.equal(attempts, 2) +}) + +test('local model download proxy rejects SOCKS URLs explicitly', async () => { + await assert.rejects(() => createProxyFetch('socks5://127.0.0.1:1080'), /SOCKS proxy is not supported/) +}) + +test('API provider posts OpenAI-compatible request and parses ordered embeddings', async () => { + let captured: { url: string; body: string; headers: Record } | null = null + const fetchFn: FetchFn = async (url, init) => { + captured = { url, body: init.body, headers: init.headers } + return { + ok: true, + status: 200, + text: async () => '', + json: async () => ({ + data: [ + { index: 1, embedding: [0.3, 0.4] }, + { index: 0, embedding: [0.1, 0.2] }, + ], + }), + } + } + const provider = new OpenAICompatibleEmbeddingProvider( + { baseUrl: 'https://api.example.com/v1/', apiKey: 'sk-test', model: 'text-embedding-3-small' }, + { fetchFn } + ) + + assert.equal(provider.documentBatchSize, 8) + const vectors = await provider.embedDocuments(['first', 'second']) + assert.equal(captured!.url, 'https://api.example.com/v1/embeddings') + assert.equal(captured!.headers.Authorization, 'Bearer sk-test') + assert.deepEqual(JSON.parse(captured!.body), { model: 'text-embedding-3-small', input: ['first', 'second'] }) + // 按 index 排序:index 0 在前(Float32 精度,按近似比较) + const approx = (a: Float32Array, b: number[]) => b.every((v, i) => Math.abs(a[i] - v) < 1e-6) + assert.ok(approx(vectors[0], [0.1, 0.2])) + assert.ok(approx(vectors[1], [0.3, 0.4])) + assert.equal(provider.dim, 2) +}) + +test('API provider omits Authorization header when API key is empty', async () => { + let capturedHeaders: Record | null = null + const fetchFn: FetchFn = async (_url, init) => { + capturedHeaders = init.headers + return { + ok: true, + status: 200, + text: async () => '', + json: async () => ({ data: [{ index: 0, embedding: [0.1, 0.2] }] }), + } + } + const provider = new OpenAICompatibleEmbeddingProvider( + { baseUrl: 'http://localhost:11434/v1', apiKey: '', model: 'nomic-embed-text' }, + { fetchFn } + ) + + await provider.embedDocuments(['hello']) + + assert.equal(capturedHeaders!.Authorization, undefined) +}) + +test('API provider embedQuery does not add instruction', async () => { + let capturedInput: unknown = null + const fetchFn: FetchFn = async (_url, init) => { + capturedInput = JSON.parse(init.body).input + return { ok: true, status: 200, text: async () => '', json: async () => ({ data: [{ index: 0, embedding: [1] }] }) } + } + const provider = new OpenAICompatibleEmbeddingProvider( + { baseUrl: 'https://api.example.com', apiKey: 'k', model: 'm' }, + { fetchFn } + ) + await provider.embedQuery('原始查询') + assert.deepEqual(capturedInput, ['原始查询']) +}) + +test('API provider throws on non-ok response', async () => { + const fetchFn: FetchFn = async () => ({ + ok: false, + status: 429, + text: async () => 'rate limited', + json: async () => ({}), + }) + const provider = new OpenAICompatibleEmbeddingProvider( + { baseUrl: 'https://api.example.com', apiKey: 'k', model: 'm' }, + { fetchFn } + ) + await assert.rejects(() => provider.embedDocuments(['x']), /429/) +}) diff --git a/packages/node-runtime/src/semantic-index/embedding/text.ts b/packages/node-runtime/src/semantic-index/embedding/text.ts new file mode 100644 index 000000000..ac56d844a --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedding/text.ts @@ -0,0 +1,17 @@ +/** + * Embedding 输入文本处理(纯函数) + */ + +/** 给 query 加模型 queryInstruction 前缀;document 不调用此函数 */ +export function applyQueryInstruction(instruction: string, query: string): string { + const trimmed = instruction.trim() + if (!trimmed) return query + if (trimmed.endsWith(':') || trimmed.endsWith(':')) return `${trimmed}${query}` + return `Instruct: ${trimmed}\nQuery: ${query}` +} + +/** 按字符上限截断文本;maxChars 未提供或文本更短时原样返回 */ +export function clampTextChars(text: string, maxChars?: number): string { + if (maxChars === undefined || text.length <= maxChars) return text + return text.slice(0, maxChars) +} diff --git a/packages/node-runtime/src/semantic-index/embedding/types.ts b/packages/node-runtime/src/semantic-index/embedding/types.ts new file mode 100644 index 000000000..918d17447 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/embedding/types.ts @@ -0,0 +1,26 @@ +/** + * Embedding provider 统一接口与公共类型 + * + * 两类 provider:Qwen3(本地 ONNX,batch=1)与 OpenAI-compatible API。 + * 检索时 query 与 document 分别走 embedQuery / embedDocuments, + * 本地模型对 query 加 queryInstruction,document 不加。 + */ + +export type EmbeddingPooling = 'cls' | 'last_token' | 'mean' + +export interface EmbeddingProvider { + /** 用于 chunk 分区与重建判定的稳定模型标识 */ + readonly modelId: string + /** 向量维度;API provider 在首次调用前可能为 0(未知) */ + readonly dim: number + /** 单文本 token 上限(profile cap) */ + readonly maxTokens: number + /** warmup 建索引时推荐的 document 批量大小;未声明时按 1 条处理 */ + readonly documentBatchSize?: number + /** document 向量化(可批量;Qwen3 内部固定逐条) */ + embedDocuments(texts: string[]): Promise + /** query 向量化(本地模型会加 queryInstruction) */ + embedQuery(text: string): Promise + /** 预热:触发模型下载/加载而不做实际 embedding。API provider 为 no-op。 */ + preload?(): Promise +} diff --git a/packages/node-runtime/src/semantic-index/index.ts b/packages/node-runtime/src/semantic-index/index.ts new file mode 100644 index 000000000..a940216c2 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/index.ts @@ -0,0 +1,128 @@ +/** + * 语义索引模块(Phase 1) + * + * 独立于 dormant 的旧 `ai/rag` 模块,按 chunking-decision-final.md 重新实现: + * 向量存储、parent/child chunker、embedding provider、混合召回与后台预热。 + */ + +export { EmbeddingIndexStore } from './store' +export type { LoadSqliteVec } from './store' +export { + EMBEDDING_INDEX_SCHEMA, + CHUNK_VECTOR_INDEX_TABLE, + CHUNK_VECTOR_INDEX_INDEXES, + SEMANTIC_INDEX_SESSION_TABLE, + vecTableName, + vecTableSchema, +} from './schema' +export { SemanticIndexStateStore } from './session-state-store' +export type { + SemanticIndexSessionState, + SemanticIndexStatus, + SemanticIndexCleanupStatus, + EnableParams, + ProgressPatch, +} from './session-state-store' +export type { + ChunkRecord, + ChunkInsert, + ChunkStatus, + DenseQueryParams, + DenseQueryResult, + MessageToChunkParams, +} from './types' +export { + CHUNKER_VERSION, + STRATEGY_ID, + DEFAULT_CHUNKER_CONFIG, + computeChunkerConfigHash, + computeDbPathHash, + composeChunkId, + deriveParentId, + parseParentBounds, +} from './chunker-config' +export type { ChunkerConfig } from './chunker-config' +export { estimateTokens } from './tokens' +export { + SemanticIndexConfigStore, + defaultSemanticIndexConfig, + isSemanticIndexConfigured, + resolveModelId, + clampSearchMaxResults, + SEMANTIC_INDEX_CONFIG_VERSION, + SEARCH_MAX_RESULTS_DEFAULT, + SEARCH_MAX_RESULTS_MIN, + SEARCH_MAX_RESULTS_MAX, +} from './config' +export type { + SemanticIndexConfig, + SemanticIndexConfigInput, + SemanticIndexMode, + SemanticIndexLocalConfig, + SemanticIndexApiConfig, +} from './config' +export { createEmbedder } from './embedder-factory' +export type { EmbedderFactoryDeps } from './embedder-factory' +export { + SemanticIndexService, + createSemanticIndexService, + persistSemanticIndexConfig, + resolveSemanticIndexApiKeySet, + SEMANTIC_INDEX_AUTH_PROFILE, + SEMANTIC_INDEX_DB_FILE, + SEMANTIC_INDEX_CONFIG_FILE, +} from './service' +export type { SemanticIndexRuntime, MaybePromise } from './runtime' +export { + SemanticIndexWorkerClient, + createSemanticIndexWorkerClient, + createSemanticIndexWorkerRuntimeClient, +} from './worker-client' +export type { + SemanticIndexWorkerClientOptions, + SemanticIndexWorkerRuntimeClientOptions, + SemanticIndexWorkerTransport, + SemanticIndexWorkerTransportFactory, +} from './worker-client' +export { createSemanticIndexWorkerThreadTransport } from './worker-thread-transport' +export type { SemanticIndexWorkerThreadTransportOptions, SemanticIndexWorkerLike } from './worker-thread-transport' +export { StaticPathProvider, snapshotPathProvider } from './static-path-provider' +export type { StaticPathProviderSnapshot } from './static-path-provider' +export type { + SemanticIndexServiceOptions, + SemanticIndexSessionStatus, + SemanticSearchResult, + SemanticSearchReason, + SemanticSearchToolResult, + SemanticSearchToolSource, + SemanticSearchToolOptions, +} from './service' +export { runWarmup } from './warmup/runner' +export type { + SemanticMessageSource, + StopSignal, + WarmupRunnerOptions, + WarmupResult, + WarmupStatus, +} from './warmup/runner' +export { SemanticIndexJobQueue } from './warmup/job-queue' +export type { SemanticIndexJob, SemanticIndexJobType, JobContext, JobExecutor } from './warmup/job-queue' +export { reciprocalRankFusion } from './retrieval/rrf' +export type { RrfResult } from './retrieval/rrf' +export { hybridSearch } from './retrieval/hybrid-search' +export type { FtsSearcher, HybridSearchDeps, HybridSearchParams, HybridSearchResult } from './retrieval/hybrid-search' +export { createChatDbMessageSource } from './chat-db/message-source' +export { createChatDbMessageRangeReader } from './chat-db/message-range-reader' +export { createChatDbFtsSearcher, extractFtsKeywords } from './chat-db/fts-searcher' +export { assembleEvidence, formatEvidenceMessages } from './retrieval/evidence' +export type { + EvidenceMessage, + MessageRangeReader, + EvidenceHit, + EvidenceBudget, + EvidenceBlock, + EvidenceResult, +} from './retrieval/evidence' +export { chunkMessages, isSemanticVoid } from './chunker' +export type { ChunkMessageInput, ChunkSource, ChunkMessagesInput, ChildChunk, ChunkResult } from './chunker' +export * as embedding from './embedding' diff --git a/packages/node-runtime/src/semantic-index/retrieval/evidence.test.ts b/packages/node-runtime/src/semantic-index/retrieval/evidence.test.ts new file mode 100644 index 000000000..bdb5304e9 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/retrieval/evidence.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + assembleEvidence, + formatEvidenceMessages, + type EvidenceHit, + type EvidenceMessage, + type MessageRangeReader, +} from './evidence' +import { estimateTokens } from '../tokens' + +function makeMessages(startId: number, endId: number, charsPerMsg = 10): EvidenceMessage[] { + const filler = '一二三四五六七八九十'.slice(0, charsPerMsg) + const msgs: EvidenceMessage[] = [] + for (let id = startId; id <= endId; id++) { + msgs.push({ id, senderName: '甲', content: filler, ts: id * 1000 }) + } + return msgs +} + +/** 多 parent 内存 reader:按 ts, id 升序返回区间消息(通过 startId/endId 的 ts 值确定范围) */ +function makeReader(all: EvidenceMessage[]): MessageRangeReader { + const tsById = new Map(all.map((m) => [m.id, m.ts])) + return { + readRange(startId, endId) { + const startTs = tsById.get(startId) ?? -Infinity + const endTs = tsById.get(endId) ?? Infinity + return all.filter((m) => m.ts >= startTs && m.ts <= endTs).sort((a, b) => a.ts - b.ts || a.id - b.id) + }, + } +} + +function parentId(start: number, end: number): string { + return `parent:${start}:${end}:1800:v1.0:cfg` +} + +function hit(chunkId: string, p: [number, number], range: [number, number], score: number): EvidenceHit { + // In makeMessages, ts = id * 1000 + return { + chunkId, + score, + parentId: parentId(p[0], p[1]), + startMessageId: range[0], + endMessageId: range[1], + startTs: range[0] * 1000, + endTs: range[1] * 1000, + } +} + +test('expands hit by ±N messages within parent', () => { + const reader = makeReader(makeMessages(1, 20)) + const result = assembleEvidence(reader, [hit('c1', [1, 20], [10, 11], 1)], { expandMessages: 3 }) + + assert.equal(result.blocks.length, 1) + const block = result.blocks[0] + assert.equal(block.startMessageId, 7) + assert.equal(block.endMessageId, 14) + assert.deepEqual(block.chunkIds, ['c1']) +}) + +test('clamps expansion at parent start boundary', () => { + const reader = makeReader(makeMessages(1, 20)) + const result = assembleEvidence(reader, [hit('c1', [1, 20], [2, 3], 1)], { expandMessages: 3 }) + + assert.equal(result.blocks[0].startMessageId, 1) + assert.equal(result.blocks[0].endMessageId, 6) +}) + +test('expansion does not cross into another parent', () => { + const reader = makeReader(makeMessages(1, 20)) + // 命中在 parent [1,10] 末尾,扩展不能进入 parent [11,20] + const result = assembleEvidence(reader, [hit('c1', [1, 10], [9, 10], 1)], { expandMessages: 3 }) + + assert.equal(result.blocks[0].endMessageId, 10) + assert.equal(result.blocks[0].startMessageId, 6) +}) + +test('merges adjacent hits in the same parent under soft cap', () => { + const reader = makeReader(makeMessages(1, 20)) + const hits = [hit('cA', [1, 20], [5, 6], 2), hit('cB', [1, 20], [9, 10], 1)] + const result = assembleEvidence(reader, hits, { expandMessages: 3, blockSoftCapTokens: 100000 }) + + assert.equal(result.blocks.length, 1) + assert.deepEqual(result.blocks[0].chunkIds.sort(), ['cA', 'cB']) + assert.equal(result.blocks[0].startMessageId, 2) + assert.equal(result.blocks[0].endMessageId, 13) +}) + +test('keeps hits separate when merge would exceed soft cap', () => { + const reader = makeReader(makeMessages(1, 20)) + const hits = [hit('cA', [1, 20], [5, 6], 2), hit('cB', [1, 20], [9, 10], 1)] + + // 测得单块与合并块 token,设置 soft cap 介于两者之间 + const single = assembleEvidence(reader, [hits[0]], { expandMessages: 3, blockSoftCapTokens: 100000 }) + const mergedTokens = assembleEvidence(reader, hits, { expandMessages: 3, blockSoftCapTokens: 100000 }).blocks[0] + .tokens + const cap = mergedTokens - 1 + assert.ok(cap >= single.blocks[0].tokens) + + const result = assembleEvidence(reader, hits, { + expandMessages: 3, + blockSoftCapTokens: cap, + totalTokens: 100000, + }) + assert.equal(result.blocks.length, 2) +}) + +test('soft cap shrinks expansion without crossing the hit core', () => { + const reader = makeReader(makeMessages(1, 20)) + const full = assembleEvidence(reader, [hit('c1', [1, 20], [10, 10], 1)], { + expandMessages: 3, + blockSoftCapTokens: 100000, + }) + const coreTokens = estimateTokens(formatEvidenceMessages(makeMessages(10, 10))) + const cap = Math.floor((full.blocks[0].tokens + coreTokens) / 2) + + const result = assembleEvidence(reader, [hit('c1', [1, 20], [10, 10], 1)], { + expandMessages: 3, + blockSoftCapTokens: cap, + }) + const block = result.blocks[0] + assert.ok(block.tokens <= cap) + assert.ok(block.messages.length < 7) + // core 消息必须保留 + assert.ok(block.messages.some((m) => m.id === 10)) +}) + +test('total budget drops lowest-score blocks first', () => { + const all = [...makeMessages(1, 10), ...makeMessages(11, 20), ...makeMessages(21, 30)] + const reader = makeReader(all) + const hits = [ + hit('cHigh', [1, 10], [5, 5], 3), + hit('cMid', [11, 20], [15, 15], 2), + hit('cLow', [21, 30], [25, 25], 1), + ] + + const oneBlockTokens = assembleEvidence(reader, [hits[0]], { expandMessages: 0 }).blocks[0].tokens + // 预算只够约 2 个最小块(expand=0 时核心块) + const result = assembleEvidence(reader, hits, { + expandMessages: 0, + totalTokens: oneBlockTokens * 2 + 1, + }) + + const survivingChunks = result.blocks.flatMap((b) => b.chunkIds) + assert.ok(survivingChunks.includes('cHigh')) + assert.ok(!survivingChunks.includes('cLow')) + assert.ok(result.totalTokens <= oneBlockTokens * 2 + 1) +}) diff --git a/packages/node-runtime/src/semantic-index/retrieval/evidence.ts b/packages/node-runtime/src/semantic-index/retrieval/evidence.ts new file mode 100644 index 000000000..45b0c9581 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/retrieval/evidence.ts @@ -0,0 +1,212 @@ +/** + * 证据块组装 + * + * chunking-decision-final.md 第 14 节: + * - RRF 后取 topK 命中 chunk,每个命中在 parent 内前后各扩展若干条消息,不跨 parent。 + * - 相邻命中可合并,但合并后超过单块 soft cap 则拆开注入。 + * - 总证据预算固定 1500 token:不够时优先减少前后扩展,再减少 chunk 数。 + * + * 消息原文读取(聊天库连接)由注入的 MessageRangeReader 提供,保持模块平台无关、可单测。 + */ + +import { parseParentBounds } from '../chunker-config' +import { estimateTokens } from '../tokens' + +export interface EvidenceMessage { + id: number + senderName: string + content: string + ts: number + /** 发送者 id,用于昵称匿名化(U{id}) */ + senderId?: number + /** 发送者平台 id,用于匿名化时识别 owner */ + senderPlatformId?: string +} + +/** 读取聊天库父消息区间,按 ts, id 升序返回;startId/endId 为 parent 两端消息 id(ts 序,非 min/max) */ +export interface MessageRangeReader { + readRange(startId: number, endId: number): EvidenceMessage[] +} + +/** 命中 chunk(来自 hybridSearch,已按 score 降序) */ +export interface EvidenceHit { + chunkId: string + score: number + parentId: string + startMessageId: number + endMessageId: number + /** chunk 的起始时间戳(毫秒),供时间范围过滤使用 */ + startTs: number + /** chunk 的结束时间戳(毫秒),供时间范围过滤使用 */ + endTs: number +} + +export interface EvidenceBudget { + /** 总证据预算 token,默认 1500 */ + totalTokens?: number + /** 单证据块 soft cap token,默认 800 */ + blockSoftCapTokens?: number + /** 每个命中 chunk 前后各扩展消息条数,默认 3 */ + expandMessages?: number + /** 最多取多少个命中 chunk,默认 5 */ + maxChunks?: number +} + +export interface EvidenceBlock { + parentId: string + startMessageId: number + endMessageId: number + messages: EvidenceMessage[] + tokens: number + /** 该块覆盖的命中 chunkId */ + chunkIds: string[] + /** 块内最高命中分 */ + score: number +} + +export interface EvidenceResult { + blocks: EvidenceBlock[] + totalTokens: number +} + +/** 证据块规范化文本(用于 token 估算与后续 prompt 注入) */ +export function formatEvidenceMessages(messages: EvidenceMessage[]): string { + return messages.map((m) => `${m.senderName}: ${m.content}`).join('\n') +} + +function messagesTokens(messages: EvidenceMessage[]): number { + return estimateTokens(formatEvidenceMessages(messages)) +} + +interface WorkingBlock { + parentId: string + parentMsgs: EvidenceMessage[] + startIdx: number + endIdx: number + /** 命中核心范围(合并后取并集),收缩扩展时不得越过此范围 */ + coreStartIdx: number + coreEndIdx: number + chunkIds: string[] + score: number +} + +export function assembleEvidence( + reader: MessageRangeReader, + hits: EvidenceHit[], + budget: EvidenceBudget = {} +): EvidenceResult { + const totalTokens = budget.totalTokens ?? 1500 + const softCap = budget.blockSoftCapTokens ?? 800 + const expand = budget.expandMessages ?? 3 + const maxChunks = budget.maxChunks ?? 5 + + const selected = hits.slice(0, maxChunks) + const parentCache = new Map() + + // 1. 每个命中在 parent 内扩展为 working block + const raw: WorkingBlock[] = [] + for (const hit of selected) { + const bounds = parseParentBounds(hit.parentId) + if (!bounds) continue + let parentMsgs = parentCache.get(hit.parentId) + if (!parentMsgs) { + parentMsgs = reader.readRange(bounds.startMessageId, bounds.endMessageId) + parentCache.set(hit.parentId, parentMsgs) + } + if (parentMsgs.length === 0) continue + + // Use message IDs for precise core boundary — ts has second granularity and is non-unique + const coreStartIdx = parentMsgs.findIndex((m) => m.id === hit.startMessageId) + let coreEndIdx = -1 + for (let i = parentMsgs.length - 1; i >= 0; i--) { + if (parentMsgs[i].id === hit.endMessageId) { + coreEndIdx = i + break + } + } + if (coreStartIdx < 0 || coreEndIdx < coreStartIdx) continue + + raw.push({ + parentId: hit.parentId, + parentMsgs, + startIdx: Math.max(0, coreStartIdx - expand), + endIdx: Math.min(parentMsgs.length - 1, coreEndIdx + expand), + coreStartIdx, + coreEndIdx, + chunkIds: [hit.chunkId], + score: hit.score, + }) + } + + // 2. 同 parent 内相邻/重叠且合并后不超 soft cap 的块合并 + const byParent = new Map() + for (const block of raw) { + const list = byParent.get(block.parentId) ?? [] + list.push(block) + byParent.set(block.parentId, list) + } + + const merged: WorkingBlock[] = [] + for (const list of byParent.values()) { + list.sort((a, b) => a.startIdx - b.startIdx) + for (const block of list) { + const last = merged[merged.length - 1] + const mergeable = last && last.parentId === block.parentId && block.startIdx <= last.endIdx + 1 + if (mergeable) { + const candStart = Math.min(last.startIdx, block.startIdx) + const candEnd = Math.max(last.endIdx, block.endIdx) + const candTokens = messagesTokens(block.parentMsgs.slice(candStart, candEnd + 1)) + if (candTokens <= softCap) { + last.startIdx = candStart + last.endIdx = candEnd + last.coreStartIdx = Math.min(last.coreStartIdx, block.coreStartIdx) + last.coreEndIdx = Math.max(last.coreEndIdx, block.coreEndIdx) + last.chunkIds.push(...block.chunkIds) + last.score = Math.max(last.score, block.score) + continue + } + } + merged.push({ ...block }) + } + } + + // 3. 单块超 soft cap 时收缩扩展(不越过 core) + for (const block of merged) shrinkToFit(block, softCap) + + // 4. 总预算控制:按 score 降序贪心装入;不够时收缩扩展,再不够则丢弃该块 + merged.sort((a, b) => b.score - a.score) + const blocks: EvidenceBlock[] = [] + let used = 0 + for (const block of merged) { + let tokens = messagesTokens(block.parentMsgs.slice(block.startIdx, block.endIdx + 1)) + if (used + tokens > totalTokens) { + shrinkToFit(block, Math.min(softCap, totalTokens - used)) + tokens = messagesTokens(block.parentMsgs.slice(block.startIdx, block.endIdx + 1)) + if (used + tokens > totalTokens) continue + } + const messages = block.parentMsgs.slice(block.startIdx, block.endIdx + 1) + blocks.push({ + parentId: block.parentId, + startMessageId: messages[0].id, + endMessageId: messages[messages.length - 1].id, + messages, + tokens, + chunkIds: block.chunkIds, + score: block.score, + }) + used += tokens + } + + return { blocks, totalTokens: used } +} + +/** 收缩扩展使块 token 不超过 cap:从扩展更多的一侧逐条收缩,不越过 core 范围 */ +function shrinkToFit(block: WorkingBlock, cap: number): void { + while (messagesTokens(block.parentMsgs.slice(block.startIdx, block.endIdx + 1)) > cap) { + const startMargin = block.coreStartIdx - block.startIdx + const endMargin = block.endIdx - block.coreEndIdx + if (startMargin <= 0 && endMargin <= 0) break + if (endMargin >= startMargin) block.endIdx-- + else block.startIdx++ + } +} diff --git a/packages/node-runtime/src/semantic-index/retrieval/hybrid-search.test.ts b/packages/node-runtime/src/semantic-index/retrieval/hybrid-search.test.ts new file mode 100644 index 000000000..6b16c1eae --- /dev/null +++ b/packages/node-runtime/src/semantic-index/retrieval/hybrid-search.test.ts @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { hybridSearch, type FtsSearcher } from './hybrid-search' +import { EmbeddingIndexStore } from '../store' +import { STRATEGY_ID } from '../chunker-config' +import type { EmbeddingProvider } from '../embedding/types' +import type { ChunkRecord } from '../types' + +const DB_HASH = 'dbA' +const MODEL = 'fake' +const DIM = 4 + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-hybrid-')) +} + +function makeRecord(chunkId: string, startMessageId: number, endMessageId: number): ChunkRecord { + return { + chunkId, + dbPathHash: DB_HASH, + strategyId: STRATEGY_ID, + modelId: MODEL, + dim: DIM, + parentId: `parent:${startMessageId}`, + startMessageId, + endMessageId, + startTs: startMessageId * 1000, + endTs: endMessageId * 1000, + messageCount: endMessageId - startMessageId + 1, + rawContentHash: `raw-${chunkId}`, + embeddingInputHash: `emb-${chunkId}`, + chunkerVersion: 'v1.0', + chunkerConfigHash: 'cfg', + indexedAt: Date.now(), + status: 'indexed', + } +} + +function makeEmbedder(queryVector: number[]): EmbeddingProvider { + return { + modelId: MODEL, + dim: DIM, + maxTokens: 1000, + async embedDocuments(texts) { + return texts.map(() => new Float32Array(queryVector)) + }, + async embedQuery() { + return new Float32Array(queryVector) + }, + } +} + +function setupStore() { + const dir = makeTempDir() + const store = new EmbeddingIndexStore(path.join(dir, 'embedding_index.db')) + store.insertChunk(makeRecord('c1', 1, 2), [1, 0, 0, 0]) + store.insertChunk(makeRecord('c2', 3, 4), [0, 1, 0, 0]) + store.insertChunk(makeRecord('c3', 5, 6), [0, 0, 1, 0]) + store.insertChunk(makeRecord('c4', 7, 8), [0, 0, 0, 1]) + return store +} + +const baseParams = { + query: '测试问题', + dbPathHash: DB_HASH, + modelId: MODEL, + strategyId: STRATEGY_ID, + dim: DIM, +} + +test('fuses dense and fts so a chunk hit by both ranks first', async () => { + const store = setupStore() + // query 最接近 c1;FTS 命中 message 5(c3) 和 7(c4) + const embedder = makeEmbedder([0.9, 0.1, 0, 0]) + const fts: FtsSearcher = { + search: () => [ + { id: 5, ts: 5000 }, + { id: 7, ts: 7000 }, + ], + } + + const results = await hybridSearch({ embedder, store, fts }, baseParams) + + // c3、c4 同时出现在 dense 与 fts,RRF 分数高于仅 dense 命中的 c1、c2 + assert.equal(results[0].chunkId, 'c3') + assert.equal(results[1].chunkId, 'c4') + const c3 = results[0] + assert.equal(c3.ftsRank, 0) + assert.notEqual(c3.denseRank, null) + const c1 = results.find((r) => r.chunkId === 'c1')! + assert.equal(c1.ftsRank, null) + assert.equal(c1.denseRank, 0) + store.close() +}) + +test('respects finalTopK limit', async () => { + const store = setupStore() + const embedder = makeEmbedder([0.9, 0.1, 0, 0]) + const fts: FtsSearcher = { + search: () => [ + { id: 5, ts: 5000 }, + { id: 7, ts: 7000 }, + ], + } + + const results = await hybridSearch({ embedder, store, fts }, { ...baseParams, finalTopK: 2 }) + assert.equal(results.length, 2) + store.close() +}) + +test('deduplicates fts message ids mapping to the same chunk', async () => { + const store = setupStore() + const embedder = makeEmbedder([1, 0, 0, 0]) + // message 5 和 6 都映射到 c3 + const fts: FtsSearcher = { + search: () => [ + { id: 5, ts: 5000 }, + { id: 6, ts: 6000 }, + ], + } + + const results = await hybridSearch({ embedder, store, fts }, baseParams) + const c3 = results.find((r) => r.chunkId === 'c3')! + assert.equal(c3.ftsRank, 0) + store.close() +}) + +test('works with dense only when fts returns nothing', async () => { + const store = setupStore() + const embedder = makeEmbedder([1, 0, 0, 0]) + const fts: FtsSearcher = { search: () => [] } + + const results = await hybridSearch({ embedder, store, fts }, baseParams) + assert.equal(results[0].chunkId, 'c1') + assert.ok(results.every((r) => r.ftsRank === null)) + store.close() +}) + +test('empty query returns no results', async () => { + const store = setupStore() + const embedder = makeEmbedder([1, 0, 0, 0]) + const fts: FtsSearcher = { search: () => [{ id: 1, ts: 1000 }] } + + const results = await hybridSearch({ embedder, store, fts }, { ...baseParams, query: ' ' }) + assert.deepEqual(results, []) + store.close() +}) + +// 时间过滤:chunk startTs/endTs 为毫秒(makeRecord 用 messageId*1000) +// c1: 1000-2000, c2: 3000-4000, c3: 5000-6000, c4: 7000-8000 +test('timeRangeMs filters out chunks with no overlap', async () => { + const store = setupStore() + const embedder = makeEmbedder([0.5, 0.5, 0.5, 0.5]) + const fts: FtsSearcher = { search: () => [] } + + const results = await hybridSearch( + { embedder, store, fts }, + { ...baseParams, timeRangeMs: { startTs: 4500, endTs: 9000 } } + ) + const ids = results.map((r) => r.chunkId).sort() + assert.deepEqual(ids, ['c3', 'c4']) + store.close() +}) + +test('timeRangeMs keeps chunks overlapping the range', async () => { + const store = setupStore() + const embedder = makeEmbedder([0.5, 0.5, 0.5, 0.5]) + const fts: FtsSearcher = { search: () => [] } + + const results = await hybridSearch( + { embedder, store, fts }, + { ...baseParams, timeRangeMs: { startTs: 1500, endTs: 3500 } } + ) + const ids = results.map((r) => r.chunkId).sort() + assert.deepEqual(ids, ['c1', 'c2']) + store.close() +}) + +test('timeRangeMs supports single-sided startTs', async () => { + const store = setupStore() + const embedder = makeEmbedder([0.5, 0.5, 0.5, 0.5]) + const fts: FtsSearcher = { search: () => [] } + + const results = await hybridSearch({ embedder, store, fts }, { ...baseParams, timeRangeMs: { startTs: 5000 } }) + const ids = results.map((r) => r.chunkId).sort() + assert.deepEqual(ids, ['c3', 'c4']) + store.close() +}) + +test('timeRangeMs supports single-sided endTs and filters fts-mapped chunks', async () => { + const store = setupStore() + const embedder = makeEmbedder([0.5, 0.5, 0.5, 0.5]) + // fts maps message 7 -> c4 (out of range, must be dropped) + const fts: FtsSearcher = { search: () => [{ id: 7, ts: 7000 }] } + + const results = await hybridSearch({ embedder, store, fts }, { ...baseParams, timeRangeMs: { endTs: 4000 } }) + const ids = results.map((r) => r.chunkId).sort() + assert.deepEqual(ids, ['c1', 'c2']) + assert.ok(results.every((r) => r.chunkId !== 'c4')) + store.close() +}) diff --git a/packages/node-runtime/src/semantic-index/retrieval/hybrid-search.ts b/packages/node-runtime/src/semantic-index/retrieval/hybrid-search.ts new file mode 100644 index 000000000..af290d0bb --- /dev/null +++ b/packages/node-runtime/src/semantic-index/retrieval/hybrid-search.ts @@ -0,0 +1,131 @@ +/** + * 混合检索:dense(向量)+ FTS(全文)+ RRF 融合 + * + * chunking-decision-final.md 第 10 节: + * - dense:query 向量化后在 embedding_index.db 做 ANN,限定单对话 + 单模型分区。 + * - FTS:在聊天库做全文检索得到 message_id 排序,再用 store.mapMessageToChunk 映射到 chunk。 + * - 两路各自独立排序,Node 层 RRF 融合,取 finalTopK。 + * + * FTS 来源(聊天库连接 + 分词)由注入的 FtsSearcher 提供,保持本模块平台无关、可单测。 + */ + +import type { EmbeddingProvider } from '../embedding/types' +import type { EmbeddingIndexStore } from '../store' +import type { ChunkRecord } from '../types' +import { reciprocalRankFusion } from './rrf' + +/** 聊天库全文检索:返回按相关度排序的消息(最相关在前);ts 为毫秒,与 chunk_vector_index 的 startTs/endTs 单位一致 */ +export interface FtsSearcher { + search(query: string, topN: number): Array<{ id: number; ts: number }> +} + +export interface HybridSearchDeps { + embedder: EmbeddingProvider + store: EmbeddingIndexStore + fts: FtsSearcher +} + +/** 毫秒级、可单边的时间区间(语义 chunk startTs/endTs 也是毫秒) */ +export interface SemanticTimeRangeMs { + startTs?: number + endTs?: number +} + +export interface HybridSearchParams { + query: string + dbPathHash: string + modelId: string + strategyId: string + dim: number + /** dense 取回数量,默认 40 */ + denseTopN?: number + /** FTS 取回 message 数量,默认 40 */ + ftsTopN?: number + /** RRF 常数,默认 60 */ + rrfK?: number + /** 融合后最终返回数量,默认 5 */ + finalTopK?: number + /** + * 毫秒级时间范围过滤(可单边)。 + * 只保留与 chunk [startTs, endTs] 有交集的候选;启用时会放大候选池避免过滤后结果过少。 + */ + timeRangeMs?: SemanticTimeRangeMs +} + +/** chunk 时间范围是否与过滤区间有交集 */ +function overlapsTimeRangeMs(record: ChunkRecord, filter?: SemanticTimeRangeMs): boolean { + if (!filter) return true + if (filter.startTs != null && record.endTs < filter.startTs) return false + if (filter.endTs != null && record.startTs > filter.endTs) return false + return true +} + +/** 启用时间过滤时放大候选池的倍数;必须足够大,避免长对话中窄时间段的 in-range chunk 均排在 top-N 以外 */ +const TIME_FILTER_POOL_MULTIPLIER = 10 + +export interface HybridSearchResult { + chunkId: string + score: number + record: ChunkRecord + /** 在 dense 排序中的位次(0 最优),未命中为 null */ + denseRank: number | null + /** 在 FTS 映射排序中的位次(0 最优),未命中为 null */ + ftsRank: number | null +} + +export async function hybridSearch(deps: HybridSearchDeps, params: HybridSearchParams): Promise { + const { embedder, store, fts } = deps + const { query, dbPathHash, modelId, strategyId, dim, rrfK = 60, finalTopK = 5, timeRangeMs } = params + + if (!query.trim()) return [] + + // 启用时间过滤时放大候选池,避免过滤后融合结果过少 + const poolFactor = timeRangeMs ? TIME_FILTER_POOL_MULTIPLIER : 1 + const denseTopN = Math.max((params.denseTopN ?? 40) * poolFactor, 40) + const ftsTopN = Math.max((params.ftsTopN ?? 40) * poolFactor, 40) + + const records = new Map() + + // dense(按时间交集过滤后顺序构造排名) + const queryVector = await embedder.embedQuery(query) + const dense = store.queryDense({ dbPathHash, modelId, dim, embedding: queryVector, k: denseTopN }) + const denseIds: string[] = [] + const denseRankById = new Map() + for (const hit of dense) { + if (!overlapsTimeRangeMs(hit.record, timeRangeMs)) continue + denseRankById.set(hit.chunkId, denseIds.length) + denseIds.push(hit.chunkId) + records.set(hit.chunkId, hit.record) + } + + // FTS message -> chunk(按 ts 映射,去重保序 + 时间交集过滤) + const ftsIds: string[] = [] + const ftsRankById = new Map() + for (const { id: messageId, ts: messageTs } of fts.search(query, ftsTopN)) { + const record = store.mapMessageToChunk({ dbPathHash, modelId, strategyId, messageId, messageTs }) + if (!record || ftsRankById.has(record.chunkId)) continue + if (!overlapsTimeRangeMs(record, timeRangeMs)) continue + ftsRankById.set(record.chunkId, ftsIds.length) + ftsIds.push(record.chunkId) + if (!records.has(record.chunkId)) records.set(record.chunkId, record) + } + + const fused = reciprocalRankFusion([denseIds, ftsIds], rrfK) + + const results: HybridSearchResult[] = [] + for (const { id, score } of fused) { + const record = records.get(id) ?? store.getChunkById(id) + if (!record) continue + // 兜底:getChunkById 取回的候选也按时间过滤 + if (!overlapsTimeRangeMs(record, timeRangeMs)) continue + results.push({ + chunkId: id, + score, + record, + denseRank: denseRankById.get(id) ?? null, + ftsRank: ftsRankById.get(id) ?? null, + }) + if (results.length >= finalTopK) break + } + return results +} diff --git a/packages/node-runtime/src/semantic-index/retrieval/rrf.test.ts b/packages/node-runtime/src/semantic-index/retrieval/rrf.test.ts new file mode 100644 index 000000000..b6575a5a7 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/retrieval/rrf.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { reciprocalRankFusion } from './rrf' + +test('single list preserves order with descending scores', () => { + const result = reciprocalRankFusion([['a', 'b', 'c']], 60) + assert.deepEqual( + result.map((r) => r.id), + ['a', 'b', 'c'] + ) + assert.ok(result[0].score > result[1].score && result[1].score > result[2].score) + assert.ok(Math.abs(result[0].score - 1 / 60) < 1e-9) +}) + +test('items present in both lists outrank items in one list', () => { + // x 在两个列表都靠前;y 仅在 dense 第一;z 仅在 fts 第一 + const dense = ['y', 'x'] + const fts = ['z', 'x'] + const result = reciprocalRankFusion([dense, fts], 60) + assert.equal(result[0].id, 'x') +}) + +test('item appearing in only one list still scored', () => { + const result = reciprocalRankFusion([['a'], ['b']], 60) + const ids = result.map((r) => r.id) + assert.ok(ids.includes('a') && ids.includes('b')) + assert.ok(Math.abs(result[0].score - result[1].score) < 1e-9) +}) + +test('ties break by first-seen order for stability', () => { + const result = reciprocalRankFusion([['a', 'b']], 60) + // a 与 b 不同分,但验证同分场景:两个独立单元素列表同 rank + const tie = reciprocalRankFusion([['a'], ['b']], 60) + assert.deepEqual( + tie.map((r) => r.id), + ['a', 'b'] + ) + assert.equal(result[0].id, 'a') +}) + +test('smaller k increases score separation between ranks', () => { + const small = reciprocalRankFusion([['a', 'b']], 1) + const large = reciprocalRankFusion([['a', 'b']], 1000) + const smallGap = small[0].score - small[1].score + const largeGap = large[0].score - large[1].score + assert.ok(smallGap > largeGap) +}) + +test('empty input returns empty array', () => { + assert.deepEqual(reciprocalRankFusion([], 60), []) + assert.deepEqual(reciprocalRankFusion([[], []], 60), []) +}) diff --git a/packages/node-runtime/src/semantic-index/retrieval/rrf.ts b/packages/node-runtime/src/semantic-index/retrieval/rrf.ts new file mode 100644 index 000000000..09c022cf9 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/retrieval/rrf.ts @@ -0,0 +1,35 @@ +/** + * Reciprocal Rank Fusion(RRF) + * + * chunking-decision-final.md 第 10 节:dense 与 FTS 各自独立排序,在 Node 层用 RRF 融合, + * 不依赖跨库 join。每个 id 的融合分 = Σ 1 / (k + rank),rank 为该 id 在某个排序列表中的 + * 位次(0 为最优);id 未出现在某列表时该列表不贡献分数。 + * + * 纯函数,按 id 操作(此处 id 即 chunkId),与具体检索来源解耦。 + */ + +export interface RrfResult { + id: string + score: number +} + +/** + * @param rankedLists 多个已排序 id 列表,每个列表内 index 0 为最相关 + * @param k RRF 常数,默认 60(业界常用值,降低高位排名的极端权重) + */ +export function reciprocalRankFusion(rankedLists: string[][], k = 60): RrfResult[] { + const scores = new Map() + const firstSeen = new Map() + let seq = 0 + + for (const list of rankedLists) { + list.forEach((id, rank) => { + scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank)) + if (!firstSeen.has(id)) firstSeen.set(id, seq++) + }) + } + + return [...scores.entries()] + .map(([id, score]) => ({ id, score })) + .sort((a, b) => b.score - a.score || firstSeen.get(a.id)! - firstSeen.get(b.id)!) +} diff --git a/packages/node-runtime/src/semantic-index/runtime.ts b/packages/node-runtime/src/semantic-index/runtime.ts new file mode 100644 index 000000000..655620931 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/runtime.ts @@ -0,0 +1,51 @@ +import type { SemanticIndexConfig, SemanticIndexConfigInput } from './config' +import type { + SemanticIndexSessionStatus, + SemanticSearchResult, + SemanticSearchToolOptions, + SemanticSearchToolResult, +} from './service' + +export type MaybePromise = T | Promise + +/** + * 语义索引运行时的公共能力面。 + * + * 真实 SemanticIndexService 和 worker client 都实现这组方法;调用方必须 await 返回值, + * 这样本进程实现和跨 worker RPC 可以共用同一套 HTTP/Agent 入口。 + */ +export interface SemanticIndexRuntime { + getConfig(): MaybePromise + setConfig(config: SemanticIndexConfigInput, options?: { apiKey?: string }): MaybePromise + getModelStatus(): MaybePromise<'idle' | 'downloading' | 'ready' | 'error'> + isConfigured(): MaybePromise + hasApiKey(): MaybePromise + + enable(sessionId: string): MaybePromise + remove(sessionId: string): MaybePromise + build(sessionId: string): MaybePromise + pause(sessionId: string): MaybePromise + cancel(sessionId: string): MaybePromise + rebuild(sessionId: string): MaybePromise + buildAllPending(): MaybePromise + + listEnabledStatuses(): MaybePromise + status(sessionId: string): MaybePromise + statusForSessions(sessionIds: string[]): MaybePromise + + canSearch(sessionId: string): MaybePromise + search( + sessionId: string, + query: string, + options?: { finalTopK?: number; timeRangeMs?: { startTs?: number; endTs?: number } } + ): Promise + searchForTool( + sessionId: string, + query: string, + options?: SemanticSearchToolOptions + ): Promise + + cleanupUnused(): MaybePromise<{ cleaned: number }> + recover(): MaybePromise + close(): MaybePromise +} diff --git a/packages/node-runtime/src/semantic-index/schema.ts b/packages/node-runtime/src/semantic-index/schema.ts new file mode 100644 index 000000000..770821264 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/schema.ts @@ -0,0 +1,91 @@ +/** + * embedding_index.db schema 定义(单一事实来源) + * + * 结构: + * - chunk_vector_index:dim 无关的元数据普通表,rowid 关联 vec0 向量。 + * - chunk_vec_{dim}:每个 embedding 维度一张 vec0 虚拟表(vec0 要求列内维度固定)。 + * PARTITION KEY 同时包含 db_path_hash 和 model_id,保证查询命中分区裁剪、隔离 + * 模型切换/重建期的新旧向量。详见 chunking-decision-final.md 第 11.1/17 节。 + */ + +/** 元数据表(dim 无关),rowid 即对应 vec0 的 vector_id */ +export const CHUNK_VECTOR_INDEX_TABLE = ` + CREATE TABLE IF NOT EXISTS chunk_vector_index ( + rowid INTEGER PRIMARY KEY, + chunk_id TEXT NOT NULL UNIQUE, + db_path_hash TEXT NOT NULL, + strategy_id TEXT NOT NULL, + model_id TEXT NOT NULL, + dim INTEGER NOT NULL, + parent_id TEXT NOT NULL, + start_message_id INTEGER NOT NULL, + end_message_id INTEGER NOT NULL, + start_ts INTEGER NOT NULL, + end_ts INTEGER NOT NULL, + message_count INTEGER NOT NULL, + raw_content_hash TEXT NOT NULL, + embedding_input_hash TEXT NOT NULL, + chunker_version TEXT NOT NULL, + chunker_config_hash TEXT NOT NULL, + indexed_at INTEGER NOT NULL, + status TEXT NOT NULL + ); +` + +/** + * 范围映射索引: + * - idx_chunk_range 保留 message_id 领先的旧查询路径和既有数据库兼容性。 + * - idx_chunk_ts_range 支撑当前 `start_ts, start_message_id` 组合查找,避免按会话扫描排序。 + */ +export const CHUNK_VECTOR_INDEX_INDEXES = ` + CREATE INDEX IF NOT EXISTS idx_chunk_range + ON chunk_vector_index(db_path_hash, model_id, strategy_id, start_message_id, end_message_id); + CREATE INDEX IF NOT EXISTS idx_chunk_ts_range + ON chunk_vector_index(db_path_hash, model_id, strategy_id, start_ts, start_message_id); +` + +export const EMBEDDING_INDEX_SCHEMA = CHUNK_VECTOR_INDEX_TABLE + CHUNK_VECTOR_INDEX_INDEXES + +/** + * 语义索引业务状态表(与向量表同库)。 + * + * 保存"对话是否启用索引"、索引进度/状态与清理状态。这些状态是权威来源, + * 不能从后台任务队列反推(见 chunking-decision-final.md 第 11.1/15 节)。 + * 以 db_path_hash 作为对话主键(一个聊天库 = 一个对话)。 + */ +export const SEMANTIC_INDEX_SESSION_TABLE = ` + CREATE TABLE IF NOT EXISTS semantic_index_session ( + db_path_hash TEXT PRIMARY KEY, + db_path TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0, + model_id TEXT, + index_status TEXT NOT NULL DEFAULT 'idle', + cleanup_status TEXT NOT NULL DEFAULT 'none', + total_messages INTEGER NOT NULL DEFAULT 0, + indexed_messages INTEGER NOT NULL DEFAULT 0, + last_indexed_message_id INTEGER, + chunk_count INTEGER NOT NULL DEFAULT 0, + chunker_version TEXT, + chunker_config_hash TEXT, + error TEXT, + enabled_at INTEGER, + updated_at INTEGER NOT NULL + ); +` + +/** 给定维度的 vec0 表名 */ +export function vecTableName(dim: number): string { + return `chunk_vec_${dim}` +} + +/** 给定维度的 vec0 虚拟表 DDL */ +export function vecTableSchema(dim: number): string { + return ` + CREATE VIRTUAL TABLE IF NOT EXISTS ${vecTableName(dim)} USING vec0( + vector_id INTEGER PRIMARY KEY, + db_path_hash TEXT PARTITION KEY, + model_id TEXT PARTITION KEY, + embedding FLOAT[${dim}] distance_metric=cosine + ); + ` +} diff --git a/packages/node-runtime/src/semantic-index/service-budget.test.ts b/packages/node-runtime/src/semantic-index/service-budget.test.ts new file mode 100644 index 000000000..adbbaf674 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/service-budget.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { resolveSearchToolEvidenceTokens } from './service' + +describe('resolveSearchToolEvidenceTokens', () => { + it('defaults semantic search evidence budget to 8000 tokens', () => { + assert.equal(resolveSearchToolEvidenceTokens(), 8000) + }) + + it('caps semantic search evidence budget by caller tool result budget', () => { + assert.equal(resolveSearchToolEvidenceTokens(1200), 1200) + assert.equal(resolveSearchToolEvidenceTokens(100000), 8000) + }) +}) diff --git a/packages/node-runtime/src/semantic-index/service.test.ts b/packages/node-runtime/src/semantic-index/service.test.ts new file mode 100644 index 000000000..5d63bcf22 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/service.test.ts @@ -0,0 +1,643 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { CHAT_DB_SCHEMA, FTS_TABLE_SCHEMA } from '@openchatlab/core' +import { openBetterSqliteDatabase } from '../better-sqlite3-adapter' +import { buildFtsIndex } from '../fts' +import { + SemanticIndexService, + persistSemanticIndexConfig, + resolveSemanticIndexApiKeySet, + SEMANTIC_INDEX_AUTH_PROFILE, +} from './service' +import { SemanticIndexConfigStore } from './config' +import { SemanticIndexStateStore } from './session-state-store' +import { computeDbPathHash } from './chunker-config' +import { BGE_BASE_PROFILE, QWEN3_PROFILE } from './embedding/profiles' +import type { SessionRuntimeAdapter } from '../services/adapters' +import type { LocalPipelineFactory } from './embedding/local' + +const SESSION_ID = 'sess1' +// 真实秒级基准时间(2023-11-14),用于让证据 snippet 渲染出真实年份, +// 暴露「毫秒被当作秒再 *1000」导致五位数年份(如 56150)的回归。 +const BASE_TS_SECONDS = 1_700_000_000 + +function setup(opts?: { + embedDelayMs?: number + failFirstPipelineCreation?: boolean + failPipelineCreationForModelIds?: ReadonlySet + onEmbedBatchStarted?: () => void +}) { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-si-svc-')) + const chatDbPath = path.join(dir, `${SESSION_ID}.db`) + const db = openBetterSqliteDatabase(chatDbPath) + db.exec(CHAT_DB_SCHEMA) + db.exec(FTS_TABLE_SCHEMA) + db.exec(` + INSERT INTO meta (name, platform, type, imported_at) VALUES ('测试群', 'wechat', 'group', 0); + INSERT INTO member (id, platform_id, account_name) VALUES (1, 'p1', '张三'), (2, 'p2', '李四'); + `) + const insert = db.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, 0, ?)') + for (let i = 1; i <= 40; i++) { + insert.run(i, (i % 2) + 1, BASE_TS_SECONDS + i * 60, `第${i}条关于项目排期和需求讨论的消息内容`) + } + buildFtsIndex(db) + + const adapter: SessionRuntimeAdapter = { + listSessionIds: () => [SESSION_ID], + openReadonly: (id) => (id === SESSION_ID ? db : null), + openWritable: (id) => (id === SESSION_ID ? db : null), + closeSession: () => {}, + getDbPath: () => chatDbPath, + deleteSessionFile: () => false, + ensureReadonly: (id) => { + if (id !== SESSION_ID) throw new Error('not found') + return db + }, + ensureWritable: (id) => { + if (id !== SESSION_ID) throw new Error('not found') + return db + }, + } + + // 本地 pipeline 工厂:返回 Qwen3 维度向量,按文本长度给一点变化,避免零向量 + // embedTextCount 统计累计嵌入文本数,用于区分 rebuild(重新嵌入) 与 build 续跑(跳过不嵌入) + let embedTextCount = 0 + let pipelineCreateAttempts = 0 + const localPipelineFactory: LocalPipelineFactory = async ({ modelId }) => { + pipelineCreateAttempts++ + if (opts?.failFirstPipelineCreation && pipelineCreateAttempts === 1) { + throw new Error('temporary preload failure') + } + if (opts?.failPipelineCreationForModelIds?.has(modelId)) { + throw new Error(`temporary preload failure for ${modelId}`) + } + return async (texts) => { + opts?.onEmbedBatchStarted?.() + embedTextCount += texts.length + if (opts?.embedDelayMs) await new Promise((r) => setTimeout(r, opts.embedDelayMs)) + return texts.map((t) => { + const v = new Array(QWEN3_PROFILE.dim).fill(0) + v[t.length % QWEN3_PROFILE.dim] = 1 + return v + }) + } + } + + // 内存 auth profile 存储:避免测试写真实 ~/.chatlab/auth-profiles.json + const authProfiles = new Map() + + const service = new SemanticIndexService({ + vectorDbPath: path.join(dir, 'embedding_index.db'), + configPath: path.join(dir, 'ai', 'semantic-index-config.json'), + sessionAdapter: adapter, + writeAuthProfile: (name, profile) => authProfiles.set(name, { key: profile.key }), + embedderFactoryDeps: { + localPipelineFactory, + resolveApiKey: (_provider, authProfile) => (authProfile ? (authProfiles.get(authProfile)?.key ?? '') : ''), + }, + }) + // 默认配置不再预选模型;测试显式选择 Qwen3 本地模型,使建索引可执行 + service.setConfig({ version: 1, mode: 'local', local: { modelId: QWEN3_PROFILE.modelId }, api: null }) + + return { service, chatDbPath, dir, db, authProfiles, getEmbedCount: () => embedTextCount } +} + +async function enableAndBuild(service: SemanticIndexService): Promise { + service.enable(SESSION_ID) + service.build(SESSION_ID) + await service.whenIdle() +} + +async function waitForModelStatus( + service: SemanticIndexService, + expected: ReturnType +): Promise { + const deadline = Date.now() + 1000 + while (Date.now() < deadline) { + if (service.getModelStatus() === expected) return + await new Promise((resolve) => setTimeout(resolve, 10)) + } + assert.equal(service.getModelStatus(), expected) +} + +test('explicit build indexes an enabled session to completion and reports status', async () => { + const { service, db } = setup() + await enableAndBuild(service) + + const status = service.status(SESSION_ID)! + assert.equal(status.enabled, true) + assert.equal(status.indexStatus, 'completed') + assert.ok(status.chunkCount > 0) + assert.equal(status.totalMessages, 40) + assert.equal(status.needsRebuild, false) + assert.ok(status.coverage > 0) + service.close() + db.close() +}) + +test('enable only marks a session enabled without starting a build', async () => { + const { service, db } = setup({ embedDelayMs: 25 }) + + service.enable(SESSION_ID) + + const status = service.status(SESSION_ID)! + assert.equal(status.enabled, true) + assert.equal(status.indexStatus, 'idle') + assert.equal(status.queued, false) + assert.equal(status.running, false) + assert.equal(status.chunkCount, 0) + + service.close() + db.close() +}) + +test('search returns evidence blocks for an enabled completed index', async () => { + const { service, db } = setup() + await enableAndBuild(service) + + const result = await service.search(SESSION_ID, '项目排期') + assert.equal(result.available, true) + assert.ok(result.blocks.length > 0) + assert.equal(result.partial, false) + service.close() + db.close() +}) + +test('successful warmup retry clears a previous local model preload error', async () => { + const { service, db } = setup({ failFirstPipelineCreation: true }) + + await waitForModelStatus(service, 'error') + await enableAndBuild(service) + + assert.equal(service.status(SESSION_ID)!.indexStatus, 'completed') + assert.equal(service.getModelStatus(), 'ready') + service.close() + db.close() +}) + +test('stale local warmup completion does not mark current local model ready', async () => { + let resolveFirstBatchStarted: () => void = () => {} + const firstBatchStarted = new Promise((resolve) => { + resolveFirstBatchStarted = resolve + }) + const { service, db } = setup({ + embedDelayMs: 50, + failPipelineCreationForModelIds: new Set([BGE_BASE_PROFILE.modelId]), + onEmbedBatchStarted: resolveFirstBatchStarted, + }) + + service.enable(SESSION_ID) + service.build(SESSION_ID) + await firstBatchStarted + + service.setConfig({ version: 1, mode: 'local', local: { modelId: BGE_BASE_PROFILE.modelId }, api: null }) + await waitForModelStatus(service, 'error') + await service.whenIdle() + + assert.equal(service.getModelStatus(), 'error') + service.close() + db.close() +}) + +test('changing model identity marks needsRebuild and blocks search until rebuilt', async () => { + const { service, db } = setup() + await enableAndBuild(service) + + // 切换到不同模型身份(改用 API)-> 身份变化 -> 需重建 + service.setConfig( + { + version: 1, + mode: 'api', + local: { modelId: QWEN3_PROFILE.modelId }, + api: { baseUrl: 'https://x', model: 'emb' }, + }, + { apiKey: 'sk-test' } + ) + assert.equal(service.status(SESSION_ID)!.needsRebuild, true) + const blocked = await service.search(SESSION_ID, '项目排期') + assert.equal(blocked.available, false) + assert.equal(blocked.reason, 'needs-rebuild') + + // 切回原本地模型 -> 身份恢复 -> 旧索引可复用 + service.setConfig({ version: 1, mode: 'local', local: { modelId: QWEN3_PROFILE.modelId }, api: null }) + assert.equal(service.status(SESSION_ID)!.needsRebuild, false) + const restored = await service.search(SESSION_ID, '项目排期') + assert.equal(restored.available, true) + service.close() + db.close() +}) + +test('changing chunker identity marks needsRebuild and blocks search until rebuilt', async () => { + const { service, dir, db } = setup() + await enableAndBuild(service) + assert.equal(service.status(SESSION_ID)!.needsRebuild, false) + assert.equal(service.canSearch(SESSION_ID), true) + + // 模拟索引由旧 chunker 参数建立:直接改写状态表中的 chunker_config_hash + const external = openBetterSqliteDatabase(path.join(dir, 'embedding_index.db')) + external + .prepare('UPDATE semantic_index_session SET chunker_config_hash = ? WHERE db_path_hash = ?') + .run('stale-cfg', computeDbPathHash(SESSION_ID)) + external.close() + + assert.equal(service.status(SESSION_ID)!.needsRebuild, true) + assert.equal(service.canSearch(SESSION_ID), false) + const blocked = await service.search(SESSION_ID, '项目排期') + assert.equal(blocked.available, false) + assert.equal(blocked.reason, 'needs-rebuild') + + // 重建后写入当前 chunker 身份 -> 恢复可检索 + service.rebuild(SESSION_ID) + await service.whenIdle() + assert.equal(service.status(SESSION_ID)!.needsRebuild, false) + const restored = await service.search(SESSION_ID, '项目排期') + assert.equal(restored.available, true) + service.close() + db.close() +}) + +test('buildAllPending rebuilds a stale index back to searchable', async () => { + const { service, dir, db, getEmbedCount } = setup() + await enableAndBuild(service) + const afterFirstBuild = getEmbedCount() + assert.ok(afterFirstBuild > 0) + + // 模拟旧 chunker 参数建立的索引 -> 当前版本判定为 stale + const external = openBetterSqliteDatabase(path.join(dir, 'embedding_index.db')) + external + .prepare('UPDATE semantic_index_session SET chunker_config_hash = ? WHERE db_path_hash = ?') + .run('stale-cfg', computeDbPathHash(SESSION_ID)) + external.close() + assert.equal(service.status(SESSION_ID)!.needsRebuild, true) + + service.buildAllPending() + await service.whenIdle() + + // 重建必须重新嵌入并刷新身份,否则完成后仍 needsRebuild / 不可检索 + assert.ok(getEmbedCount() > afterFirstBuild, 'stale rebuild should re-embed chunks') + assert.equal(service.status(SESSION_ID)!.needsRebuild, false) + assert.equal(service.canSearch(SESSION_ID), true) + const result = await service.search(SESSION_ID, '项目排期') + assert.equal(result.available, true) + service.close() + db.close() +}) + +test('re-enabling a stale index keeps it pending until explicit rebuild', async () => { + const { service, dir, db, getEmbedCount } = setup() + await enableAndBuild(service) + const afterFirstBuild = getEmbedCount() + + const external = openBetterSqliteDatabase(path.join(dir, 'embedding_index.db')) + external + .prepare('UPDATE semantic_index_session SET chunker_config_hash = ? WHERE db_path_hash = ?') + .run('stale-cfg', computeDbPathHash(SESSION_ID)) + external.close() + + // 重新启用旧 stale 索引:只恢复 enabled,不自动消耗算力/API 额度。 + service.enable(SESSION_ID) + + assert.equal(getEmbedCount(), afterFirstBuild) + assert.equal(service.status(SESSION_ID)!.needsRebuild, true) + assert.equal(service.canSearch(SESSION_ID), false) + + service.buildAllPending() + await service.whenIdle() + + assert.ok(getEmbedCount() > afterFirstBuild, 'explicit pending rebuild should re-embed chunks') + assert.equal(service.status(SESSION_ID)!.needsRebuild, false) + assert.equal(service.canSearch(SESSION_ID), true) + service.close() + db.close() +}) + +test('remove clears index immediately and blocks search', async () => { + const { service, db } = setup() + await enableAndBuild(service) + + service.remove(SESSION_ID) + // State record is deleted — status() returns null + assert.equal(service.status(SESSION_ID), null) + const blocked = await service.search(SESSION_ID, '排期') + assert.equal(blocked.available, false) + // cleanupUnused not needed; remove() is already immediate + service.close() + db.close() +}) + +test('re-enabling after remove rebuilds from scratch', async () => { + const { service, db, getEmbedCount } = setup() + await enableAndBuild(service) + const afterFirstBuild = getEmbedCount() + + service.remove(SESSION_ID) + + await enableAndBuild(service) + + assert.ok(getEmbedCount() > afterFirstBuild, 're-enabling a cleaned index should embed chunks again') + assert.equal(service.status(SESSION_ID)!.indexStatus, 'completed') + assert.equal(service.canSearch(SESSION_ID), true) + const result = await service.search(SESSION_ID, '项目排期') + assert.equal(result.available, true) + service.close() + db.close() +}) + +test('rebuild wipes and rebuilds the index', async () => { + const { service, db } = setup() + await enableAndBuild(service) + const before = service.status(SESSION_ID)!.chunkCount + + service.rebuild(SESSION_ID) + await service.whenIdle() + const after = service.status(SESSION_ID)! + assert.equal(after.indexStatus, 'completed') + assert.equal(after.chunkCount, before) + service.close() + db.close() +}) + +test('setConfig stores API key by reference only and hasApiKey reflects it', async () => { + const { service, authProfiles, db } = setup() + + assert.equal(service.hasApiKey(), false) + + const saved = service.setConfig( + { version: 1, mode: 'api', local: { modelId: QWEN3_PROFILE.modelId }, api: { baseUrl: 'https://x', model: 'emb' } }, + { apiKey: 'sk-secret-123' } + ) + + // config 只保存引用,不保存明文 + assert.equal(saved.api?.authProfile, 'semantic-index-embedding') + assert.ok(!JSON.stringify(saved).includes('sk-secret-123')) + // 明文写入 auth profile 存储 + assert.equal(authProfiles.get('semantic-index-embedding')?.key, 'sk-secret-123') + assert.equal(service.hasApiKey(), true) + service.close() + db.close() +}) + +test('keyless local Ollama API config can run without an auth profile', async () => { + const { service, db } = setup() + + service.setConfig({ + version: 1, + mode: 'api', + local: { modelId: QWEN3_PROFILE.modelId }, + api: { baseUrl: 'http://localhost:11434/v1', model: 'nomic-embed-text' }, + }) + + assert.equal(service.hasApiKey(), false) + assert.equal(service.isConfigured(), true) + assert.equal(service.canRun(), true) + service.close() + db.close() +}) + +test('canSearch reflects availability: disabled/needs-rebuild/empty are false', async () => { + const { service, db } = setup() + + // 未启用 + assert.equal(service.canSearch(SESSION_ID), false) + + service.enable(SESSION_ID) + // 未显式建立前没有 chunk + assert.equal(service.canSearch(SESSION_ID), false) + + service.build(SESSION_ID) + await service.whenIdle() + assert.equal(service.canSearch(SESSION_ID), true) + + // 模型身份变化(改用 API)-> 需重建 -> 不可检索 + service.setConfig({ + version: 1, + mode: 'api', + local: { modelId: QWEN3_PROFILE.modelId }, + api: { baseUrl: 'https://x', model: 'emb' }, + searchMaxResults: 5, + }) + assert.equal(service.canSearch(SESSION_ID), false) + + // 切回本地模型 -> 可检索 + service.setConfig({ + version: 1, + mode: 'local', + local: { modelId: QWEN3_PROFILE.modelId }, + api: null, + searchMaxResults: 5, + }) + assert.equal(service.canSearch(SESSION_ID), true) + service.close() + db.close() +}) + +test('searchForTool desensitizes + anonymizes via pipeline, never leaks raw', async () => { + const { service, db } = setup() + await enableAndBuild(service) + + const result = await service.searchForTool(SESSION_ID, '项目排期', { + preprocessConfig: { + anonymizeNames: true, + desensitize: true, + desensitizeRules: [ + { + id: 'r1', + label: 'proj', + pattern: '项目', + replacement: '[REDACTED]', + enabled: true, + builtin: false, + locales: [], + }, + ], + } as Record, + ownerPlatformId: 'p1', + locale: 'zh-CN', + }) + + assert.equal(result.available, true) + assert.ok(result.returned > 0) + assert.ok(result.hitCount > 0) + assert.ok(result.sources.length > 0) + + // 脱敏规则生效:原文被替换(关键风险:证据必须经 applyPreprocessingPipeline) + assert.ok(result.text.includes('[REDACTED]')) + assert.equal(result.text.includes('项目'), false) + // 匿名化生效:LLM 文本只通过 name map 暴露一次映射,正文用 U{id} + assert.ok(result.text.startsWith('[Name Map]')) + + for (const s of result.sources) { + // snippet 已脱敏且不含原始昵称(匿名化为 U{id}),也不含 name map + assert.equal(s.snippet.includes('项目'), false) + assert.equal(s.snippet.includes('张三'), false) + assert.equal(s.snippet.includes('[Name Map]'), false) + assert.equal(s.text?.includes('项目'), false) + assert.equal(s.text?.includes('张三'), false) + assert.equal(s.text?.includes('[Name Map]'), false) + assert.ok(typeof s.startMessageId === 'number') + assert.ok(Array.isArray(s.chunkIds)) + } + // 元数据不得夹带原始消息 + assert.equal(JSON.stringify(result.sources).includes('rawMessages'), false) + + // 回归:snippet 时间口径正确——证据消息 ts 是毫秒,预处理管道按秒渲染(内部 *1000), + // 服务必须把毫秒转回秒,否则会渲染出五位数年份(如 2023 -> 56xxx)。 + const fiveDigitYear = /\b\d{5}\/\d{1,2}\/\d{1,2}/ + assert.equal(fiveDigitYear.test(result.text), false) + assert.ok(result.text.includes('2023/')) + + service.close() + db.close() +}) + +test('disabling the global switch blocks search and re-enabling keeps data usable', async () => { + const { service, db } = setup() + await enableAndBuild(service) + assert.equal(service.canSearch(SESSION_ID), true) + + // 关闭全局开关:不暴露工具 + 检索返回 disabled(已建索引数据保留) + service.setConfig({ version: 1, enabled: false, mode: 'local', local: { modelId: QWEN3_PROFILE.modelId }, api: null }) + assert.equal(service.isEnabled(), false) + assert.equal(service.canSearch(SESSION_ID), false) + const res = await service.search(SESSION_ID, '项目排期') + assert.equal(res.available, false) + assert.equal(res.reason, 'disabled') + + // 重新开启后可直接使用保留的索引 + service.setConfig({ version: 1, enabled: true, mode: 'local', local: { modelId: QWEN3_PROFILE.modelId }, api: null }) + assert.equal(service.canSearch(SESSION_ID), true) + service.close() + db.close() +}) + +test('searchForTool is unavailable when disabled or empty query', async () => { + const { service, db } = setup() + + const disabled = await service.searchForTool(SESSION_ID, '项目排期') + assert.equal(disabled.available, false) + assert.equal(disabled.reason, 'disabled') + + await enableAndBuild(service) + const empty = await service.searchForTool(SESSION_ID, ' ') + assert.equal(empty.available, false) + assert.equal(empty.reason, 'empty-query') + service.close() + db.close() +}) + +test('searchForTool uses configured default max results when caller omits it', async () => { + const { service, db } = setup() + await enableAndBuild(service) + service.setConfig({ + version: 1, + mode: 'local', + local: { modelId: QWEN3_PROFILE.modelId }, + api: null, + searchMaxResults: 5, + }) + + const result = await service.searchForTool(SESSION_ID, '项目排期') + assert.equal(result.available, true) + assert.ok(result.returned <= 5) + service.close() + db.close() +}) + +test('searchForTool honors maxResultTokens as evidence budget ceiling', async () => { + const { service, db } = setup() + await enableAndBuild(service) + + const tiny = await service.searchForTool(SESSION_ID, '项目排期', { maxResults: 10, maxResultTokens: 10 }) + const big = await service.searchForTool(SESSION_ID, '项目排期', { maxResults: 10, maxResultTokens: 100000 }) + + assert.equal(tiny.available, true) + assert.equal(big.available, true) + // 小预算必须显著截断证据文本,证明 maxResultTokens 被服务层消费 + assert.ok(tiny.text.length < big.text.length) + assert.ok(tiny.returned <= big.returned) + service.close() + db.close() +}) + +test('searchForTool applies timeFilter to exclude out-of-range chunks', async () => { + const { service, db } = setup() + await enableAndBuild(service) + + // 不带时间过滤:应有命中 + const all = await service.searchForTool(SESSION_ID, '项目排期', { maxResults: 10 }) + assert.equal(all.available, true) + assert.ok(all.sources.length > 0) + + // 时间范围设在遥远未来:所有 chunk 都应被过滤掉(对秒/毫秒口径均成立) + const farFuture = Date.now() + 10 * 365 * 24 * 3600 * 1000 + const future = await service.searchForTool(SESSION_ID, '项目排期', { + maxResults: 10, + timeFilter: { startTs: farFuture }, + }) + assert.equal(future.sources.length, 0) + assert.equal(future.returned, 0) + service.close() + db.close() +}) + +function tempPersistStore(prefix: string): SemanticIndexConfigStore { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const dir = fs.mkdtempSync(path.join(baseDir, prefix)) + return new SemanticIndexConfigStore(path.join(dir, 'ai', 'semantic-index-config.json')) +} + +// 回归:向量库不可用的降级路径(HTTP stub PUT 复用此 helper)必须持久化 API Key, +// 否则 API 模式在降级期会丢 key,恢复后出现"已配置但 hasApiKey=false 无法检索"。 +test('persistSemanticIndexConfig (degraded path) saves API key by reference, never plaintext', () => { + const store = tempPersistStore('chatlab-si-persist-') + const authProfiles = new Map() + + const saved = persistSemanticIndexConfig( + store, + { version: 1, mode: 'api', local: { modelId: '' }, api: { baseUrl: 'https://x', model: 'emb' } }, + { apiKey: 'sk-degraded-1', writeAuthProfile: (name, profile) => authProfiles.set(name, { key: profile.key }) } + ) + + assert.equal(saved.api?.authProfile, SEMANTIC_INDEX_AUTH_PROFILE) + assert.ok(!JSON.stringify(saved).includes('sk-degraded-1')) + assert.equal(authProfiles.get(SEMANTIC_INDEX_AUTH_PROFILE)?.key, 'sk-degraded-1') + // 已落盘:重新读取仍保留引用 + assert.equal(store.get().api?.authProfile, SEMANTIC_INDEX_AUTH_PROFILE) + + const resolve = (_p: string, ap?: string) => (ap ? (authProfiles.get(ap)?.key ?? '') : '') + assert.equal(resolveSemanticIndexApiKeySet(saved, resolve), true) +}) + +test('persistSemanticIndexConfig ignores apiKey in local mode (no auth profile write)', () => { + const store = tempPersistStore('chatlab-si-persist-local-') + const authProfiles = new Map() + + const saved = persistSemanticIndexConfig( + store, + { version: 1, mode: 'local', local: { modelId: 'qwen3' }, api: null }, + { apiKey: 'should-be-ignored', writeAuthProfile: (name, profile) => authProfiles.set(name, { key: profile.key }) } + ) + + assert.equal(saved.mode, 'local') + assert.equal(authProfiles.size, 0) + assert.equal(resolveSemanticIndexApiKeySet(saved), false) +}) + +test('recover marks stale running as paused without auto-resuming', async () => { + const { service, dir, db } = setup() + await enableAndBuild(service) + + // 模拟崩溃:另一连接把状态置为 running + const external = new SemanticIndexStateStore(path.join(dir, 'embedding_index.db')) + external.setIndexStatus(computeDbPathHash(SESSION_ID), 'running') + external.close() + + service.recover() + assert.equal(service.status(SESSION_ID)!.indexStatus, 'paused') + service.close() + db.close() +}) diff --git a/packages/node-runtime/src/semantic-index/service.ts b/packages/node-runtime/src/semantic-index/service.ts new file mode 100644 index 000000000..a10a999de --- /dev/null +++ b/packages/node-runtime/src/semantic-index/service.ts @@ -0,0 +1,871 @@ +/** + * 语义索引共享 service(node-runtime,Electron 与 CLI Web 复用) + * + * 职责:把配置、向量库、业务状态、串行队列、warmup、检索、证据组装与聊天库适配器 + * 编排成一个有状态服务。每个运行时进程一个实例,由 server 启动时创建、停止时 close。 + * 不做隐藏全局单例。 + * + * 关键约定(见用户决策): + * - 启用/状态以业务状态表为权威,队列只负责执行。 + * - 模型身份(resolveModelId)变化即需重建;模型身份不变(仅换 API Key)不重建。 + * 身份不一致时检索不使用旧索引。 + * - 启动恢复只把 stale running 标记为 paused,不自动续跑。 + */ + +import path from 'node:path' +import { + writeAuthProfile as defaultWriteAuthProfile, + resolveApiKey as defaultResolveApiKey, + type AuthProfile, +} from '@openchatlab/config' +import type { DatabaseAdapter, PathProvider } from '@openchatlab/core' +import { + CHUNKER_VERSION, + DEFAULT_CHUNKER_CONFIG, + computeChunkerConfigHash, + computeDbPathHash, + STRATEGY_ID, +} from './chunker-config' +import type { ChunkSource } from './chunker' +import { + isKeylessSemanticIndexApiBaseUrl, + isSemanticIndexConfigured, + SemanticIndexConfigStore, + type SemanticIndexConfig, + type SemanticIndexConfigInput, +} from './config' +import { createEmbedder, type EmbedderFactoryDeps } from './embedder-factory' +import type { EmbeddingProvider } from './embedding/types' +import { EmbeddingIndexStore, type LoadSqliteVec } from './store' +import { + SemanticIndexStateStore, + type SemanticIndexSessionState, + type SemanticIndexStatus, +} from './session-state-store' +import { SemanticIndexJobQueue, type JobContext } from './warmup/job-queue' +import { runWarmup } from './warmup/runner' +import { hybridSearch } from './retrieval/hybrid-search' +import { assembleEvidence, type EvidenceBlock, type EvidenceBudget, type EvidenceHit } from './retrieval/evidence' +import { createChatDbMessageSource } from './chat-db/message-source' +import { createChatDbMessageRangeReader } from './chat-db/message-range-reader' +import { createChatDbFtsSearcher } from './chat-db/fts-searcher' +import type { SessionRuntimeAdapter } from '../services/adapters' +import { clampSearchMaxResults, SEARCH_MAX_RESULTS_HARD_CAP } from './config' +import { applyPreprocessingPipeline } from '../ai/preprocessor/preprocessing-pipeline' +import type { PreprocessConfig, PreprocessableMessage } from '../ai/preprocessor/types' +import { appLogger } from '../logging/app-logger' + +export interface SemanticIndexServiceOptions { + /** embedding_index.db 路径({vectorDir}/embedding_index.db) */ + vectorDbPath: string + /** semantic-index-config.json 路径({aiDataDir}/semantic-index-config.json) */ + configPath: string + sessionAdapter: SessionRuntimeAdapter + /** 本地模型缓存目录 */ + modelsCacheDir?: string + /** Optional HTTP(S) proxy URL used only for local embedding model downloads. */ + modelDownloadProxyUrl?: string + /** embedder 工厂注入(测试/平台) */ + embedderFactoryDeps?: EmbedderFactoryDeps + /** sqlite-vec 加载器注入(Electron 打包) */ + loadSqliteVec?: LoadSqliteVec + /** better-sqlite3 原生绑定路径(CLI 打包需要) */ + nativeBinding?: string + /** auth profile 写入注入(测试用,避免写真实 ~/.chatlab) */ + writeAuthProfile?: (name: string, profile: AuthProfile) => void +} + +/** 语义索引 API Key 固定存储在此 auth profile;config 只保存引用,不保存明文 */ +export const SEMANTIC_INDEX_AUTH_PROFILE = 'semantic-index-embedding' + +/** + * 仅持久化配置与(可选)API Key,不依赖向量库。 + * 供 SemanticIndexService.setConfig 复用,也供 service 不可用(如 sqlite-vec 加载失败) + * 的降级路径直接写配置,保证 API 模式下 key 不被丢弃。 + */ +export function persistSemanticIndexConfig( + configStore: SemanticIndexConfigStore, + config: SemanticIndexConfigInput, + options?: { apiKey?: string; writeAuthProfile?: (name: string, profile: AuthProfile) => void } +): SemanticIndexConfig { + let next = config + if (options?.apiKey && config.mode === 'api' && config.api) { + const writer = options.writeAuthProfile ?? defaultWriteAuthProfile + writer(SEMANTIC_INDEX_AUTH_PROFILE, { type: 'api_key', provider: 'semantic-index', key: options.apiKey }) + next = { ...config, api: { ...config.api, authProfile: SEMANTIC_INDEX_AUTH_PROFILE } } + } + return configStore.set(next) +} + +/** 当前配置的 API 模式是否已设置可用 Key(不返回明文)。service 与降级路径共用。 */ +export function resolveSemanticIndexApiKeySet( + config: SemanticIndexConfig, + resolveApiKey: (provider: string, authProfile?: string) => string = defaultResolveApiKey +): boolean { + if (config.mode !== 'api' || !config.api || !config.api.authProfile) return false + return resolveApiKey('semantic-index', config.api.authProfile) !== '' +} + +export interface SemanticIndexSessionStatus { + sessionId: string + enabled: boolean + indexStatus: SemanticIndexStatus + needsRebuild: boolean + /** completed session has received new messages since last index run */ + hasNewMessages?: boolean + totalMessages: number + indexedMessages: number + chunkCount: number + coverage: number + queued: boolean + running: boolean + partial: boolean + error: string | null + modelId: string | null +} + +export type SemanticSearchReason = 'disabled' | 'needs-rebuild' | 'empty' | 'not-found' + +/** 面向 AI 工具的安全来源条目(已脱敏,不含原始消息)。结构与 @openchatlab/tools 一致。 */ +export interface SemanticSearchToolSource { + startMessageId: number + endMessageId: number + score: number + chunkIds: string[] + snippet: string + text?: string + startTime?: string + endTime?: string +} + +/** 面向 AI 工具的检索结果:text 已经过 applyPreprocessingPipeline 清洗/脱敏/匿名化/截断 */ +export interface SemanticSearchToolResult { + available: boolean + reason?: string + text: string + returned: number + hitCount: number + partial: boolean + coverage: number + truncated: boolean + timeRange?: { earliest: string; latest: string } + sources: SemanticSearchToolSource[] +} + +export interface SemanticSearchToolOptions { + maxResults?: number + preprocessConfig?: Record + ownerPlatformId?: string + locale?: string + maxResultTokens?: number + /** 毫秒级时间范围过滤(可单边);仅保留与 chunk 时间范围有交集的语义候选 */ + timeFilter?: { startTs?: number; endTs?: number } +} + +/** 工具返回片段预览的最大字符数 */ +const SEARCH_TOOL_SNIPPET_MAX = 160 + +/** + * 注入证据的固定 token 预算(与 max_results 解耦)。 + * max_results 只决定召回候选数,最终注入量由本预算(再以 maxResultTokens 封顶)控制, + * 避免候选变多导致上下文 token 膨胀;单块 soft cap 保持较小,让宽泛检索优先增加结果数量。 + */ +const SEARCH_TOOL_EVIDENCE_TOKENS = 8000 + +export function resolveSearchToolEvidenceTokens(maxResultTokens?: number): number { + return maxResultTokens && maxResultTokens > 0 + ? Math.min(SEARCH_TOOL_EVIDENCE_TOKENS, maxResultTokens) + : SEARCH_TOOL_EVIDENCE_TOKENS +} + +export interface SemanticSearchResult { + available: boolean + reason?: SemanticSearchReason + blocks: EvidenceBlock[] + coverage: number + /** 索引未完成时为 true,证据可能不完整 */ + partial: boolean + /** RRF 融合后的候选命中数(证据组装前) */ + hitCount: number +} + +function sessionIdFromDbPath(dbPath: string): string { + return path.basename(dbPath, '.db') +} + +/** 语义索引相关文件名(统一约定,避免各端硬编码) */ +export const SEMANTIC_INDEX_DB_FILE = 'embedding_index.db' +export const SEMANTIC_INDEX_CONFIG_FILE = 'semantic-index-config.json' + +/** + * 按 PathProvider 约定路径创建 service: + * - 向量库:{vectorDir}/embedding_index.db + * - 配置:{aiDataDir}/semantic-index-config.json + * - 本地模型:{aiDataDir}/models/semantic-index(不在 cache 目录,不随清理缓存丢失) + */ +export function createSemanticIndexService(params: { + pathProvider: PathProvider + sessionAdapter: SessionRuntimeAdapter + nativeBinding?: string + loadSqliteVec?: LoadSqliteVec + embedderFactoryDeps?: EmbedderFactoryDeps + modelDownloadProxyUrl?: string +}): SemanticIndexService { + const { pathProvider } = params + return new SemanticIndexService({ + vectorDbPath: path.join(pathProvider.getVectorDir(), SEMANTIC_INDEX_DB_FILE), + configPath: path.join(pathProvider.getAiDataDir(), SEMANTIC_INDEX_CONFIG_FILE), + modelsCacheDir: path.join(pathProvider.getAiDataDir(), 'models', 'semantic-index'), + modelDownloadProxyUrl: params.modelDownloadProxyUrl, + sessionAdapter: params.sessionAdapter, + nativeBinding: params.nativeBinding, + loadSqliteVec: params.loadSqliteVec, + embedderFactoryDeps: params.embedderFactoryDeps, + }) +} + +export class SemanticIndexService { + private store: EmbeddingIndexStore + private stateStore: SemanticIndexStateStore + private configStore: SemanticIndexConfigStore + private queue: SemanticIndexJobQueue + private sessionAdapter: SessionRuntimeAdapter + private options: SemanticIndexServiceOptions + + private embedder: EmbeddingProvider | null = null + private embedderModelId: string | null = null + private modelPreloadStatus: 'idle' | 'downloading' | 'ready' | 'error' = 'idle' + + /** 当前激活的 chunker 身份(version + 参数 hash),随索引记录用于重建判定 */ + private readonly chunkerVersion = CHUNKER_VERSION + private readonly chunkerConfigHash = computeChunkerConfigHash(DEFAULT_CHUNKER_CONFIG) + + constructor(options: SemanticIndexServiceOptions) { + this.options = options + this.sessionAdapter = options.sessionAdapter + this.store = new EmbeddingIndexStore(options.vectorDbPath, { + loadSqliteVec: options.loadSqliteVec, + nativeBinding: options.nativeBinding, + }) + this.stateStore = new SemanticIndexStateStore(options.vectorDbPath, { nativeBinding: options.nativeBinding }) + this.configStore = new SemanticIndexConfigStore(options.configPath) + this.queue = new SemanticIndexJobQueue((ctx) => this.runJob(ctx)) + } + + // ---------- 配置 ---------- + + getConfig(): SemanticIndexConfig { + return this.configStore.get() + } + + /** 用户是否已显式配置向量模型(区分默认兜底配置) */ + isConfigured(): boolean { + return this.configStore.isConfigured() + } + + /** 全局功能开关是否开启 */ + isEnabled(): boolean { + return this.configStore.isEnabled() + } + + /** 语义索引是否可实际建立/检索:全局开启且已选择模型 */ + canRun(): boolean { + if (!this.configStore.canRun()) return false + const cfg = this.configStore.get() + return cfg.mode !== 'api' || isKeylessSemanticIndexApiBaseUrl(cfg.api?.baseUrl) || this.hasApiKey() + } + + /** 当前 API 模式是否已配置可用的 API Key(不返回明文,仅布尔) */ + hasApiKey(): boolean { + return resolveSemanticIndexApiKeySet(this.configStore.get(), this.options.embedderFactoryDeps?.resolveApiKey) + } + + /** + * 保存配置;模型身份变化会使已启用对话的索引在重建完成前不被检索使用。 + * 传入 apiKey 时写入固定 auth profile,config 内只保存引用,绝不持久化明文。 + */ + getModelStatus(): 'idle' | 'downloading' | 'ready' | 'error' { + return this.modelPreloadStatus + } + + setConfig(config: SemanticIndexConfigInput, options?: { apiKey?: string }): SemanticIndexConfig { + const saved = persistSemanticIndexConfig(this.configStore, config, { + apiKey: options?.apiKey, + writeAuthProfile: this.options.writeAuthProfile, + }) + this.embedder = null + this.embedderModelId = null + this.modelPreloadStatus = 'idle' + // 关闭全局开关时停止所有在跑/排队的建索引任务(保留已建立的部分数据) + if (!saved.enabled) { + for (const state of this.stateStore.listEnabled()) this.queue.pause(state.dbPathHash) + } + // 本地模式已配置且功能已开启时,立即在后台触发模型下载,让用户在建索引前完成等待 + if (saved.enabled && isSemanticIndexConfigured(saved) && saved.mode === 'local') { + const modelId = saved.local.modelId + const cacheDir = this.options.modelsCacheDir + this.modelPreloadStatus = 'downloading' + appLogger.info('semantic-index', 'local embedding model preload started', { modelId, cacheDir }) + void this.getEmbedder() + .preload?.() + .then(() => { + this.modelPreloadStatus = 'ready' + appLogger.info('semantic-index', 'local embedding model preload completed', { modelId, cacheDir }) + }) + .catch((error) => { + this.modelPreloadStatus = 'error' + appLogger.error('semantic-index', `local embedding model preload failed: ${modelId}`, error) + }) + } + return saved + } + + private currentModelId(): string { + return this.configStore.resolveModelId() + } + + private getEmbedder(): EmbeddingProvider { + const modelId = this.currentModelId() + if (!this.embedder || this.embedderModelId !== modelId) { + this.embedder = createEmbedder(this.configStore.get(), { + modelsCacheDir: this.options.modelsCacheDir, + modelDownloadProxyUrl: this.options.modelDownloadProxyUrl, + ...this.options.embedderFactoryDeps, + }) + this.embedderModelId = modelId + } + return this.embedder + } + + // ---------- 启用 / 生命周期 ---------- + + private hashFor(sessionId: string): string { + return computeDbPathHash(sessionId) + } + + /** + * 查找 sessionId 对应的实际 db_path_hash。 + * 优先用当前规则(computeDbPathHash(sessionId)),找不到时扫全表匹配 dbPath basename, + * 兼容旧版本用完整 dbPath 计算 hash 存储的历史记录。 + */ + private resolveHash(sessionId: string): string { + const hash = this.hashFor(sessionId) + if (this.stateStore.getState(hash)) return hash + const legacy = this.stateStore.listAll().find((s) => sessionIdFromDbPath(s.dbPath) === sessionId) + return legacy?.dbPathHash ?? hash + } + + /** 索引身份是否与当前运行时不一致(模型身份或 chunker 身份变化 => 需重建) */ + private isStale(state: SemanticIndexSessionState, currentModelId: string): boolean { + return ( + state.modelId !== currentModelId || + state.chunkerVersion !== this.chunkerVersion || + state.chunkerConfigHash !== this.chunkerConfigHash + ) + } + + private enableParams( + dbPath: string, + dbPathHash?: string + ): { + dbPathHash: string + dbPath: string + modelId: string + chunkerVersion: string + chunkerConfigHash: string + } { + return { + dbPathHash: dbPathHash ?? computeDbPathHash(sessionIdFromDbPath(dbPath)), + dbPath, + modelId: this.currentModelId(), + chunkerVersion: this.chunkerVersion, + chunkerConfigHash: this.chunkerConfigHash, + } + } + + enable(sessionId: string): void { + if (!this.canRun()) return + const dbPath = this.sessionAdapter.getDbPath(sessionId) + const hash = computeDbPathHash(sessionId) + const existing = this.stateStore.getState(hash) + const stale = !!existing && this.isStale(existing, this.currentModelId()) + if (stale) { + // 保留旧身份字段,只重置 enabled 和 index_status。 + // 这样 app 在 rebuild 运行前退出后,重启时 buildAllPending() 仍能通过 + // isStale() 检测到需重建并入队 rebuild,避免永久卡死在"新身份+无向量"状态。 + this.stateStore.reactivate(hash, dbPath) + this.stateStore.setIndexStatus(hash, 'idle') + } else { + this.stateStore.enable(this.enableParams(dbPath)) + } + } + + /** 彻底移除:取消队列、删除向量数据、删除状态记录,立即生效无需二次清理 */ + remove(sessionId: string): void { + const hash = this.resolveHash(sessionId) + this.queue.cancel(hash) + this.store.deleteByDbPathHash(hash) + this.stateStore.remove(hash) + } + + /** 建立 / 续跑索引(续跑从断点游标继续) */ + build(sessionId: string): void { + if (!this.canRun()) return + this.queue.enqueue({ type: 'build', dbPathHash: this.resolveHash(sessionId) }) + } + + pause(sessionId: string): void { + this.queue.pause(this.resolveHash(sessionId)) + } + + cancel(sessionId: string): void { + this.queue.cancel(this.resolveHash(sessionId)) + } + + /** 重建:换模型身份或用户主动重建时清空旧索引后重新建立 */ + rebuild(sessionId: string): void { + if (!this.canRun()) return + const dbPath = this.sessionAdapter.getDbPath(sessionId) + const hash = this.resolveHash(sessionId) + this.queue.cancel(hash) + this.stateStore.enable(this.enableParams(dbPath, hash)) + this.queue.enqueue({ type: 'rebuild', dbPathHash: hash }) + } + + /** 为所有启用但未完成 / 需重建的对话入队("建立待处理索引") */ + buildAllPending(): void { + if (!this.canRun()) return + const modelId = this.currentModelId() + for (const state of this.stateStore.listEnabled()) { + if (state.indexStatus === 'running') continue + if (this.isStale(state, modelId)) { + this.queue.enqueue({ type: 'rebuild', dbPathHash: state.dbPathHash }) + } else if (state.indexStatus !== 'completed') { + this.queue.enqueue({ type: 'build', dbPathHash: state.dbPathHash }) + } else { + // Completed sessions may have received new messages since last index run + const sessionId = sessionIdFromDbPath(state.dbPath) + const db = this.sessionAdapter.openReadonly(sessionId) + if (db) { + const liveCount = (db.prepare('SELECT COUNT(*) AS c FROM message').get() as { c: number } | undefined)?.c ?? 0 + if (liveCount > state.totalMessages) { + this.queue.enqueue({ type: 'build', dbPathHash: state.dbPathHash }) + } else if (liveCount < state.totalMessages) { + // Messages were deleted (e.g. member deletion); must rebuild to remove stale vectors + this.queue.enqueue({ type: 'rebuild', dbPathHash: state.dbPathHash }) + } + } + } + } + } + + // ---------- 状态 ---------- + + private toStatus(state: SemanticIndexSessionState, currentModelId: string): SemanticIndexSessionStatus { + const needsRebuild = state.enabled && this.isStale(state, currentModelId) + const coverage = state.totalMessages > 0 ? Math.min(1, state.indexedMessages / state.totalMessages) : 0 + return { + sessionId: sessionIdFromDbPath(state.dbPath), + enabled: state.enabled, + indexStatus: state.indexStatus, + needsRebuild, + totalMessages: state.totalMessages, + indexedMessages: state.indexedMessages, + chunkCount: state.chunkCount, + coverage, + queued: this.queue.isQueued(state.dbPathHash), + running: this.queue.isRunning(state.dbPathHash), + partial: state.indexStatus !== 'completed', + error: state.error, + modelId: state.modelId, + } + } + + status(sessionId: string): SemanticIndexSessionStatus | null { + const state = this.stateStore.getState(this.resolveHash(sessionId)) + if (!state) return null + return this.toStatus(state, this.currentModelId()) + } + + /** 批量查询多个对话状态(未启用的返回 null 过滤) */ + statusForSessions(sessionIds: string[]): SemanticIndexSessionStatus[] { + const modelId = this.currentModelId() + const result: SemanticIndexSessionStatus[] = [] + for (const sessionId of sessionIds) { + const state = this.stateStore.getState(this.resolveHash(sessionId)) + if (state) result.push(this.toStatus(state, modelId)) + } + return result + } + + listEnabledStatuses(): SemanticIndexSessionStatus[] { + const modelId = this.currentModelId() + return this.stateStore.listEnabled().map((s) => { + const status = this.toStatus(s, modelId) + if (s.indexStatus === 'completed' && !status.needsRebuild && !status.queued && !status.running) { + const sessionId = sessionIdFromDbPath(s.dbPath) + const db = this.sessionAdapter.openReadonly(sessionId) + if (db) { + const liveCount = (db.prepare('SELECT COUNT(*) AS c FROM message').get() as { c: number } | undefined)?.c ?? 0 + if (liveCount !== s.totalMessages) status.hasNewMessages = true + } + } + return status + }) + } + + // ---------- 检索 ---------- + + async search( + sessionId: string, + query: string, + options?: { finalTopK?: number; budget?: EvidenceBudget; timeRangeMs?: { startTs?: number; endTs?: number } } + ): Promise { + if (!this.canRun()) + return { available: false, reason: 'disabled', blocks: [], coverage: 0, partial: false, hitCount: 0 } + + const hash = this.resolveHash(sessionId) + const state = this.stateStore.getState(hash) + const modelId = this.currentModelId() + + if (!state || !state.enabled) + return { available: false, reason: 'disabled', blocks: [], coverage: 0, partial: false, hitCount: 0 } + if (this.isStale(state, modelId)) + return { available: false, reason: 'needs-rebuild', blocks: [], coverage: 0, partial: true, hitCount: 0 } + + const dim = this.store.getDim(hash, modelId) + if (!dim || state.chunkCount === 0) + return { + available: false, + reason: 'empty', + blocks: [], + coverage: 0, + partial: state.indexStatus !== 'completed', + hitCount: 0, + } + + const db = this.sessionAdapter.openReadonly(sessionId) + if (!db) return { available: false, reason: 'not-found', blocks: [], coverage: 0, partial: false, hitCount: 0 } + + const embedder = this.getEmbedder() + const fts = createChatDbFtsSearcher(db) + const finalTopK = options?.finalTopK ?? 5 + // 候选池随 finalTopK 放大:短 chunk 下保证高 max_results 时召回覆盖足够(下限保持默认 40) + const candidateTopN = Math.max(40, finalTopK * 4) + const hits = await hybridSearch( + { embedder, store: this.store, fts }, + { + query, + dbPathHash: hash, + modelId, + strategyId: STRATEGY_ID, + dim, + finalTopK, + denseTopN: candidateTopN, + ftsTopN: candidateTopN, + timeRangeMs: options?.timeRangeMs, + } + ) + + const evidenceHits: EvidenceHit[] = hits.map((h) => ({ + chunkId: h.chunkId, + score: h.score, + parentId: h.record.parentId, + startMessageId: h.record.startMessageId, + endMessageId: h.record.endMessageId, + startTs: h.record.startTs, + endTs: h.record.endTs, + })) + const reader = createChatDbMessageRangeReader(db) + const { blocks } = assembleEvidence(reader, evidenceHits, options?.budget) + + const coverage = state.totalMessages > 0 ? Math.min(1, state.indexedMessages / state.totalMessages) : 0 + return { available: true, blocks, coverage, partial: state.indexStatus !== 'completed', hitCount: hits.length } + } + + /** + * 当前会话是否可被 AI 工具检索。 + * 已启用 + 模型身份一致 + 有 chunk + 维度可用即可(running/paused/failed 只要已有 chunk 也可,结果标记 partial)。 + * 未启用 / 需重建 / 无 chunk 返回 false,runner 据此不向 LLM 暴露检索工具。 + */ + canSearch(sessionId: string): boolean { + if (!this.canRun()) return false + const hash = this.resolveHash(sessionId) + const state = this.stateStore.getState(hash) + if (!state || !state.enabled) return false + const modelId = this.currentModelId() + if (this.isStale(state, modelId)) return false + if (state.chunkCount <= 0) return false + return !!this.store.getDim(hash, modelId) + } + + /** + * 面向 AI 工具的检索:复用 search(),再用 applyPreprocessingPipeline 对证据做清洗/脱敏/匿名化/截断。 + * 工具层只拿到安全文本与安全 metadata,绝不接触原始消息。 + */ + async searchForTool( + sessionId: string, + query: string, + options?: SemanticSearchToolOptions + ): Promise { + const unavailable = (reason: string, partial = false): SemanticSearchToolResult => ({ + available: false, + reason, + text: '', + returned: 0, + hitCount: 0, + partial, + coverage: 0, + truncated: false, + sources: [], + }) + + const q = query?.trim() + if (!q) return unavailable('empty-query') + + const configuredDefault = clampSearchMaxResults(this.configStore.get().searchMaxResults) + const requested = options?.maxResults ?? configuredDefault + const finalTopK = Math.max(1, Math.min(SEARCH_MAX_RESULTS_HARD_CAP, Math.floor(requested))) + + // 注入预算与 max_results 解耦:固定预算再以工具结果预算(maxResultTokens)封顶。 + // finalTopK 只放大召回候选数,不放大注入 token,避免大范围查询撑爆上下文。 + const evidenceTokens = resolveSearchToolEvidenceTokens(options?.maxResultTokens) + + let result: SemanticSearchResult + try { + result = await this.search(sessionId, q, { + finalTopK, + budget: { maxChunks: finalTopK, totalTokens: evidenceTokens }, + timeRangeMs: options?.timeFilter, + }) + } catch (err) { + return unavailable(err instanceof Error ? err.message : String(err)) + } + if (!result.available) return unavailable(result.reason ?? 'unavailable', result.partial) + + // 归一化预处理配置:补齐数组字段,避免 partial 配置导致管道崩溃(脱敏规则仍以传入为准) + const preprocessConfig = options?.preprocessConfig + ? ({ blacklistKeywords: [], desensitizeRules: [], ...options.preprocessConfig } as unknown as PreprocessConfig) + : undefined + const anonymizeNames = !!preprocessConfig?.anonymizeNames + const locale = options?.locale + const ownerPlatformId = options?.ownerPlatformId + + const sources: SemanticSearchToolSource[] = [] + const blockTexts: string[] = [] + // 全局昵称映射(仅 anonymize 时使用),保证整段证据只出现一次 name map + const nameMap = new Map() + let earliest = Number.POSITIVE_INFINITY + let latest = Number.NEGATIVE_INFINITY + + for (const block of result.blocks) { + const rawMessages: PreprocessableMessage[] = block.messages.map((m) => ({ + id: m.id, + senderId: m.senderId, + senderName: m.senderName, + senderPlatformId: m.senderPlatformId, + content: m.content, + // EvidenceMessage.ts 是毫秒;预处理管道按秒渲染时间(format.ts 内部 *1000),故此处回退为秒 + timestamp: Math.floor(m.ts / 1000), + })) + for (const m of block.messages) { + if (m.senderId != null && !nameMap.has(m.senderId)) { + nameMap.set(m.senderId, { name: m.senderName, platformId: m.senderPlatformId }) + } + } + // 每块统一过 applyPreprocessingPipeline 做清洗/脱敏/匿名化;用 details.messages(不含 name map 行) + const { details } = applyPreprocessingPipeline({ + rawMessages, + preprocessConfig, + locale, + anonymizeNames, + ownerPlatformId, + }) + const lines = Array.isArray(details.messages) ? (details.messages as string[]) : [] + const safeText = lines.join('\n').trim() + const first = block.messages[0] + const last = block.messages[block.messages.length - 1] + const startTime = first ? new Date(first.ts).toISOString() : undefined + const endTime = last ? new Date(last.ts).toISOString() : undefined + for (const m of block.messages) { + if (m.ts < earliest) earliest = m.ts + if (m.ts > latest) latest = m.ts + } + const snippet = + safeText.length > SEARCH_TOOL_SNIPPET_MAX ? `${safeText.slice(0, SEARCH_TOOL_SNIPPET_MAX)}…` : safeText + sources.push({ + startMessageId: block.startMessageId, + endMessageId: block.endMessageId, + score: block.score, + chunkIds: block.chunkIds, + snippet, + text: safeText, + startTime, + endTime, + }) + const rangeLabel = startTime && endTime ? `${startTime} ~ ${endTime}` : '' + blockTexts.push(`--- ${rangeLabel}\n${safeText}`) + } + + let text = blockTexts.join('\n\n') + if (anonymizeNames && nameMap.size > 0) { + const entries = [...nameMap.entries()].map( + ([id, { name, platformId }]) => + `U${id}=${name}${ownerPlatformId && platformId === ownerPlatformId ? '(owner)' : ''}` + ) + text = `[Name Map] ${entries.join(' | ')}\n\n${text}` + } + + const timeRange = + Number.isFinite(earliest) && Number.isFinite(latest) + ? { earliest: new Date(earliest).toISOString(), latest: new Date(latest).toISOString() } + : undefined + + // 命中候选已填满 finalTopK 上限,说明可能还有更多相关历史,提示用更具体 query 继续 + const truncated = result.hitCount >= finalTopK + + return { + available: true, + text, + returned: result.blocks.length, + hitCount: result.hitCount, + partial: result.partial, + coverage: result.coverage, + truncated, + timeRange, + sources, + } + } + + // ---------- 清理 ---------- + + /** 清理已停用待清理的对话索引,以及无对应业务状态的孤儿 chunk */ + cleanupUnused(): { cleaned: number } { + let cleaned = 0 + + // 检测已删除的对话(DB 文件不再存在):将其状态标为 pending cleanup,交由下方统一清理 + const activeSessionIds = new Set(this.sessionAdapter.listSessionIds()) + for (const state of this.stateStore.listAll()) { + if (state.cleanupStatus !== 'pending' && !activeSessionIds.has(sessionIdFromDbPath(state.dbPath))) { + this.queue.cancel(state.dbPathHash) + this.stateStore.disable(state.dbPathHash) + } + } + + for (const state of this.stateStore.listPendingCleanup()) { + this.queue.cancel(state.dbPathHash) + this.store.deleteByDbPathHash(state.dbPathHash) + this.stateStore.resetProgress(state.dbPathHash) + this.stateStore.setCleanupStatus(state.dbPathHash, 'done') + cleaned++ + } + const known = new Set(this.stateStore.listAll().map((s) => s.dbPathHash)) + for (const hash of this.store.listDbPathHashes()) { + if (!known.has(hash)) { + this.store.deleteByDbPathHash(hash) + cleaned++ + } + } + return { cleaned } + } + + // ---------- 启动恢复 ---------- + + /** 启动时把中断的 running 标记为 paused,保留断点;不自动续跑 */ + recover(): void { + for (const state of this.stateStore.listAll()) { + if (state.indexStatus === 'running') { + this.stateStore.setIndexStatus(state.dbPathHash, 'paused') + } + } + } + + // ---------- 队列执行 ---------- + + private resolveSource(db: DatabaseAdapter): ChunkSource { + try { + const row = db.prepare('SELECT name, type FROM meta LIMIT 1').get() as + | { name?: string; type?: string } + | undefined + const kind: ChunkSource['kind'] = row?.type === 'group' ? 'group' : 'private' + return { title: row?.name ?? '', kind } + } catch { + return { title: '', kind: 'private' } + } + } + + private async runJob(ctx: JobContext): Promise { + const { job, checkStop } = ctx + if (job.type === 'cleanup') { + this.cleanupUnused() + return + } + + const state = this.stateStore.getState(job.dbPathHash) + if (!state || !state.enabled) return + const sessionId = sessionIdFromDbPath(state.dbPath) + + const db = this.sessionAdapter.openReadonly(sessionId) + if (!db) { + this.stateStore.setIndexStatus(job.dbPathHash, 'failed', 'session database not found') + return + } + + if (job.type === 'rebuild') { + this.store.deleteByDbPathHash(job.dbPathHash) + this.stateStore.resetProgress(job.dbPathHash) + // 以当前模型/chunker 身份重建:刷新状态身份,确保完成后 needsRebuild=false、可检索 + this.stateStore.setBuildIdentity(job.dbPathHash, { + modelId: this.currentModelId(), + chunkerVersion: this.chunkerVersion, + chunkerConfigHash: this.chunkerConfigHash, + }) + } + + const jobConfig = this.configStore.get() + const jobModelId = this.currentModelId() + const jobEmbedder = this.getEmbedder() + const source = createChatDbMessageSource(db, this.resolveSource(db)) + const startedAt = Date.now() + appLogger.info('semantic-index', 'warmup job started', { type: job.type, sessionId, dbPathHash: job.dbPathHash }) + const result = await runWarmup({ + dbPathHash: job.dbPathHash, + modelId: jobModelId, + embedder: jobEmbedder, + store: this.store, + stateStore: this.stateStore, + source, + checkStop, + }) + // warmup 已成功写入 chunk,说明本地 extractor 通过重试加载成功;清除此前 preload 失败状态。 + const currentConfig = this.configStore.get() + if ( + result.status === 'completed' && + result.chunksWritten > 0 && + jobConfig.mode === 'local' && + currentConfig.mode === 'local' && + currentConfig.local.modelId === jobConfig.local.modelId + ) { + this.modelPreloadStatus = 'ready' + } + appLogger.info('semantic-index', 'warmup job finished', { + type: job.type, + sessionId, + dbPathHash: job.dbPathHash, + status: result.status, + chunksWritten: result.chunksWritten, + elapsedMs: Date.now() - startedAt, + error: result.error, + }) + } + + /** 等待队列清空(优雅停止 / 测试用) */ + whenIdle(): Promise { + return this.queue.whenIdle() + } + + close(): void { + this.store.close() + this.stateStore.close() + } +} diff --git a/packages/node-runtime/src/semantic-index/session-state-store.test.ts b/packages/node-runtime/src/semantic-index/session-state-store.test.ts new file mode 100644 index 000000000..3e23a1041 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/session-state-store.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { SemanticIndexStateStore } from './session-state-store' + +function makeTempDbPath(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-sis-')) + return path.join(dir, 'embedding_index.db') +} + +const ENABLE = { + dbPathHash: 'dbA', + dbPath: '/data/a.db', + modelId: 'qwen3', + chunkerVersion: 'v1.1', + chunkerConfigHash: 'cfg', +} + +test('enable creates an enabled idle state', () => { + const store = new SemanticIndexStateStore(makeTempDbPath()) + store.enable(ENABLE) + const state = store.getState('dbA')! + assert.equal(state.enabled, true) + assert.equal(state.modelId, 'qwen3') + assert.equal(state.indexStatus, 'idle') + assert.equal(state.cleanupStatus, 'none') + assert.ok(state.enabledAt && state.enabledAt > 0) + store.close() +}) + +test('getState returns null for unknown conversation', () => { + const store = new SemanticIndexStateStore(makeTempDbPath()) + assert.equal(store.getState('missing'), null) + store.close() +}) + +test('disable marks not enabled and cleanup pending without deleting state', () => { + const store = new SemanticIndexStateStore(makeTempDbPath()) + store.enable(ENABLE) + store.disable('dbA') + const state = store.getState('dbA')! + assert.equal(state.enabled, false) + assert.equal(state.cleanupStatus, 'pending') + store.close() +}) + +test('re-enable clears pending cleanup', () => { + const store = new SemanticIndexStateStore(makeTempDbPath()) + store.enable(ENABLE) + store.disable('dbA') + store.enable(ENABLE) + const state = store.getState('dbA')! + assert.equal(state.enabled, true) + assert.equal(state.cleanupStatus, 'none') + store.close() +}) + +test('enable with a different model updates model id (rebuild scenario)', () => { + const store = new SemanticIndexStateStore(makeTempDbPath()) + store.enable(ENABLE) + store.enable({ ...ENABLE, modelId: 'modelB' }) + assert.equal(store.getState('dbA')!.modelId, 'modelB') + store.close() +}) + +test('updateProgress patches counters and index status', () => { + const store = new SemanticIndexStateStore(makeTempDbPath()) + store.enable(ENABLE) + store.updateProgress('dbA', { + indexStatus: 'running', + totalMessages: 1000, + indexedMessages: 250, + lastIndexedMessageId: 250, + chunkCount: 40, + }) + const state = store.getState('dbA')! + assert.equal(state.indexStatus, 'running') + assert.equal(state.totalMessages, 1000) + assert.equal(state.indexedMessages, 250) + assert.equal(state.lastIndexedMessageId, 250) + assert.equal(state.chunkCount, 40) + store.close() +}) + +test('setIndexStatus records failure with error message', () => { + const store = new SemanticIndexStateStore(makeTempDbPath()) + store.enable(ENABLE) + store.setIndexStatus('dbA', 'failed', 'disk full') + const state = store.getState('dbA')! + assert.equal(state.indexStatus, 'failed') + assert.equal(state.error, 'disk full') + store.close() +}) + +test('listEnabled and listPendingCleanup filter correctly', () => { + const store = new SemanticIndexStateStore(makeTempDbPath()) + store.enable({ dbPathHash: 'a', dbPath: '/a.db', modelId: 'qwen3', chunkerVersion: 'v1.1', chunkerConfigHash: 'cfg' }) + store.enable({ dbPathHash: 'b', dbPath: '/b.db', modelId: 'qwen3', chunkerVersion: 'v1.1', chunkerConfigHash: 'cfg' }) + store.disable('b') + + assert.deepEqual( + store.listEnabled().map((s) => s.dbPathHash), + ['a'] + ) + assert.deepEqual( + store.listPendingCleanup().map((s) => s.dbPathHash), + ['b'] + ) + store.close() +}) + +test('setCleanupStatus and remove manage cleanup lifecycle', () => { + const store = new SemanticIndexStateStore(makeTempDbPath()) + store.enable(ENABLE) + store.disable('dbA') + store.setCleanupStatus('dbA', 'running') + assert.equal(store.getState('dbA')!.cleanupStatus, 'running') + store.remove('dbA') + assert.equal(store.getState('dbA'), null) + store.close() +}) + +test('state persists across reopen', () => { + const dbPath = makeTempDbPath() + const store = new SemanticIndexStateStore(dbPath) + store.enable(ENABLE) + store.updateProgress('dbA', { indexedMessages: 5 }) + store.close() + + const reopened = new SemanticIndexStateStore(dbPath) + const state = reopened.getState('dbA')! + assert.equal(state.enabled, true) + assert.equal(state.indexedMessages, 5) + reopened.close() +}) diff --git a/packages/node-runtime/src/semantic-index/session-state-store.ts b/packages/node-runtime/src/semantic-index/session-state-store.ts new file mode 100644 index 000000000..9b0051433 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/session-state-store.ts @@ -0,0 +1,269 @@ +/** + * 语义索引业务状态存储(与向量库同一 embedding_index.db 文件) + * + * 保存对话级权威状态:是否启用、索引进度/状态、清理状态。后台任务队列只负责执行, + * 启用与结果状态不从队列反推(见 chunking-decision-final.md 第 11.1/15 节)。 + * 以 db_path_hash 作为对话主键(一个聊天库 = 一个对话)。 + */ + +import Database from 'better-sqlite3' +import { SEMANTIC_INDEX_SESSION_TABLE } from './schema' + +/** 索引生命周期状态 */ +export type SemanticIndexStatus = 'idle' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled' + +/** 清理生命周期状态 */ +export type SemanticIndexCleanupStatus = 'none' | 'pending' | 'running' | 'done' + +export interface SemanticIndexSessionState { + dbPathHash: string + dbPath: string + enabled: boolean + modelId: string | null + indexStatus: SemanticIndexStatus + cleanupStatus: SemanticIndexCleanupStatus + totalMessages: number + indexedMessages: number + lastIndexedMessageId: number | null + chunkCount: number + /** 建立索引时使用的 chunker 算法版本(与当前不一致 => 需重建) */ + chunkerVersion: string | null + /** 建立索引时使用的 chunker 参数 hash(与当前不一致 => 需重建) */ + chunkerConfigHash: string | null + error: string | null + enabledAt: number | null + updatedAt: number +} + +export interface EnableParams { + dbPathHash: string + dbPath: string + modelId: string + /** 当前 chunker 算法版本,随索引一起记录用于重建判断 */ + chunkerVersion: string + /** 当前 chunker 参数 hash,随索引一起记录用于重建判断 */ + chunkerConfigHash: string +} + +export interface ProgressPatch { + indexStatus?: SemanticIndexStatus + totalMessages?: number + indexedMessages?: number + lastIndexedMessageId?: number + chunkCount?: number + error?: string | null +} + +interface SessionRow { + db_path_hash: string + db_path: string + enabled: number + model_id: string | null + index_status: string + cleanup_status: string + total_messages: number + indexed_messages: number + last_indexed_message_id: number | null + chunk_count: number + chunker_version: string | null + chunker_config_hash: string | null + error: string | null + enabled_at: number | null + updated_at: number +} + +function rowToState(row: SessionRow): SemanticIndexSessionState { + return { + dbPathHash: row.db_path_hash, + dbPath: row.db_path, + enabled: row.enabled === 1, + modelId: row.model_id, + indexStatus: row.index_status as SemanticIndexStatus, + cleanupStatus: row.cleanup_status as SemanticIndexCleanupStatus, + totalMessages: row.total_messages, + indexedMessages: row.indexed_messages, + lastIndexedMessageId: row.last_indexed_message_id, + chunkCount: row.chunk_count, + chunkerVersion: row.chunker_version ?? null, + chunkerConfigHash: row.chunker_config_hash ?? null, + error: row.error, + enabledAt: row.enabled_at, + updatedAt: row.updated_at, + } +} + +export class SemanticIndexStateStore { + private db: Database.Database + + constructor(dbPath: string, options?: { nativeBinding?: string }) { + this.db = new Database(dbPath, { nativeBinding: options?.nativeBinding }) + this.db.pragma('journal_mode = WAL') + this.db.exec(SEMANTIC_INDEX_SESSION_TABLE) + this.ensureChunkerColumns() + } + + /** 兼容旧版本表结构:缺少 chunker 身份列时补齐(identity 为空 => 后续判定需重建) */ + private ensureChunkerColumns(): void { + const columns = this.db.prepare(`PRAGMA table_info(semantic_index_session)`).all() as { name: string }[] + const names = new Set(columns.map((c) => c.name)) + if (!names.has('chunker_version')) { + this.db.exec(`ALTER TABLE semantic_index_session ADD COLUMN chunker_version TEXT`) + } + if (!names.has('chunker_config_hash')) { + this.db.exec(`ALTER TABLE semantic_index_session ADD COLUMN chunker_config_hash TEXT`) + } + } + + /** 启用对话索引;已存在则更新模型并清除待清理标记(覆盖重新启用/换模型场景) */ + enable(params: EnableParams): void { + const now = Date.now() + this.db + .prepare( + `INSERT INTO semantic_index_session + (db_path_hash, db_path, enabled, model_id, index_status, cleanup_status, + chunker_version, chunker_config_hash, enabled_at, updated_at) + VALUES (@dbPathHash, @dbPath, 1, @modelId, 'idle', 'none', + @chunkerVersion, @chunkerConfigHash, @now, @now) + ON CONFLICT(db_path_hash) DO UPDATE SET + db_path = excluded.db_path, + enabled = 1, + model_id = excluded.model_id, + cleanup_status = 'none', + chunker_version = excluded.chunker_version, + chunker_config_hash = excluded.chunker_config_hash, + enabled_at = COALESCE(semantic_index_session.enabled_at, excluded.enabled_at), + updated_at = excluded.updated_at` + ) + .run({ + dbPathHash: params.dbPathHash, + dbPath: params.dbPath, + modelId: params.modelId, + chunkerVersion: params.chunkerVersion, + chunkerConfigHash: params.chunkerConfigHash, + now, + }) + } + + /** + * 重新激活已有状态行(stale 重建场景):仅更新 enabled 和 dbPath,保留旧身份字段, + * 使 buildAllPending() 重启后仍能通过 isStale() 检测到需重建并入队 rebuild。 + */ + reactivate(dbPathHash: string, dbPath: string): void { + this.db + .prepare( + `UPDATE semantic_index_session SET enabled = 1, db_path = ?, cleanup_status = 'none', updated_at = ? WHERE db_path_hash = ?` + ) + .run(dbPath, Date.now(), dbPathHash) + } + + /** 停用对话索引;标记待清理,保留状态与已建索引直到清理任务执行 */ + disable(dbPathHash: string): void { + this.db + .prepare( + `UPDATE semantic_index_session + SET enabled = 0, cleanup_status = 'pending', updated_at = ? + WHERE db_path_hash = ?` + ) + .run(Date.now(), dbPathHash) + } + + updateProgress(dbPathHash: string, patch: ProgressPatch): void { + const sets: string[] = [] + const values: Record = { dbPathHash, now: Date.now() } + const map: Record = { + indexStatus: 'index_status', + totalMessages: 'total_messages', + indexedMessages: 'indexed_messages', + lastIndexedMessageId: 'last_indexed_message_id', + chunkCount: 'chunk_count', + error: 'error', + } + for (const key of Object.keys(map) as (keyof ProgressPatch)[]) { + if (patch[key] !== undefined) { + sets.push(`${map[key]} = @${key}`) + values[key] = patch[key] + } + } + if (sets.length === 0) return + this.db + .prepare( + `UPDATE semantic_index_session SET ${sets.join(', ')}, updated_at = @now WHERE db_path_hash = @dbPathHash` + ) + .run(values) + } + + setIndexStatus(dbPathHash: string, status: SemanticIndexStatus, error?: string | null): void { + this.db + .prepare(`UPDATE semantic_index_session SET index_status = ?, error = ?, updated_at = ? WHERE db_path_hash = ?`) + .run(status, error ?? null, Date.now(), dbPathHash) + } + + setCleanupStatus(dbPathHash: string, status: SemanticIndexCleanupStatus): void { + this.db + .prepare(`UPDATE semantic_index_session SET cleanup_status = ?, updated_at = ? WHERE db_path_hash = ?`) + .run(status, Date.now(), dbPathHash) + } + + /** + * 刷新索引身份(模型 + chunker version/config hash)。 + * rebuild 以当前配置重写向量后调用,确保完成后不再被判为需重建(stale -> rebuild 闭环)。 + */ + setBuildIdentity( + dbPathHash: string, + identity: { modelId: string; chunkerVersion: string; chunkerConfigHash: string } + ): void { + this.db + .prepare( + `UPDATE semantic_index_session + SET model_id = ?, chunker_version = ?, chunker_config_hash = ?, updated_at = ? + WHERE db_path_hash = ?` + ) + .run(identity.modelId, identity.chunkerVersion, identity.chunkerConfigHash, Date.now(), dbPathHash) + } + + /** 重置进度与状态到初始(rebuild 前调用,清空断点游标,避免续跑跳过) */ + resetProgress(dbPathHash: string): void { + this.db + .prepare( + `UPDATE semantic_index_session + SET index_status = 'idle', indexed_messages = 0, chunk_count = 0, + last_indexed_message_id = NULL, error = NULL, updated_at = ? + WHERE db_path_hash = ?` + ) + .run(Date.now(), dbPathHash) + } + + getState(dbPathHash: string): SemanticIndexSessionState | null { + const row = this.db.prepare(`SELECT * FROM semantic_index_session WHERE db_path_hash = ?`).get(dbPathHash) as + | SessionRow + | undefined + return row ? rowToState(row) : null + } + + listEnabled(): SemanticIndexSessionState[] { + const rows = this.db + .prepare(`SELECT * FROM semantic_index_session WHERE enabled = 1 ORDER BY enabled_at`) + .all() as SessionRow[] + return rows.map(rowToState) + } + + listAll(): SemanticIndexSessionState[] { + const rows = this.db.prepare(`SELECT * FROM semantic_index_session ORDER BY enabled_at`).all() as SessionRow[] + return rows.map(rowToState) + } + + listPendingCleanup(): SemanticIndexSessionState[] { + const rows = this.db + .prepare(`SELECT * FROM semantic_index_session WHERE cleanup_status = 'pending' ORDER BY updated_at`) + .all() as SessionRow[] + return rows.map(rowToState) + } + + remove(dbPathHash: string): number { + return this.db.prepare(`DELETE FROM semantic_index_session WHERE db_path_hash = ?`).run(dbPathHash).changes + } + + close(): void { + this.db.close() + } +} diff --git a/packages/node-runtime/src/semantic-index/static-path-provider.ts b/packages/node-runtime/src/semantic-index/static-path-provider.ts new file mode 100644 index 000000000..abde212dc --- /dev/null +++ b/packages/node-runtime/src/semantic-index/static-path-provider.ts @@ -0,0 +1,73 @@ +import type { PathProvider } from '@openchatlab/core' + +export interface StaticPathProviderSnapshot { + systemDir: string + userDataDir: string + databaseDir: string + vectorDir: string + aiDataDir: string + settingsDir: string + cacheDir: string + tempDir: string + logsDir: string + downloadsDir: string +} + +export function snapshotPathProvider(pathProvider: PathProvider): StaticPathProviderSnapshot { + return { + systemDir: pathProvider.getSystemDir(), + userDataDir: pathProvider.getUserDataDir(), + databaseDir: pathProvider.getDatabaseDir(), + vectorDir: pathProvider.getVectorDir(), + aiDataDir: pathProvider.getAiDataDir(), + settingsDir: pathProvider.getSettingsDir(), + cacheDir: pathProvider.getCacheDir(), + tempDir: pathProvider.getTempDir(), + logsDir: pathProvider.getLogsDir(), + downloadsDir: pathProvider.getDownloadsDir(), + } +} + +export class StaticPathProvider implements PathProvider { + constructor(private readonly paths: StaticPathProviderSnapshot) {} + + getSystemDir(): string { + return this.paths.systemDir + } + + getUserDataDir(): string { + return this.paths.userDataDir + } + + getDatabaseDir(): string { + return this.paths.databaseDir + } + + getVectorDir(): string { + return this.paths.vectorDir + } + + getAiDataDir(): string { + return this.paths.aiDataDir + } + + getSettingsDir(): string { + return this.paths.settingsDir + } + + getCacheDir(): string { + return this.paths.cacheDir + } + + getTempDir(): string { + return this.paths.tempDir + } + + getLogsDir(): string { + return this.paths.logsDir + } + + getDownloadsDir(): string { + return this.paths.downloadsDir + } +} diff --git a/packages/node-runtime/src/semantic-index/store-mutations.test.ts b/packages/node-runtime/src/semantic-index/store-mutations.test.ts new file mode 100644 index 000000000..d021ebe77 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/store-mutations.test.ts @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { EmbeddingIndexStore } from './store' +import { STRATEGY_ID } from './chunker-config' +import type { ChunkRecord } from './types' + +function makeStore() { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-store-mut-')) + return new EmbeddingIndexStore(path.join(dir, 'embedding_index.db')) +} + +function record(chunkId: string, dbPathHash: string, modelId: string, dim: number, start: number): ChunkRecord { + return { + chunkId, + dbPathHash, + strategyId: STRATEGY_ID, + modelId, + dim, + parentId: `parent:${start}`, + startMessageId: start, + endMessageId: start + 1, + startTs: start * 1000, + endTs: (start + 1) * 1000, + messageCount: 2, + rawContentHash: `raw-${chunkId}`, + embeddingInputHash: `emb-${chunkId}`, + chunkerVersion: 'v1.0', + chunkerConfigHash: 'cfg', + indexedAt: Date.now(), + status: 'indexed', + } +} + +test('deleteByDbPathHash removes only the target conversation across models and dims', () => { + const store = makeStore() + store.insertChunk(record('a1', 'A', 'm-dim4', 4, 1), [1, 0, 0, 0]) + store.insertChunk(record('a2', 'A', 'm-dim3', 3, 3), [0, 1, 0]) + store.insertChunk(record('b1', 'B', 'm-dim4', 4, 1), [0, 0, 1, 0]) + + const removed = store.deleteByDbPathHash('A') + assert.equal(removed, 2) + assert.equal(store.countChunks('A'), 0) + assert.equal(store.countChunks('B'), 1) + + // 删除后 vec0 行也清掉:A 的 dim4 查询应无结果,B 仍可查到 + assert.equal( + store.queryDense({ dbPathHash: 'A', modelId: 'm-dim4', dim: 4, embedding: [1, 0, 0, 0], k: 10 }).length, + 0 + ) + assert.equal( + store.queryDense({ dbPathHash: 'B', modelId: 'm-dim4', dim: 4, embedding: [0, 0, 1, 0], k: 10 }).length, + 1 + ) + store.close() +}) + +test('deleteByModelFromPosition removes only chunks at and after a chat-order boundary', () => { + const store = makeStore() + store.insertChunk(record('a1', 'A', 'm1', 4, 1), [1, 0, 0, 0]) + store.insertChunk(record('a2', 'A', 'm1', 4, 3), [0, 1, 0, 0]) + store.insertChunk(record('a3', 'A', 'm1', 4, 5), [0, 0, 1, 0]) + store.insertChunk(record('other-model', 'A', 'm2', 4, 3), [0, 0, 0, 1]) + store.insertChunk(record('other-db', 'B', 'm1', 4, 3), [1, 1, 0, 0]) + + const removed = store.deleteByModelFromPosition({ dbPathHash: 'A', modelId: 'm1', startTs: 3000, startMessageId: 3 }) + assert.equal(removed, 2) + assert.equal(store.countChunks('A', 'm1'), 1) + assert.equal(store.countChunks('A', 'm2'), 1) + assert.equal(store.countChunks('B', 'm1'), 1) + assert.equal(store.getChunkById('a1')?.chunkId, 'a1') + assert.equal(store.getChunkById('a2'), null) + assert.equal(store.getChunkById('a3'), null) + + assert.equal(store.queryDense({ dbPathHash: 'A', modelId: 'm1', dim: 4, embedding: [0, 1, 0, 0], k: 10 }).length, 1) + store.close() +}) + +test('listDbPathHashes returns distinct conversation hashes', () => { + const store = makeStore() + store.insertChunk(record('a1', 'A', 'm', 4, 1), [1, 0, 0, 0]) + store.insertChunk(record('a2', 'A', 'm', 4, 3), [0, 1, 0, 0]) + store.insertChunk(record('b1', 'B', 'm', 4, 1), [0, 0, 1, 0]) + + assert.deepEqual(store.listDbPathHashes().sort(), ['A', 'B']) + store.close() +}) + +test('countChunks can scope by model id', () => { + const store = makeStore() + store.insertChunk(record('a1', 'A', 'm1', 4, 1), [1, 0, 0, 0]) + store.insertChunk(record('a2', 'A', 'm2', 4, 3), [0, 1, 0, 0]) + + assert.equal(store.countChunks('A'), 2) + assert.equal(store.countChunks('A', 'm1'), 1) + store.close() +}) diff --git a/packages/node-runtime/src/semantic-index/store.test.ts b/packages/node-runtime/src/semantic-index/store.test.ts new file mode 100644 index 000000000..081c641b9 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/store.test.ts @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import Database from 'better-sqlite3' +import { EmbeddingIndexStore } from './store' +import type { ChunkRecord } from './types' + +function makeTempDbPath(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + const dir = fs.mkdtempSync(path.join(baseDir, 'chatlab-embidx-')) + return path.join(dir, 'embedding_index.db') +} + +function baseRecord(overrides: Partial = {}): ChunkRecord { + return { + chunkId: 'chunk-1', + dbPathHash: 'dbA', + strategyId: 'balanced', + modelId: 'qwen3', + dim: 4, + parentId: 'parent-1', + startMessageId: 100, + endMessageId: 120, + startTs: 1700000000, + endTs: 1700000600, + messageCount: 8, + rawContentHash: 'raw-1', + embeddingInputHash: 'emb-1', + chunkerVersion: 'v1.0', + chunkerConfigHash: 'cfg-1', + indexedAt: 1700000700, + status: 'indexed', + ...overrides, + } +} + +test('insert and query dense ANN within a single partition', () => { + const dbPath = makeTempDbPath() + const store = new EmbeddingIndexStore(dbPath) + + store.insertChunk(baseRecord({ chunkId: 'c1' }), [1, 0, 0, 0]) + store.insertChunk(baseRecord({ chunkId: 'c2', startMessageId: 200, endMessageId: 220 }), [0, 1, 0, 0]) + + const results = store.queryDense({ dbPathHash: 'dbA', modelId: 'qwen3', dim: 4, embedding: [1, 0, 0, 0], k: 10 }) + + assert.equal(results.length, 2) + assert.equal(results[0].chunkId, 'c1') + assert.ok(results[0].distance < results[1].distance) + assert.equal(results[0].record.parentId, 'parent-1') + + store.close() +}) + +test('partition pruning isolates db_path_hash and model_id', () => { + const dbPath = makeTempDbPath() + const store = new EmbeddingIndexStore(dbPath) + + store.insertChunk(baseRecord({ chunkId: 'a-q', dbPathHash: 'dbA', modelId: 'qwen3' }), [1, 0, 0, 0]) + store.insertChunk(baseRecord({ chunkId: 'a-b', dbPathHash: 'dbA', modelId: 'modelB' }), [1, 0, 0, 0]) + store.insertChunk(baseRecord({ chunkId: 'b-q', dbPathHash: 'dbB', modelId: 'qwen3' }), [1, 0, 0, 0]) + + const results = store.queryDense({ dbPathHash: 'dbA', modelId: 'qwen3', dim: 4, embedding: [1, 0, 0, 0], k: 10 }) + + assert.equal(results.length, 1) + assert.equal(results[0].chunkId, 'a-q') + + store.close() +}) + +test('coexisting dims are stored in separate vec0 tables and queryable', () => { + const dbPath = makeTempDbPath() + const store = new EmbeddingIndexStore(dbPath) + + store.insertChunk(baseRecord({ chunkId: 'small', modelId: 'modelB', dim: 4 }), [1, 0, 0, 0]) + store.insertChunk(baseRecord({ chunkId: 'big', modelId: 'qwen3', dim: 8 }), [1, 0, 0, 0, 0, 0, 0, 0]) + + const small = store.queryDense({ dbPathHash: 'dbA', modelId: 'modelB', dim: 4, embedding: [1, 0, 0, 0], k: 10 }) + const big = store.queryDense({ + dbPathHash: 'dbA', + modelId: 'qwen3', + dim: 8, + embedding: [1, 0, 0, 0, 0, 0, 0, 0], + k: 10, + }) + + assert.equal(small.length, 1) + assert.equal(small[0].chunkId, 'small') + assert.equal(big.length, 1) + assert.equal(big[0].chunkId, 'big') + + store.close() +}) + +test('insertChunk rejects embedding whose length mismatches dim', () => { + const dbPath = makeTempDbPath() + const store = new EmbeddingIndexStore(dbPath) + + assert.throws(() => store.insertChunk(baseRecord({ dim: 4 }), [1, 0, 0]), /dim/i) + + store.close() +}) + +test('mapMessageToChunk returns the chunk whose ts range covers the message', () => { + const dbPath = makeTempDbPath() + const store = new EmbeddingIndexStore(dbPath) + + // ts 用 messageId * 100 映射,三段连续 + store.insertChunk( + baseRecord({ chunkId: 'c1', startMessageId: 0, endMessageId: 11, startTs: 0, endTs: 1100 }), + [1, 0, 0, 0] + ) + store.insertChunk( + baseRecord({ chunkId: 'c2', startMessageId: 12, endMessageId: 23, startTs: 1200, endTs: 2300 }), + [0, 1, 0, 0] + ) + store.insertChunk( + baseRecord({ chunkId: 'c3', startMessageId: 24, endMessageId: 35, startTs: 2400, endTs: 3500 }), + [0, 0, 1, 0] + ) + + const params = { dbPathHash: 'dbA', modelId: 'qwen3', strategyId: 'balanced' } + assert.equal(store.mapMessageToChunk({ ...params, messageId: 15, messageTs: 1500 })?.chunkId, 'c2') + assert.equal(store.mapMessageToChunk({ ...params, messageId: 0, messageTs: 0 })?.chunkId, 'c1') + assert.equal(store.mapMessageToChunk({ ...params, messageId: 35, messageTs: 3500 })?.chunkId, 'c3') + + store.close() +}) + +test('mapMessageToChunk returns null when message ts falls in a gap between chunks', () => { + const dbPath = makeTempDbPath() + const store = new EmbeddingIndexStore(dbPath) + + // chunk 之间留 ts 空洞 + store.insertChunk( + baseRecord({ chunkId: 'c1', startMessageId: 0, endMessageId: 11, startTs: 0, endTs: 1100 }), + [1, 0, 0, 0] + ) + store.insertChunk( + baseRecord({ chunkId: 'c2', startMessageId: 100, endMessageId: 111, startTs: 10000, endTs: 11100 }), + [0, 1, 0, 0] + ) + + const params = { dbPathHash: 'dbA', modelId: 'qwen3', strategyId: 'balanced' } + assert.equal(store.mapMessageToChunk({ ...params, messageId: 50, messageTs: 5000 }), null) + assert.equal(store.mapMessageToChunk({ ...params, messageId: 200, messageTs: 20000 }), null) + + store.close() +}) + +test('mapMessageToChunk breaks ties when multiple chunks share the same start_ts', () => { + const dbPath = makeTempDbPath() + const store = new EmbeddingIndexStore(dbPath) + + // 同一秒内两个 chunk:c1 覆盖 id 1-2,c2 覆盖 id 3-4,start_ts 相同 + store.insertChunk( + baseRecord({ chunkId: 'c1', startMessageId: 1, endMessageId: 2, startTs: 1000, endTs: 1000 }), + [1, 0, 0, 0] + ) + store.insertChunk( + baseRecord({ chunkId: 'c2', startMessageId: 3, endMessageId: 4, startTs: 1000, endTs: 1000 }), + [0, 1, 0, 0] + ) + + const params = { dbPathHash: 'dbA', modelId: 'qwen3', strategyId: 'balanced' } + assert.equal(store.mapMessageToChunk({ ...params, messageId: 1, messageTs: 1000 })?.chunkId, 'c1') + assert.equal(store.mapMessageToChunk({ ...params, messageId: 3, messageTs: 1000 })?.chunkId, 'c2') + assert.equal(store.mapMessageToChunk({ ...params, messageId: 4, messageTs: 1000 })?.chunkId, 'c2') + + store.close() +}) + +test('schema includes timestamp-leading index for message-to-chunk lookup', () => { + const dbPath = makeTempDbPath() + const store = new EmbeddingIndexStore(dbPath) + store.close() + + const db = new Database(dbPath, { readonly: true }) + const indexes = db.pragma('index_list(chunk_vector_index)') as { name: string }[] + const timestampIndex = indexes.find((index) => index.name === 'idx_chunk_ts_range') + assert.ok(timestampIndex, 'expected idx_chunk_ts_range to exist') + + const columns = (db.pragma(`index_info(${timestampIndex.name})`) as { name: string }[]).map((column) => column.name) + assert.deepEqual(columns, ['db_path_hash', 'model_id', 'strategy_id', 'start_ts', 'start_message_id']) + db.close() +}) + +test('data persists across store reopen', () => { + const dbPath = makeTempDbPath() + const store = new EmbeddingIndexStore(dbPath) + store.insertChunk(baseRecord({ chunkId: 'persist' }), [1, 0, 0, 0]) + store.close() + + const reopened = new EmbeddingIndexStore(dbPath) + const fetched = reopened.getChunkById('persist') + assert.equal(fetched?.chunkId, 'persist') + assert.equal(fetched?.dim, 4) + reopened.close() +}) + +test('insertChunks writes a batch in one transaction', () => { + const dbPath = makeTempDbPath() + const store = new EmbeddingIndexStore(dbPath) + + store.insertChunks([ + { record: baseRecord({ chunkId: 'b1', startMessageId: 0, endMessageId: 9 }), embedding: [1, 0, 0, 0] }, + { record: baseRecord({ chunkId: 'b2', startMessageId: 10, endMessageId: 19 }), embedding: [0, 1, 0, 0] }, + ]) + + const results = store.queryDense({ dbPathHash: 'dbA', modelId: 'qwen3', dim: 4, embedding: [0, 1, 0, 0], k: 10 }) + assert.equal(results.length, 2) + assert.equal(results[0].chunkId, 'b2') + + store.close() +}) diff --git a/packages/node-runtime/src/semantic-index/store.ts b/packages/node-runtime/src/semantic-index/store.ts new file mode 100644 index 000000000..e7ad378ed --- /dev/null +++ b/packages/node-runtime/src/semantic-index/store.ts @@ -0,0 +1,337 @@ +/** + * 语义索引向量存储(sqlite-vec vec0 + better-sqlite3) + * + * embedding_index.db 是独立系统级向量库。本存储负责: + * - 初始化元数据表与按维度的 vec0 表。 + * - 写入 chunk 元数据与向量(同一事务,rowid 关联)。 + * - dense ANN 查询(限定 db_path_hash + model_id 分区)。 + * - FTS message_id -> chunk 的 O(log n) 范围映射。 + * + * P0-1 硬约定:vec0 的 INTEGER 列与 k 参数绑定必须使用 CAST(? AS INTEGER)。 + */ + +import Database from 'better-sqlite3' +import * as sqliteVec from 'sqlite-vec' +import type { + ChunkInsert, + ChunkRecord, + ChunkStatus, + DenseQueryParams, + DenseQueryResult, + MessageToChunkParams, +} from './types' +import { EMBEDDING_INDEX_SCHEMA, vecTableName, vecTableSchema } from './schema' + +/** 加载 sqlite-vec 扩展。Electron 打包后可注入自定义实现处理 asar 解包路径。 */ +export type LoadSqliteVec = (db: Database.Database) => void + +const defaultLoadSqliteVec: LoadSqliteVec = (db) => sqliteVec.load(db) + +interface ChunkRow { + chunk_id: string + db_path_hash: string + strategy_id: string + model_id: string + dim: number + parent_id: string + start_message_id: number + end_message_id: number + start_ts: number + end_ts: number + message_count: number + raw_content_hash: string + embedding_input_hash: string + chunker_version: string + chunker_config_hash: string + indexed_at: number + status: string +} + +function rowToRecord(row: ChunkRow): ChunkRecord { + return { + chunkId: row.chunk_id, + dbPathHash: row.db_path_hash, + strategyId: row.strategy_id, + modelId: row.model_id, + dim: row.dim, + parentId: row.parent_id, + startMessageId: row.start_message_id, + endMessageId: row.end_message_id, + startTs: row.start_ts, + endTs: row.end_ts, + messageCount: row.message_count, + rawContentHash: row.raw_content_hash, + embeddingInputHash: row.embedding_input_hash, + chunkerVersion: row.chunker_version, + chunkerConfigHash: row.chunker_config_hash, + indexedAt: row.indexed_at, + status: row.status as ChunkStatus, + } +} + +function toFloat32Buffer(embedding: Float32Array | number[]): Buffer { + const arr = embedding instanceof Float32Array ? embedding : new Float32Array(embedding) + return Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength) +} + +const CHUNK_COLUMN_LIST = [ + 'chunk_id', + 'db_path_hash', + 'strategy_id', + 'model_id', + 'dim', + 'parent_id', + 'start_message_id', + 'end_message_id', + 'start_ts', + 'end_ts', + 'message_count', + 'raw_content_hash', + 'embedding_input_hash', + 'chunker_version', + 'chunker_config_hash', + 'indexed_at', + 'status', +] as const + +const CHUNK_COLUMNS = CHUNK_COLUMN_LIST.join(', ') +const CHUNK_COLUMNS_PREFIXED = CHUNK_COLUMN_LIST.map((c) => `m.${c}`).join(', ') + +export class EmbeddingIndexStore { + private db: Database.Database + private ensuredVecDims = new Set() + + constructor(dbPath: string, options?: { loadSqliteVec?: LoadSqliteVec; readonly?: boolean; nativeBinding?: string }) { + this.db = new Database(dbPath, { readonly: options?.readonly ?? false, nativeBinding: options?.nativeBinding }) + ;(options?.loadSqliteVec ?? defaultLoadSqliteVec)(this.db) + if (!options?.readonly) { + this.db.pragma('journal_mode = WAL') + this.db.exec(EMBEDDING_INDEX_SCHEMA) + } + } + + /** 确保对应维度的 vec0 表已创建(懒加载) */ + private ensureVecTable(dim: number): void { + if (this.ensuredVecDims.has(dim)) return + this.db.exec(vecTableSchema(dim)) + this.ensuredVecDims.add(dim) + } + + private insertOne(item: ChunkInsert): void { + const { record, embedding } = item + if (embedding.length !== record.dim) { + throw new Error(`embedding length ${embedding.length} mismatches dim ${record.dim} for chunk ${record.chunkId}`) + } + this.ensureVecTable(record.dim) + + const meta = this.db + .prepare( + // ponytail: OR IGNORE makes resume after mid-batch crash idempotent; vec insert skipped below if chunk exists + `INSERT OR IGNORE INTO chunk_vector_index (${CHUNK_COLUMNS}) + VALUES (@chunkId, @dbPathHash, @strategyId, @modelId, @dim, @parentId, @startMessageId, @endMessageId, + @startTs, @endTs, @messageCount, @rawContentHash, @embeddingInputHash, @chunkerVersion, + @chunkerConfigHash, @indexedAt, @status)` + ) + .run({ + chunkId: record.chunkId, + dbPathHash: record.dbPathHash, + strategyId: record.strategyId, + modelId: record.modelId, + dim: record.dim, + parentId: record.parentId, + startMessageId: record.startMessageId, + endMessageId: record.endMessageId, + startTs: record.startTs, + endTs: record.endTs, + messageCount: record.messageCount, + rawContentHash: record.rawContentHash, + embeddingInputHash: record.embeddingInputHash, + chunkerVersion: record.chunkerVersion, + chunkerConfigHash: record.chunkerConfigHash, + indexedAt: record.indexedAt, + status: record.status, + }) + + if (meta.changes === 0) return + this.db + .prepare( + `INSERT INTO ${vecTableName(record.dim)} (vector_id, db_path_hash, model_id, embedding) + VALUES (CAST(? AS INTEGER), ?, ?, ?)` + ) + .run(Number(meta.lastInsertRowid), record.dbPathHash, record.modelId, toFloat32Buffer(embedding)) + } + + /** 写入单个 chunk(元数据 + 向量,同一事务) */ + insertChunk(record: ChunkRecord, embedding: Float32Array | number[]): void { + this.db.transaction(() => this.insertOne({ record, embedding }))() + } + + /** 批量写入 chunk(单事务) */ + insertChunks(items: ChunkInsert[]): void { + this.db.transaction(() => { + for (const item of items) this.insertOne(item) + })() + } + + /** dense ANN 查询,限定 db_path_hash + model_id 分区,cosine 距离升序 */ + queryDense(params: DenseQueryParams): DenseQueryResult[] { + const table = vecTableName(params.dim) + if (!this.tableExists(table)) return [] + + const rows = this.db + .prepare( + `SELECT v.distance AS distance, ${CHUNK_COLUMNS_PREFIXED} + FROM ${table} v + JOIN chunk_vector_index m ON m.rowid = v.vector_id + WHERE v.embedding MATCH ? AND v.db_path_hash = ? AND v.model_id = ? AND v.k = CAST(? AS INTEGER) + ORDER BY v.distance` + ) + .all(toFloat32Buffer(params.embedding), params.dbPathHash, params.modelId, params.k) as Array< + ChunkRow & { distance: number } + > + + return rows.map((row) => ({ chunkId: row.chunk_id, distance: row.distance, record: rowToRecord(row) })) + } + + /** + * FTS message -> chunk 映射。按 (ts, id) 复合查找:取 (start_ts, start_message_id) <= (messageTs, messageId) + * 的最大值,再校验 end 覆盖。 + * - 用 ts 而非 ID 做主键:回填旧消息后 ID 不再单调 + * - 用 (ts, id) 复合而非仅 ts:同秒多 chunk 时 ts 相同,需 id 打破 tie + */ + mapMessageToChunk(params: MessageToChunkParams): ChunkRecord | null { + const row = this.db + .prepare( + `SELECT ${CHUNK_COLUMNS} FROM chunk_vector_index + WHERE db_path_hash = ? AND model_id = ? AND strategy_id = ? + AND (start_ts < CAST(? AS INTEGER) + OR (start_ts = CAST(? AS INTEGER) AND start_message_id <= CAST(? AS INTEGER))) + ORDER BY start_ts DESC, start_message_id DESC LIMIT 1` + ) + .get( + params.dbPathHash, + params.modelId, + params.strategyId, + params.messageTs, + params.messageTs, + params.messageId + ) as ChunkRow | undefined + + if (!row) return null + if (row.end_ts < params.messageTs) return null + if (row.end_ts === params.messageTs && row.end_message_id < params.messageId) return null + return rowToRecord(row) + } + + /** 删除某聊天库(全模型)的所有 chunk 元数据与向量,返回删除的元数据行数 */ + deleteByDbPathHash(dbPathHash: string): number { + return this.db.transaction(() => { + const rows = this.db + .prepare('SELECT rowid AS rowid, dim FROM chunk_vector_index WHERE db_path_hash = ?') + .all(dbPathHash) as Array<{ rowid: number; dim: number }> + + const delStmts = new Map() + for (const { rowid, dim } of rows) { + const table = vecTableName(dim) + if (!this.tableExists(table)) continue + let stmt = delStmts.get(dim) + if (!stmt) { + stmt = this.db.prepare(`DELETE FROM ${table} WHERE vector_id = CAST(? AS INTEGER)`) + delStmts.set(dim, stmt) + } + stmt.run(rowid) + } + + const res = this.db.prepare('DELETE FROM chunk_vector_index WHERE db_path_hash = ?').run(dbPathHash) + return res.changes + })() + } + + /** 删除某聊天库 + 模型中从指定聊天顺序位置开始的 chunks,返回删除的元数据行数 */ + deleteByModelFromPosition(params: { + dbPathHash: string + modelId: string + startTs: number + startMessageId: number + }): number { + return this.db.transaction(() => { + const rows = this.db + .prepare( + `SELECT rowid AS rowid, dim FROM chunk_vector_index + WHERE db_path_hash = ? AND model_id = ? + AND (start_ts > CAST(? AS INTEGER) + OR (start_ts = CAST(? AS INTEGER) AND start_message_id >= CAST(? AS INTEGER)))` + ) + .all(params.dbPathHash, params.modelId, params.startTs, params.startTs, params.startMessageId) as Array<{ + rowid: number + dim: number + }> + + const delStmts = new Map() + for (const { rowid, dim } of rows) { + const table = vecTableName(dim) + if (!this.tableExists(table)) continue + let stmt = delStmts.get(dim) + if (!stmt) { + stmt = this.db.prepare(`DELETE FROM ${table} WHERE vector_id = CAST(? AS INTEGER)`) + delStmts.set(dim, stmt) + } + stmt.run(rowid) + } + + const res = this.db + .prepare( + `DELETE FROM chunk_vector_index + WHERE db_path_hash = ? AND model_id = ? + AND (start_ts > CAST(? AS INTEGER) + OR (start_ts = CAST(? AS INTEGER) AND start_message_id >= CAST(? AS INTEGER)))` + ) + .run(params.dbPathHash, params.modelId, params.startTs, params.startTs, params.startMessageId) + return res.changes + })() + } + + /** 列出存储中存在 chunk 的所有 db_path_hash */ + listDbPathHashes(): string[] { + const rows = this.db.prepare('SELECT DISTINCT db_path_hash FROM chunk_vector_index').all() as Array<{ + db_path_hash: string + }> + return rows.map((r) => r.db_path_hash) + } + + /** 统计某聊天库 chunk 数(可选限定 model_id) */ + countChunks(dbPathHash: string, modelId?: string): number { + const row = modelId + ? (this.db + .prepare('SELECT COUNT(*) AS c FROM chunk_vector_index WHERE db_path_hash = ? AND model_id = ?') + .get(dbPathHash, modelId) as { c: number }) + : (this.db.prepare('SELECT COUNT(*) AS c FROM chunk_vector_index WHERE db_path_hash = ?').get(dbPathHash) as { + c: number + }) + return row.c + } + + /** 取某聊天库 + 模型已写入 chunk 的维度(用于检索选表);无 chunk 返回 null */ + getDim(dbPathHash: string, modelId: string): number | null { + const row = this.db + .prepare('SELECT dim FROM chunk_vector_index WHERE db_path_hash = ? AND model_id = ? LIMIT 1') + .get(dbPathHash, modelId) as { dim: number } | undefined + return row ? row.dim : null + } + + /** 按 chunk_id 取元数据 */ + getChunkById(chunkId: string): ChunkRecord | null { + const row = this.db.prepare(`SELECT ${CHUNK_COLUMNS} FROM chunk_vector_index WHERE chunk_id = ?`).get(chunkId) as + | ChunkRow + | undefined + return row ? rowToRecord(row) : null + } + + private tableExists(name: string): boolean { + return !!this.db.prepare(`SELECT 1 FROM sqlite_master WHERE name = ?`).get(name) + } + + close(): void { + this.db.close() + } +} diff --git a/packages/node-runtime/src/semantic-index/tokens.ts b/packages/node-runtime/src/semantic-index/tokens.ts new file mode 100644 index 000000000..72ec1b17f --- /dev/null +++ b/packages/node-runtime/src/semantic-index/tokens.ts @@ -0,0 +1,15 @@ +/** + * token 粗略估算(共享) + * + * 仅作硬上限护栏与证据预算控制,真实 token 由 embedder / LLM 决定。 + * 估算规则:CJK 每字约 1 token,其余字符约 1/4 token。 + */ +export function estimateTokens(text: string): number { + let cjk = 0 + let other = 0 + for (const ch of text) { + if (/[\u3400-\u9fff\uf900-\ufaff]/u.test(ch)) cjk++ + else other++ + } + return cjk + Math.ceil(other / 4) +} diff --git a/packages/node-runtime/src/semantic-index/types.ts b/packages/node-runtime/src/semantic-index/types.ts new file mode 100644 index 000000000..aceaa5caa --- /dev/null +++ b/packages/node-runtime/src/semantic-index/types.ts @@ -0,0 +1,68 @@ +/** + * 语义索引存储核心类型 + * + * embedding_index.db 是独立的系统级向量索引库,保存所有已启用对话的 child chunk + * 向量与元数据。设计依据见 .docs/tasks/chunking/chunking-decision-final.md 第 8/11/17 节。 + */ + +/** chunk 索引状态 */ +export type ChunkStatus = 'indexed' | 'pending' | 'failed' + +/** + * 单个 child chunk 的元数据记录(不含向量本身)。 + * 字段对应设计文档第 8 节"每个 chunk 至少记录"。 + */ +export interface ChunkRecord { + chunkId: string + dbPathHash: string + strategyId: string + modelId: string + dim: number + parentId: string + startMessageId: number + endMessageId: number + startTs: number + endTs: number + messageCount: number + rawContentHash: string + embeddingInputHash: string + chunkerVersion: string + chunkerConfigHash: string + indexedAt: number + status: ChunkStatus +} + +/** 写入存储时的 chunk + 向量 */ +export interface ChunkInsert { + record: ChunkRecord + /** 长度必须等于 record.dim */ + embedding: Float32Array | number[] +} + +/** dense ANN 查询参数(始终限定单对话 + 单模型,命中分区裁剪) */ +export interface DenseQueryParams { + dbPathHash: string + modelId: string + dim: number + embedding: Float32Array | number[] + /** 取回数量,对应设计默认 dense topN=40 */ + k: number +} + +/** dense ANN 查询单条结果 */ +export interface DenseQueryResult { + chunkId: string + /** cosine 距离,越小越相关 */ + distance: number + record: ChunkRecord +} + +/** message_id -> chunk 范围映射参数 */ +export interface MessageToChunkParams { + dbPathHash: string + modelId: string + strategyId: string + messageId: number + /** 消息时间戳(毫秒),用于 ts-based 区间映射,与 chunk_vector_index 的 start_ts/end_ts 单位一致 */ + messageTs: number +} diff --git a/packages/node-runtime/src/semantic-index/warmup/job-queue.test.ts b/packages/node-runtime/src/semantic-index/warmup/job-queue.test.ts new file mode 100644 index 000000000..98d640631 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/warmup/job-queue.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { SemanticIndexJobQueue, type JobContext } from './job-queue' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((r) => (resolve = r)) + return { promise, resolve } +} + +test('processes enqueued jobs serially in order', async () => { + const order: string[] = [] + const queue = new SemanticIndexJobQueue(async ({ job }) => { + order.push(`${job.type}:${job.dbPathHash}`) + }) + + queue.enqueue({ type: 'build', dbPathHash: 'a' }) + queue.enqueue({ type: 'build', dbPathHash: 'b' }) + queue.enqueue({ type: 'cleanup', dbPathHash: 'a' }) + await queue.whenIdle() + + assert.deepEqual(order, ['build:a', 'build:b', 'cleanup:a']) +}) + +test('deduplicates same conversation + type while pending', async () => { + let runs = 0 + const gate = deferred() + const queue = new SemanticIndexJobQueue(async ({ job }) => { + runs++ + if (job.dbPathHash === 'a') await gate.promise + }) + + queue.enqueue({ type: 'build', dbPathHash: 'a' }) // starts and blocks on gate + queue.enqueue({ type: 'build', dbPathHash: 'b' }) + queue.enqueue({ type: 'build', dbPathHash: 'b' }) // duplicate of pending b -> ignored + gate.resolve() + await queue.whenIdle() + + assert.equal(runs, 2) +}) + +test('pause signals stop to the running job and drops its pending work', async () => { + const started = deferred() + let observedStop: string | null = null + const release = deferred() + + const queue = new SemanticIndexJobQueue(async ({ checkStop }: JobContext) => { + started.resolve() + await release.promise + observedStop = checkStop() + }) + + queue.enqueue({ type: 'build', dbPathHash: 'a' }) + await started.promise + queue.pause('a') + release.resolve() + await queue.whenIdle() + + assert.equal(observedStop, 'paused') +}) + +test('cancel signals cancelled to the running job', async () => { + const started = deferred() + let observedStop: string | null = null + const release = deferred() + + const queue = new SemanticIndexJobQueue(async ({ checkStop }) => { + started.resolve() + await release.promise + observedStop = checkStop() + }) + + queue.enqueue({ type: 'build', dbPathHash: 'a' }) + await started.promise + queue.cancel('a') + release.resolve() + await queue.whenIdle() + + assert.equal(observedStop, 'cancelled') +}) + +test('executor failure does not stop the queue', async () => { + const seen: string[] = [] + const queue = new SemanticIndexJobQueue(async ({ job }) => { + seen.push(job.dbPathHash) + if (job.dbPathHash === 'a') throw new Error('boom') + }) + + queue.enqueue({ type: 'build', dbPathHash: 'a' }) + queue.enqueue({ type: 'build', dbPathHash: 'b' }) + await queue.whenIdle() + + assert.deepEqual(seen, ['a', 'b']) +}) + +test('isQueued reflects pending and running state', async () => { + const gate = deferred() + const queue = new SemanticIndexJobQueue(async () => { + await gate.promise + }) + + queue.enqueue({ type: 'build', dbPathHash: 'a' }) + queue.enqueue({ type: 'build', dbPathHash: 'b' }) + assert.equal(queue.isQueued('a'), true) + assert.equal(queue.isQueued('b'), true) + assert.equal(queue.isQueued('c'), false) + gate.resolve() + await queue.whenIdle() + assert.equal(queue.isQueued('a'), false) +}) diff --git a/packages/node-runtime/src/semantic-index/warmup/job-queue.ts b/packages/node-runtime/src/semantic-index/warmup/job-queue.ts new file mode 100644 index 000000000..649bf83e2 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/warmup/job-queue.ts @@ -0,0 +1,102 @@ +/** + * 语义索引串行 job 队列(Phase 1 simple runner) + * + * chunking-decision-final.md 第 15 节:build/rebuild/cleanup 任务串行处理,避免 CPU、 + * 内存、API 请求和 SQLite 写入竞争;支持按对话暂停、取消、续跑;失败保留已完成部分。 + * + * 本队列只负责调度与停止信号管理,具体执行(读库/embedding/写入)由注入的 executor + * 完成。队列不持久化;重启后的恢复由 service 层扫描业务状态表(权威来源)重新入队驱动。 + */ + +import type { StopSignal } from './runner' + +export type SemanticIndexJobType = 'build' | 'rebuild' | 'cleanup' + +export interface SemanticIndexJob { + type: SemanticIndexJobType + dbPathHash: string +} + +export interface JobContext { + job: SemanticIndexJob + /** executor 应将其传入 runWarmup;返回 'paused'/'cancelled' 时应停止 */ + checkStop: StopSignal +} + +export type JobExecutor = (ctx: JobContext) => Promise + +type StopState = null | 'paused' | 'cancelled' + +export class SemanticIndexJobQueue { + private executor: JobExecutor + private pending: SemanticIndexJob[] = [] + private processing = false + private currentHash: string | null = null + private stopState: StopState = null + private idleResolvers: (() => void)[] = [] + + constructor(executor: JobExecutor) { + this.executor = executor + } + + /** 入队;同一对话 + 同类型的待处理任务去重 */ + enqueue(job: SemanticIndexJob): void { + const duplicate = this.pending.some((j) => j.dbPathHash === job.dbPathHash && j.type === job.type) + if (duplicate) return + this.pending.push(job) + void this.process() + } + + /** 暂停指定对话:停止其正在运行的任务并移除其待处理任务(不自动续跑) */ + pause(dbPathHash: string): void { + this.removePending(dbPathHash) + if (this.currentHash === dbPathHash) this.stopState = 'paused' + } + + /** 取消指定对话:停止其正在运行的任务并移除其待处理任务 */ + cancel(dbPathHash: string): void { + this.removePending(dbPathHash) + if (this.currentHash === dbPathHash) this.stopState = 'cancelled' + } + + isRunning(dbPathHash: string): boolean { + return this.currentHash === dbPathHash + } + + isQueued(dbPathHash: string): boolean { + return this.currentHash === dbPathHash || this.pending.some((j) => j.dbPathHash === dbPathHash) + } + + /** 等待队列清空(含当前任务) */ + whenIdle(): Promise { + if (!this.processing && this.pending.length === 0) return Promise.resolve() + return new Promise((resolve) => this.idleResolvers.push(resolve)) + } + + private removePending(dbPathHash: string): void { + this.pending = this.pending.filter((j) => j.dbPathHash !== dbPathHash) + } + + private async process(): Promise { + if (this.processing) return + this.processing = true + + while (this.pending.length > 0) { + const job = this.pending.shift()! + this.currentHash = job.dbPathHash + this.stopState = null + try { + await this.executor({ job, checkStop: () => this.stopState }) + } catch { + // executor 内部负责把失败写入业务状态;队列继续处理后续任务 + } + this.currentHash = null + this.stopState = null + } + + this.processing = false + const resolvers = this.idleResolvers + this.idleResolvers = [] + for (const resolve of resolvers) resolve() + } +} diff --git a/packages/node-runtime/src/semantic-index/warmup/runner.test.ts b/packages/node-runtime/src/semantic-index/warmup/runner.test.ts new file mode 100644 index 000000000..f29510777 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/warmup/runner.test.ts @@ -0,0 +1,332 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { runWarmup, type SemanticMessageSource, type StopSignal } from './runner' +import { EmbeddingIndexStore } from '../store' +import { SemanticIndexStateStore } from '../session-state-store' +import { DEFAULT_CHUNKER_CONFIG, type ChunkerConfig } from '../chunker-config' +import type { EmbeddingProvider } from '../embedding/types' +import type { ChunkMessageInput } from '../chunker' + +const MINUTE = 60_000 +const DB_HASH = 'dbA' +const MODEL = 'fake' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-warmup-')) +} + +// overlap=0、单 parent、小字符上限:每 2 条消息一个 chunk +const config: ChunkerConfig = { + ...DEFAULT_CHUNKER_CONFIG, + parentGapSeconds: 100000, + parentMaxTokens: 100000, + childTargetMaxChars: 30, + childHardMaxTokens: 100000, + overlapMessages: 0, + semanticVoidSkipThreshold: 1, +} + +function makeMessages(count: number): ChunkMessageInput[] { + return Array.from({ length: count }, (_, i) => ({ + id: i + 1, + senderName: '张三', + content: '这是一条二十个字左右的测试消息内容', + ts: i * 0.1 * MINUTE, + })) +} + +function makeSource(messages: ChunkMessageInput[]): SemanticMessageSource { + return { + getSource: () => ({ title: '测试群', kind: 'group' }), + countMessages: () => messages.length, + readAllMessages: () => messages, + } +} + +class FakeEmbedder implements EmbeddingProvider { + readonly modelId = MODEL + readonly dim = 4 + readonly maxTokens = 1000 + calls = 0 + batchSizes: number[] = [] + failAtCall?: number + afterEmbed?: () => void + + async embedDocuments(texts: string[]): Promise { + this.batchSizes.push(texts.length) + return texts.map(() => { + this.calls++ + if (this.failAtCall && this.calls >= this.failAtCall) throw new Error('boom') + this.afterEmbed?.() + return new Float32Array([1, 0, 0, 0]) + }) + } + + async embedQuery(text: string): Promise { + return (await this.embedDocuments([text]))[0] + } +} + +class BatchingFakeEmbedder extends FakeEmbedder { + readonly documentBatchSize = 32 +} + +function setup(messages: ChunkMessageInput[]) { + const dir = makeTempDir() + const dbPath = path.join(dir, 'embedding_index.db') + const store = new EmbeddingIndexStore(dbPath) + const stateStore = new SemanticIndexStateStore(dbPath) + stateStore.enable({ + dbPathHash: DB_HASH, + dbPath: '/chat/a.db', + modelId: MODEL, + chunkerVersion: 'v1.1', + chunkerConfigHash: 'cfg', + }) + return { store, stateStore, source: makeSource(messages) } +} + +function countStored(store: EmbeddingIndexStore): number { + return store.queryDense({ dbPathHash: DB_HASH, modelId: MODEL, dim: 4, embedding: [1, 0, 0, 0], k: 1000 }).length +} + +function storedParentIds(store: EmbeddingIndexStore): string[] { + return store + .queryDense({ dbPathHash: DB_HASH, modelId: MODEL, dim: 4, embedding: [1, 0, 0, 0], k: 1000 }) + .map((result) => result.record.parentId) +} + +test('full warmup writes all chunks and marks completed', async () => { + const { store, stateStore, source } = setup(makeMessages(8)) + const embedder = new FakeEmbedder() + + const result = await runWarmup({ dbPathHash: DB_HASH, modelId: MODEL, embedder, store, stateStore, source, config }) + + assert.equal(result.status, 'completed') + assert.equal(result.chunksWritten, 4) + assert.equal(countStored(store), 4) + + const state = stateStore.getState(DB_HASH)! + assert.equal(state.indexStatus, 'completed') + assert.equal(state.chunkCount, 4) + assert.equal(state.totalMessages, 8) + assert.equal(state.indexedMessages, 8) + store.close() + stateStore.close() +}) + +test('warmup batches document embeddings when the provider supports batches', async () => { + const { store, stateStore, source } = setup(makeMessages(70)) + const embedder = new BatchingFakeEmbedder() + + const result = await runWarmup({ dbPathHash: DB_HASH, modelId: MODEL, embedder, store, stateStore, source, config }) + + assert.equal(result.status, 'completed') + assert.equal(result.chunksWritten, 35) + assert.deepEqual(embedder.batchSizes, [32, 3]) + assert.equal(countStored(store), 35) + store.close() + stateStore.close() +}) + +test('pause then resume completes without duplicate inserts', async () => { + const { store, stateStore, source } = setup(makeMessages(8)) + const embedder = new FakeEmbedder() + + let calls = 0 + const pauseAfterTwoWrittenChunks: StopSignal = () => (++calls >= 5 ? 'paused' : null) + const paused = await runWarmup({ + dbPathHash: DB_HASH, + modelId: MODEL, + embedder, + store, + stateStore, + source, + config, + checkStop: pauseAfterTwoWrittenChunks, + }) + + assert.equal(paused.status, 'paused') + assert.equal(paused.chunksWritten, 2) + assert.equal(countStored(store), 2) + assert.equal(stateStore.getState(DB_HASH)!.indexStatus, 'paused') + + const resumed = await runWarmup({ dbPathHash: DB_HASH, modelId: MODEL, embedder, store, stateStore, source, config }) + assert.equal(resumed.status, 'completed') + assert.equal(resumed.chunksWritten, 2) + assert.equal(countStored(store), 4) + assert.equal(stateStore.getState(DB_HASH)!.chunkCount, 4) + store.close() + stateStore.close() +}) + +test('cancellation stops immediately and records cancelled state', async () => { + const { store, stateStore, source } = setup(makeMessages(8)) + const embedder = new FakeEmbedder() + + const result = await runWarmup({ + dbPathHash: DB_HASH, + modelId: MODEL, + embedder, + store, + stateStore, + source, + config, + checkStop: () => 'cancelled', + }) + + assert.equal(result.status, 'cancelled') + assert.equal(result.chunksWritten, 0) + assert.equal(countStored(store), 0) + assert.equal(stateStore.getState(DB_HASH)!.indexStatus, 'cancelled') + store.close() + stateStore.close() +}) + +test('cancellation after embedding does not write the just-finished chunk', async () => { + const { store, stateStore, source } = setup(makeMessages(8)) + const embedder = new FakeEmbedder() + + let cancelled = false + embedder.afterEmbed = () => { + cancelled = true + } + + const result = await runWarmup({ + dbPathHash: DB_HASH, + modelId: MODEL, + embedder, + store, + stateStore, + source, + config, + checkStop: () => (cancelled ? 'cancelled' : null), + }) + + assert.equal(result.status, 'cancelled') + assert.equal(result.chunksWritten, 0) + assert.equal(countStored(store), 0) + assert.equal(stateStore.getState(DB_HASH)!.indexStatus, 'cancelled') + store.close() + stateStore.close() +}) + +test('embedding failure marks failed and preserves partial progress', async () => { + const { store, stateStore, source } = setup(makeMessages(8)) + const embedder = new FakeEmbedder() + embedder.failAtCall = 2 + + const result = await runWarmup({ dbPathHash: DB_HASH, modelId: MODEL, embedder, store, stateStore, source, config }) + + assert.equal(result.status, 'failed') + assert.equal(result.error, 'boom') + assert.equal(result.chunksWritten, 1) + assert.equal(countStored(store), 1) + const state = stateStore.getState(DB_HASH)! + assert.equal(state.indexStatus, 'failed') + assert.equal(state.error, 'boom') + store.close() + stateStore.close() +}) + +test('empty conversation completes with zero chunks', async () => { + const { store, stateStore, source } = setup([]) + const embedder = new FakeEmbedder() + + const result = await runWarmup({ dbPathHash: DB_HASH, modelId: MODEL, embedder, store, stateStore, source, config }) + + assert.equal(result.status, 'completed') + assert.equal(result.chunksWritten, 0) + assert.equal(stateStore.getState(DB_HASH)!.indexStatus, 'completed') + store.close() + stateStore.close() +}) + +test('backfilled older messages trigger full re-index', async () => { + // First run: index 4 messages (ids 1-4, monotonically increasing ts) + const original = makeMessages(4) + const { store, stateStore } = setup(original) + const embedder = new FakeEmbedder() + + await runWarmup({ + dbPathHash: DB_HASH, + modelId: MODEL, + embedder, + store, + stateStore, + source: makeSource(original), + config, + }) + assert.equal(stateStore.getState(DB_HASH)!.indexStatus, 'completed') + const chunksAfterFirst = countStored(store) + + // Simulate backfill: prepend 2 older messages (ids 5-6 with ts before ids 1-4) + const backfilled: ChunkMessageInput[] = [ + { id: 5, senderName: '张三', content: '这是一条二十个字左右的测试消息内容', ts: -2 * 0.1 * MINUTE }, + { id: 6, senderName: '张三', content: '这是一条二十个字左右的测试消息内容', ts: -1 * 0.1 * MINUTE }, + ...original, + ] + + // Second run: non-append-only additions detected → should re-index all messages + const embedder2 = new FakeEmbedder() + const result = await runWarmup({ + dbPathHash: DB_HASH, + modelId: MODEL, + embedder: embedder2, + store, + stateStore, + source: makeSource(backfilled), + config, + }) + + assert.equal(result.status, 'completed') + // Must have more chunks than before (backfilled messages now indexed) + assert.ok(countStored(store) > chunksAfterFirst, 'backfilled messages should produce additional chunks') + assert.equal(stateStore.getState(DB_HASH)!.totalMessages, 6) + store.close() + stateStore.close() +}) + +test('append-only warmup replaces stale chunks from the extended tail parent', async () => { + const original = makeMessages(4) + const { store, stateStore } = setup(original) + const embedder = new FakeEmbedder() + + await runWarmup({ + dbPathHash: DB_HASH, + modelId: MODEL, + embedder, + store, + stateStore, + source: makeSource(original), + config, + }) + assert.equal(countStored(store), 2) + assert.ok(storedParentIds(store).every((id) => id.includes('parent:1:4:'))) + + const appended = [ + ...original, + ...makeMessages(2).map((message) => ({ ...message, id: message.id + 4, ts: message.ts + 4 * 0.1 * MINUTE })), + ] + const result = await runWarmup({ + dbPathHash: DB_HASH, + modelId: MODEL, + embedder: new FakeEmbedder(), + store, + stateStore, + source: makeSource(appended), + config, + }) + + assert.equal(result.status, 'completed') + assert.equal(result.chunksWritten, 3) + assert.equal(countStored(store), 3) + assert.ok(storedParentIds(store).every((id) => id.includes('parent:1:6:'))) + assert.equal(stateStore.getState(DB_HASH)!.chunkCount, 3) + store.close() + stateStore.close() +}) diff --git a/packages/node-runtime/src/semantic-index/warmup/runner.ts b/packages/node-runtime/src/semantic-index/warmup/runner.ts new file mode 100644 index 000000000..f3f44521c --- /dev/null +++ b/packages/node-runtime/src/semantic-index/warmup/runner.ts @@ -0,0 +1,186 @@ +/** + * 单对话 warmup runner + * + * 职责(chunking-decision-final.md 第 9/15 节):读取对话消息流 -> 全量 chunk -> + * 逐 chunk embedding 并增量写入向量库 -> 实时更新业务状态。支持暂停、取消、失败处理 + * 和断点续跑,部分完成即可被检索。 + * + * 设计要点: + * - chunk 是纯函数且廉价,每次运行对全量消息重新 chunk(保证 parent 边界稳定)。 + * - 通过业务状态的 lastIndexedMessageId 游标跳过已写入 chunk,实现断点续跑。 + * - 每写入一个 chunk 即更新游标,保证崩溃后续跑不重复写入(chunk_id UNIQUE)。 + * - embedding 是瓶颈;每 chunk 前检查暂停/取消,embedding 返回后再次检查取消, + * 避免在清理已取消索引后写入刚完成的向量。 + * + * 依赖全部注入,便于单测(fake source/embedder + 真实内存级 SQLite store)。 + */ + +import type { EmbeddingProvider } from '../embedding/types' +import { chunkMessages, type ChunkMessageInput, type ChunkSource } from '../chunker' +import { CHUNKER_VERSION, STRATEGY_ID, composeChunkId, type ChunkerConfig } from '../chunker-config' +import type { EmbeddingIndexStore } from '../store' +import type { SemanticIndexStateStore } from '../session-state-store' +import type { ChunkInsert, ChunkRecord } from '../types' + +/** 对话消息来源(warmup 输入抽象,真实实现读取聊天库) */ +export interface SemanticMessageSource { + getSource(): ChunkSource + countMessages(): number + /** 按 ts, id 升序返回全部消息 */ + readAllMessages(): ChunkMessageInput[] +} + +/** 停止信号:返回 null 继续,'paused' 暂停(可续跑),'cancelled' 取消 */ +export type StopSignal = () => null | 'paused' | 'cancelled' + +export interface WarmupRunnerOptions { + dbPathHash: string + modelId: string + embedder: EmbeddingProvider + store: EmbeddingIndexStore + stateStore: SemanticIndexStateStore + source: SemanticMessageSource + config?: ChunkerConfig + checkStop?: StopSignal +} + +export type WarmupStatus = 'completed' | 'paused' | 'cancelled' | 'failed' + +export interface WarmupResult { + status: WarmupStatus + chunksWritten: number + error?: string +} + +function resolveDocumentBatchSize(embedder: EmbeddingProvider): number { + const size = embedder.documentBatchSize + return Number.isFinite(size) && size && size > 0 ? Math.floor(size) : 1 +} + +export async function runWarmup(options: WarmupRunnerOptions): Promise { + const { dbPathHash, modelId, embedder, store, stateStore, source, config, checkStop } = options + + let chunksWritten = 0 + try { + const messages = source.readAllMessages() + const total = source.countMessages() + const savedState = stateStore.getState(dbPathHash) + stateStore.updateProgress(dbPathHash, { indexStatus: 'running', totalMessages: total, error: null }) + + // 消息 id -> 流位置,用于进度计数(消息按 ts 排序,id 未必单调) + const streamIndexById = new Map() + messages.forEach((m, i) => streamIndexById.set(m.id, i)) + + const resumeMessageId = savedState?.lastIndexedMessageId ?? null + const rawResumeIndex = resumeMessageId !== null ? (streamIndexById.get(resumeMessageId) ?? -1) : -1 + + // Detect non-append-only additions: if the cursor's new stream position doesn't match the + // saved indexed count, older messages were backfilled before the cursor. Clear vectors and + // re-index from scratch to avoid silently missing the backfilled messages. + const isNonAppendOnly = rawResumeIndex >= 0 && rawResumeIndex + 1 !== (savedState?.indexedMessages ?? 0) + if (isNonAppendOnly) { + store.deleteByDbPathHash(dbPathHash) + } + const { chunks, chunkerConfigHash } = chunkMessages({ messages, source: source.getSource(), config }) + + const chunkRanges = chunks.map((chunk) => ({ + chunk, + startIndex: streamIndexById.get(chunk.startMessageId) ?? -1, + endIndex: streamIndexById.get(chunk.endMessageId) ?? -1, + })) + + let resumeIndex = isNonAppendOnly ? -1 : rawResumeIndex + const hasAppendedMessages = !isNonAppendOnly && rawResumeIndex >= 0 && total > (savedState?.totalMessages ?? 0) + if (hasAppendedMessages) { + const cursorRange = + chunkRanges.find((range) => range.startIndex <= rawResumeIndex && rawResumeIndex <= range.endIndex) ?? + chunkRanges.filter((range) => range.endIndex <= rawResumeIndex).sort((a, b) => b.endIndex - a.endIndex)[0] + + if (cursorRange) { + const parentRanges = chunkRanges.filter((range) => range.chunk.parentId === cursorRange.chunk.parentId) + const rewindIndex = Math.min(...parentRanges.map((range) => range.startIndex).filter((index) => index >= 0)) + const rewindMessage = messages[rewindIndex] + if (rewindMessage) { + store.deleteByModelFromPosition({ + dbPathHash, + modelId, + startTs: rewindMessage.ts, + startMessageId: rewindMessage.id, + }) + resumeIndex = rewindIndex - 1 + } + } + } + + // 续跑时统计已写入 chunk 数,保证 chunkCount 连续 + let storedChunkCount = store.countChunks(dbPathHash, modelId) + if (resumeIndex < 0) storedChunkCount = 0 + + const pendingRanges = chunkRanges.filter(({ endIndex }) => endIndex > resumeIndex) + const batchSize = resolveDocumentBatchSize(embedder) + for (let i = 0; i < pendingRanges.length; i += batchSize) { + const batchRanges = pendingRanges.slice(i, i + batchSize) + + const stop = checkStop?.() + if (stop) { + stateStore.setIndexStatus(dbPathHash, stop === 'paused' ? 'paused' : 'cancelled') + return { status: stop, chunksWritten } + } + + const vectors = await embedder.embedDocuments(batchRanges.map(({ chunk }) => chunk.embeddingInput)) + if (vectors.length !== batchRanges.length) { + throw new Error(`embedding provider returned ${vectors.length} vectors for ${batchRanges.length} inputs`) + } + const stopAfterEmbedding = checkStop?.() + if (stopAfterEmbedding === 'cancelled') { + stateStore.setIndexStatus(dbPathHash, 'cancelled') + return { status: 'cancelled', chunksWritten } + } + + // API provider 支持一次提交多个文本;本地 Qwen3 仍声明 batch=1,避免 last_token 污染。 + const indexedAt = Date.now() + const inserts: ChunkInsert[] = batchRanges.map(({ chunk }, index) => { + const vector = vectors[index] + const record: ChunkRecord = { + chunkId: composeChunkId(dbPathHash, modelId, chunk.localChunkId), + dbPathHash, + strategyId: STRATEGY_ID, + modelId, + dim: vector.length, + parentId: chunk.parentId, + startMessageId: chunk.startMessageId, + endMessageId: chunk.endMessageId, + startTs: chunk.startTs, + endTs: chunk.endTs, + messageCount: chunk.messageCount, + rawContentHash: chunk.rawContentHash, + embeddingInputHash: chunk.embeddingInputHash, + chunkerVersion: CHUNKER_VERSION, + chunkerConfigHash, + indexedAt, + status: 'indexed', + } + return { record, embedding: vector } + }) + store.insertChunks(inserts) + chunksWritten += inserts.length + storedChunkCount += inserts.length + + const lastRange = batchRanges[batchRanges.length - 1] + stateStore.updateProgress(dbPathHash, { + indexStatus: 'running', + indexedMessages: lastRange.endIndex + 1, + lastIndexedMessageId: lastRange.chunk.endMessageId, + chunkCount: storedChunkCount, + }) + } + + stateStore.updateProgress(dbPathHash, { indexedMessages: total, chunkCount: storedChunkCount }) + stateStore.setIndexStatus(dbPathHash, 'completed', null) + return { status: 'completed', chunksWritten } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + stateStore.setIndexStatus(dbPathHash, 'failed', message) + return { status: 'failed', chunksWritten, error: message } + } +} diff --git a/packages/node-runtime/src/semantic-index/worker-client.test.ts b/packages/node-runtime/src/semantic-index/worker-client.test.ts new file mode 100644 index 000000000..6ff80a42e --- /dev/null +++ b/packages/node-runtime/src/semantic-index/worker-client.test.ts @@ -0,0 +1,459 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { SemanticIndexConfigStore, type SemanticIndexConfig } from './config' +import type { SemanticIndexSessionStatus } from './service' +import { + createSemanticIndexWorkerClient, + type SemanticIndexWorkerTransport, + type SemanticIndexWorkerTransportFactory, +} from './worker-client' + +function makeConfigStore(): SemanticIndexConfigStore { + const dir = fs.mkdtempSync( + path.join(fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir(), 'chatlab-si-client-') + ) + return new SemanticIndexConfigStore(path.join(dir, 'semantic-index-config.json')) +} + +class FakeTransport implements SemanticIndexWorkerTransport { + requests: Array<{ method: string; args: unknown[] }> = [] + closed = false + + constructor(private readonly handler?: (method: string, args: unknown[]) => unknown) {} + + async request(method: string, args: unknown[]): Promise { + this.requests.push({ method, args }) + if (this.handler) return (await this.handler(method, args)) as T + if (method === 'status') { + return null as T + } + if (method === 'setConfig') { + return args[0] as T + } + throw new Error(`unexpected method: ${method}`) + } + + close(): void { + this.closed = true + } +} + +function makeFactory( + instances: FakeTransport[], + handler?: (method: string, args: unknown[]) => unknown, + proxyUrls?: Array +): SemanticIndexWorkerTransportFactory { + return (modelDownloadProxyUrl) => { + proxyUrls?.push(modelDownloadProxyUrl) + const transport = new FakeTransport(handler) + instances.push(transport) + return transport + } +} + +function makeStatus(sessionId: string, active: boolean): SemanticIndexSessionStatus { + return { + sessionId, + enabled: true, + indexStatus: active ? 'running' : 'completed', + needsRebuild: false, + totalMessages: 10, + indexedMessages: active ? 5 : 10, + chunkCount: active ? 1 : 2, + coverage: active ? 0.5 : 1, + queued: false, + running: active, + partial: active, + error: null, + modelId: 'model-a', + } +} + +test('worker client keeps config-only operations in process without starting worker', async () => { + const instances: FakeTransport[] = [] + const client = createSemanticIndexWorkerClient({ + configStore: makeConfigStore(), + transportFactory: makeFactory(instances), + idleTimeoutMs: 1000, + }) + + const initial = await client.getConfig() + await client.setConfig({ ...initial, enabled: false }) + const saved = await client.getConfig() + + assert.equal(saved.enabled, false) + assert.equal(instances.length, 0) +}) + +test('worker client returns unavailable for unconfigured canSearch without starting worker', async () => { + const instances: FakeTransport[] = [] + const client = createSemanticIndexWorkerClient({ + configStore: makeConfigStore(), + transportFactory: makeFactory(instances), + idleTimeoutMs: 1000, + }) + + const available = await client.canSearch('session-a') + + assert.equal(available, false) + assert.equal(instances.length, 0) +}) + +test('worker client treats canSearch worker failures as unavailable', async () => { + const instances: FakeTransport[] = [] + const configStore = makeConfigStore() + configStore.set({ version: 1, mode: 'local', local: { modelId: 'model-a' }, api: null }) + const client = createSemanticIndexWorkerClient({ + configStore, + transportFactory: makeFactory(instances, () => { + throw new Error('worker failed to start') + }), + idleTimeoutMs: 1000, + }) + + const available = await client.canSearch('session-a') + + assert.equal(available, false) + assert.equal(instances.length, 1) + assert.equal(instances[0].closed, true) +}) + +test('worker client treats status worker failures as unavailable', async () => { + const instances: FakeTransport[] = [] + const client = createSemanticIndexWorkerClient({ + configStore: makeConfigStore(), + transportFactory: makeFactory(instances, () => { + throw new Error('worker failed to start') + }), + idleTimeoutMs: 1000, + }) + + const status = await client.status('session-a') + + assert.equal(status, null) + assert.equal(instances.length, 1) + assert.equal(instances[0].closed, true) +}) + +test('worker client lazy-starts for runtime calls and closes after idle timeout', async () => { + const instances: FakeTransport[] = [] + const timers: Array<() => void> = [] + const client = createSemanticIndexWorkerClient({ + configStore: makeConfigStore(), + transportFactory: makeFactory(instances), + idleTimeoutMs: 1000, + timers: { + setTimeout(callback) { + timers.push(callback) + return callback + }, + clearTimeout() { + /* test timer is manually triggered */ + }, + }, + }) + + assert.equal(instances.length, 0) + + const status = await client.status('session-a') + + assert.equal(status, null) + assert.equal(instances.length, 1) + assert.deepEqual(instances[0].requests, [{ method: 'status', args: ['session-a'] }]) + assert.equal(instances[0].closed, false) + + timers.shift()?.() + + assert.equal(instances[0].closed, true) +}) + +test('worker client forwards config updates to a live worker without starting a new one', async () => { + const instances: FakeTransport[] = [] + const client = createSemanticIndexWorkerClient({ + configStore: makeConfigStore(), + transportFactory: makeFactory(instances), + idleTimeoutMs: 1000, + }) + + await client.status('session-a') + const initial = await client.getConfig() + const updated: SemanticIndexConfig = { ...initial, enabled: false } + + const saved = await client.setConfig(updated) + + assert.equal(saved.enabled, false) + assert.equal(instances.length, 1) + assert.deepEqual(instances[0].requests, [ + { method: 'status', args: ['session-a'] }, + { method: 'setConfig', args: [saved] }, + ]) +}) + +test('worker client restarts live worker before local model preload when proxy changes', async () => { + const instances: FakeTransport[] = [] + const proxy = { url: undefined as string | undefined } + const client = createSemanticIndexWorkerClient({ + configStore: makeConfigStore(), + transportFactory: makeFactory(instances), + getModelDownloadProxyUrl: () => proxy.url, + idleTimeoutMs: 1000, + }) + + await client.status('session-a') + proxy.url = 'http://127.0.0.1:7890' + const initial = await client.getConfig() + const updated: SemanticIndexConfig = { + ...initial, + enabled: true, + mode: 'local', + local: { modelId: 'model-a' }, + api: null, + } + + const saved = await client.setConfig(updated) + + assert.equal(saved.local.modelId, 'model-a') + assert.equal(instances.length, 2) + assert.equal(instances[0].closed, true) + assert.deepEqual(instances[0].requests, [{ method: 'status', args: ['session-a'] }]) + assert.deepEqual(instances[1].requests, [{ method: 'setConfig', args: [saved] }]) +}) + +test('worker client resolves async model download proxy before starting worker', async () => { + const instances: FakeTransport[] = [] + const proxyUrls: Array = [] + const client = createSemanticIndexWorkerClient({ + configStore: makeConfigStore(), + transportFactory: makeFactory(instances, undefined, proxyUrls), + getModelDownloadProxyUrl: async () => 'http://127.0.0.1:7890', + idleTimeoutMs: 1000, + }) + + await client.status('session-a') + + assert.equal(instances.length, 1) + assert.deepEqual(proxyUrls, ['http://127.0.0.1:7890']) +}) + +test('worker client restarts live worker before local build calls when proxy changes', async () => { + const cases: Array<{ + action: 'build' | 'rebuild' | 'buildAllPending' + run: (client: ReturnType) => Promise + expectedRequest: { method: string; args: unknown[] } + }> = [ + { + action: 'build', + run: (client) => client.build('session-a'), + expectedRequest: { method: 'build', args: ['session-a'] }, + }, + { + action: 'rebuild', + run: (client) => client.rebuild('session-a'), + expectedRequest: { method: 'rebuild', args: ['session-a'] }, + }, + { + action: 'buildAllPending', + run: (client) => client.buildAllPending(), + expectedRequest: { method: 'buildAllPending', args: [] }, + }, + ] + + for (const { action, run, expectedRequest } of cases) { + const instances: FakeTransport[] = [] + const proxy = { url: undefined as string | undefined } + const configStore = makeConfigStore() + configStore.set({ version: 1, enabled: true, mode: 'local', local: { modelId: 'model-a' }, api: null }) + const client = createSemanticIndexWorkerClient({ + configStore, + transportFactory: makeFactory(instances, (method) => { + if (method === 'status') return null + if (method === action) return undefined + throw new Error(`unexpected method: ${method}`) + }), + getModelDownloadProxyUrl: () => proxy.url, + idleTimeoutMs: 1000, + }) + + await client.status('session-a') + proxy.url = 'http://127.0.0.1:7890' + await run(client) + + assert.equal(instances.length, 2, action) + assert.equal(instances[0].closed, true, action) + assert.deepEqual(instances[0].requests, [{ method: 'status', args: ['session-a'] }], action) + assert.deepEqual(instances[1].requests, [expectedRequest], action) + } +}) + +test('worker client restarts live worker before local search calls when proxy changes', async () => { + const cases: Array<{ + action: 'search' | 'searchForTool' + run: (client: ReturnType) => Promise + expectedRequest: { method: string; args: unknown[] } + }> = [ + { + action: 'search', + run: (client) => client.search('session-a', 'query-a', { finalTopK: 3 }), + expectedRequest: { method: 'search', args: ['session-a', 'query-a', { finalTopK: 3 }] }, + }, + { + action: 'searchForTool', + run: (client) => client.searchForTool('session-a', 'query-a', { maxResults: 3 }), + expectedRequest: { method: 'searchForTool', args: ['session-a', 'query-a', { maxResults: 3 }] }, + }, + ] + + for (const { action, run, expectedRequest } of cases) { + const instances: FakeTransport[] = [] + const proxy = { url: undefined as string | undefined } + const configStore = makeConfigStore() + configStore.set({ version: 1, enabled: true, mode: 'local', local: { modelId: 'model-a' }, api: null }) + const client = createSemanticIndexWorkerClient({ + configStore, + transportFactory: makeFactory(instances, (method) => { + if (method === 'status') return null + if (method === action) return {} + throw new Error(`unexpected method: ${method}`) + }), + getModelDownloadProxyUrl: () => proxy.url, + idleTimeoutMs: 1000, + }) + + await client.status('session-a') + proxy.url = 'http://127.0.0.1:7890' + await run(client) + + assert.equal(instances.length, 2, action) + assert.equal(instances[0].closed, true, action) + assert.deepEqual(instances[0].requests, [{ method: 'status', args: ['session-a'] }], action) + assert.deepEqual(instances[1].requests, [expectedRequest], action) + } +}) + +test('worker client does not restart api worker before build when proxy changes', async () => { + const instances: FakeTransport[] = [] + const proxy = { url: undefined as string | undefined } + const configStore = makeConfigStore() + configStore.set({ + version: 1, + enabled: true, + mode: 'api', + local: { modelId: 'model-a' }, + api: { baseUrl: 'https://api.example.com/v1', model: 'embed-1' }, + }) + const client = createSemanticIndexWorkerClient({ + configStore, + transportFactory: makeFactory(instances, (method) => { + if (method === 'status') return null + if (method === 'build') return undefined + throw new Error(`unexpected method: ${method}`) + }), + getModelDownloadProxyUrl: () => proxy.url, + idleTimeoutMs: 1000, + }) + + await client.status('session-a') + proxy.url = 'http://127.0.0.1:7890' + await client.build('session-a') + + assert.equal(instances.length, 1) + assert.equal(instances[0].closed, false) + assert.deepEqual(instances[0].requests, [ + { method: 'status', args: ['session-a'] }, + { method: 'build', args: ['session-a'] }, + ]) +}) + +test('worker client clears tracked active builds when semantic indexing is disabled', async () => { + const instances: FakeTransport[] = [] + const timers: Array<() => void> = [] + const client = createSemanticIndexWorkerClient({ + configStore: makeConfigStore(), + transportFactory: makeFactory(instances, (method, args) => { + if (method === 'build') return undefined + if (method === 'buildAllPending') return undefined + if (method === 'setConfig') return args[0] + throw new Error(`unexpected method: ${method}`) + }), + idleTimeoutMs: 1000, + timers: { + setTimeout(callback) { + timers.push(callback) + return callback + }, + clearTimeout() { + /* test timer is manually triggered */ + }, + }, + }) + + await client.build('session-a') + await client.buildAllPending() + const initial = await client.getConfig() + await client.setConfig({ ...initial, enabled: false }) + + assert.equal(timers.length, 1) + timers.shift()?.() + assert.equal(instances[0].closed, true) +}) + +test('worker client does not clear active-build state from partial status snapshots', async () => { + const instances: FakeTransport[] = [] + const timers: Array<() => void> = [] + const client = createSemanticIndexWorkerClient({ + configStore: makeConfigStore(), + transportFactory: makeFactory(instances, (method) => { + if (method === 'listEnabledStatuses') return [makeStatus('session-a', true)] + if (method === 'status') return makeStatus('session-b', false) + throw new Error(`unexpected method: ${method}`) + }), + idleTimeoutMs: 1000, + timers: { + setTimeout(callback) { + timers.push(callback) + return callback + }, + clearTimeout() { + /* test timer is manually triggered */ + }, + }, + }) + + await client.listEnabledStatuses() + await client.status('session-b') + + assert.equal(timers.length, 0) +}) + +test('worker client clears session active-build state from matching status snapshots', async () => { + const instances: FakeTransport[] = [] + const timers: Array<() => void> = [] + const client = createSemanticIndexWorkerClient({ + configStore: makeConfigStore(), + transportFactory: makeFactory(instances, (method, args) => { + if (method === 'build') return undefined + if (method === 'status') return makeStatus(args[0] as string, false) + throw new Error(`unexpected method: ${method}`) + }), + idleTimeoutMs: 1000, + timers: { + setTimeout(callback) { + timers.push(callback) + return callback + }, + clearTimeout() { + /* test timer is manually triggered */ + }, + }, + }) + + await client.build('session-a') + await client.status('session-a') + + assert.equal(timers.length, 1) + timers.shift()?.() + assert.equal(instances[0].closed, true) +}) diff --git a/packages/node-runtime/src/semantic-index/worker-client.ts b/packages/node-runtime/src/semantic-index/worker-client.ts new file mode 100644 index 000000000..e3d5be7d3 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/worker-client.ts @@ -0,0 +1,388 @@ +import path from 'node:path' +import type { PathProvider } from '@openchatlab/core' +import type { AuthProfile } from '@openchatlab/config' +import { resolveApiKey as defaultResolveApiKey } from '@openchatlab/config' +import type { SemanticIndexConfig, SemanticIndexConfigInput } from './config' +import { isKeylessSemanticIndexApiBaseUrl, SemanticIndexConfigStore } from './config' +import { + SEMANTIC_INDEX_CONFIG_FILE, + persistSemanticIndexConfig, + resolveSemanticIndexApiKeySet, + type SemanticIndexSessionStatus, + type SemanticSearchResult, + type SemanticSearchToolOptions, + type SemanticSearchToolResult, +} from './service' +import type { SemanticIndexRuntime } from './runtime' +import type { RuntimeIdentity } from '../data-dir-compat' +import { snapshotPathProvider } from './static-path-provider' +import { createSemanticIndexWorkerThreadTransport } from './worker-thread-transport' + +export interface SemanticIndexWorkerTransport { + request(method: string, args: unknown[]): Promise + close(): void | Promise +} + +type MaybePromise = T | Promise + +export type SemanticIndexWorkerTransportFactory = (modelDownloadProxyUrl?: string) => SemanticIndexWorkerTransport + +interface WorkerClientTimers { + setTimeout(callback: () => void, ms: number): unknown + clearTimeout(timer: unknown): void +} + +export interface SemanticIndexWorkerClientOptions { + configStore: SemanticIndexConfigStore + transportFactory: SemanticIndexWorkerTransportFactory + idleTimeoutMs?: number + timers?: WorkerClientTimers + getModelDownloadProxyUrl?: () => MaybePromise + resolveApiKey?: (provider: string, authProfile?: string) => string + writeAuthProfile?: (name: string, profile: AuthProfile) => void +} + +export interface SemanticIndexWorkerRuntimeClientOptions { + pathProvider: PathProvider + runtime: RuntimeIdentity + nativeBinding?: string + sqliteVecLoadablePath?: string + workerEntryUrl?: string | URL + idleTimeoutMs?: number + getModelDownloadProxyUrl?: () => MaybePromise + resolveApiKey?: (provider: string, authProfile?: string) => string + writeAuthProfile?: (name: string, profile: AuthProfile) => void +} + +const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000 + +const defaultTimers: WorkerClientTimers = { + setTimeout: (callback, ms) => setTimeout(callback, ms), + clearTimeout: (timer) => clearTimeout(timer as ReturnType), +} + +export class SemanticIndexWorkerClient implements SemanticIndexRuntime { + private transport: SemanticIndexWorkerTransport | null = null + private pendingRequests = 0 + private idleTimer: unknown = null + private activeBuildSessionIds = new Set() + private hasUnknownActiveBuild = false + private activeModelDownloadProxyUrl: string | undefined + private readonly configStore: SemanticIndexConfigStore + private readonly transportFactory: SemanticIndexWorkerTransportFactory + private readonly idleTimeoutMs: number + private readonly timers: WorkerClientTimers + private readonly getModelDownloadProxyUrl: () => MaybePromise + private readonly resolveApiKey: (provider: string, authProfile?: string) => string + private readonly writeAuthProfile?: (name: string, profile: AuthProfile) => void + + constructor(options: SemanticIndexWorkerClientOptions) { + this.configStore = options.configStore + this.transportFactory = options.transportFactory + this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS + this.timers = options.timers ?? defaultTimers + this.getModelDownloadProxyUrl = options.getModelDownloadProxyUrl ?? (() => undefined) + this.resolveApiKey = options.resolveApiKey ?? defaultResolveApiKey + this.writeAuthProfile = options.writeAuthProfile + } + + getConfig(): SemanticIndexConfig { + return this.configStore.get() + } + + async setConfig(config: SemanticIndexConfigInput, options?: { apiKey?: string }): Promise { + const saved = persistSemanticIndexConfig(this.configStore, config, { + apiKey: options?.apiKey, + writeAuthProfile: this.writeAuthProfile, + }) + if (!saved.enabled) this.clearActiveBuildTracking() + // 本地模式且功能开启时,即使 worker 未运行也需启动它以触发 preload; + // 其余情况(disabled / api 模式)无需唤醒 worker,直接返回本地保存结果。 + const needsWorker = saved.enabled && saved.mode === 'local' && this.configStore.isConfigured() + if (!this.transport && !needsWorker) return saved + if (needsWorker) await this.closeTransportForLocalModelProxyChangeIfNeeded() + return await this.call('setConfig', [saved]) + } + + isConfigured(): boolean { + return this.configStore.isConfigured() + } + + hasApiKey(): boolean { + return resolveSemanticIndexApiKeySet(this.configStore.get(), this.resolveApiKey) + } + + async getModelStatus(): Promise<'idle' | 'downloading' | 'ready' | 'error'> { + if (!this.transport) return 'idle' + return this.call<'idle' | 'downloading' | 'ready' | 'error'>('getModelStatus', []) + } + + enable(sessionId: string): Promise { + return this.call('enable', [sessionId]) + } + + remove(sessionId: string): Promise { + return this.call('remove', [sessionId]) + } + + async build(sessionId: string): Promise { + await this.closeTransportForLocalModelProxyChangeIfNeeded() + this.activeBuildSessionIds.add(sessionId) + try { + await this.call('build', [sessionId]) + } catch (error) { + this.activeBuildSessionIds.delete(sessionId) + throw error + } + } + + pause(sessionId: string): Promise { + return this.call('pause', [sessionId]) + } + + cancel(sessionId: string): Promise { + return this.call('cancel', [sessionId]) + } + + async rebuild(sessionId: string): Promise { + await this.closeTransportForLocalModelProxyChangeIfNeeded() + this.activeBuildSessionIds.add(sessionId) + try { + await this.call('rebuild', [sessionId]) + } catch (error) { + this.activeBuildSessionIds.delete(sessionId) + throw error + } + } + + async buildAllPending(): Promise { + await this.closeTransportForLocalModelProxyChangeIfNeeded() + this.hasUnknownActiveBuild = true + try { + await this.call('buildAllPending', []) + } catch (error) { + this.hasUnknownActiveBuild = false + throw error + } + } + + async listEnabledStatuses(): Promise { + const statuses = await this.callProbe('listEnabledStatuses', [], [], { + scheduleIdle: false, + }) + this.updateActiveBuildFromCompleteSnapshot(statuses) + this.scheduleIdleCloseIfNeeded() + return statuses + } + + async status(sessionId: string): Promise { + const status = await this.callProbe('status', [sessionId], null, { + scheduleIdle: false, + }) + this.updateActiveBuildFromPartialSnapshot([sessionId], status ? [status] : []) + this.scheduleIdleCloseIfNeeded() + return status + } + + async statusForSessions(sessionIds: string[]): Promise { + const statuses = await this.callProbe('statusForSessions', [sessionIds], [], { + scheduleIdle: false, + }) + this.updateActiveBuildFromPartialSnapshot(sessionIds, statuses) + this.scheduleIdleCloseIfNeeded() + return statuses + } + + async canSearch(sessionId: string): Promise { + if (!this.canRunFromLocalConfig()) return false + return await this.callProbe('canSearch', [sessionId], false) + } + + async search( + sessionId: string, + query: string, + options?: { finalTopK?: number; timeRangeMs?: { startTs?: number; endTs?: number } } + ): Promise { + await this.closeTransportForLocalModelProxyChangeIfNeeded() + return await this.call('search', [sessionId, query, options]) + } + + async searchForTool( + sessionId: string, + query: string, + options?: SemanticSearchToolOptions + ): Promise { + await this.closeTransportForLocalModelProxyChangeIfNeeded() + return await this.call('searchForTool', [sessionId, query, options]) + } + + cleanupUnused(): Promise<{ cleaned: number }> { + return this.call('cleanupUnused', []) + } + + recover(): Promise { + return this.call('recover', []) + } + + async close(): Promise { + this.clearIdleTimer() + await this.closeTransport() + } + + private async call(method: string, args: unknown[], options?: { scheduleIdle?: boolean }): Promise { + const transport = await this.ensureTransport() + this.pendingRequests++ + this.clearIdleTimer() + try { + return await transport.request(method, args) + } finally { + this.pendingRequests-- + if (options?.scheduleIdle !== false) this.scheduleIdleCloseIfNeeded() + } + } + + private async callProbe( + method: string, + args: unknown[], + fallback: T, + options?: { scheduleIdle?: boolean } + ): Promise { + try { + return await this.call(method, args, options) + } catch { + await this.closeFailedProbeTransport() + return fallback + } + } + + private async ensureTransport(): Promise { + if (!this.transport) { + const proxyUrl = await this.currentModelDownloadProxyUrl() + if (!this.transport) { + this.activeModelDownloadProxyUrl = proxyUrl + this.transport = this.transportFactory(proxyUrl) + } + } + return this.transport + } + + private async currentModelDownloadProxyUrl(): Promise { + const proxyUrl = (await this.getModelDownloadProxyUrl())?.trim() + return proxyUrl || undefined + } + + private async closeTransportForLocalModelProxyChangeIfNeeded(): Promise { + if (!this.transport) return + const config = this.configStore.get() + if (!this.configStore.canRun() || config.mode !== 'local') return + if (this.hasUnknownActiveBuild || this.activeBuildSessionIds.size > 0) return + if ((await this.currentModelDownloadProxyUrl()) === this.activeModelDownloadProxyUrl) return + await this.closeTransport() + } + + private canRunFromLocalConfig(): boolean { + const config = this.configStore.get() + if (!this.configStore.canRun()) return false + if (config.mode !== 'api') return true + if (isKeylessSemanticIndexApiBaseUrl(config.api?.baseUrl)) return true + return resolveSemanticIndexApiKeySet(config, this.resolveApiKey) + } + + private updateActiveBuildFromCompleteSnapshot(statuses: SemanticIndexSessionStatus[]): void { + this.hasUnknownActiveBuild = false + this.activeBuildSessionIds = new Set( + statuses.filter((status) => this.isActiveBuildStatus(status)).map((status) => status.sessionId) + ) + } + + private updateActiveBuildFromPartialSnapshot( + requestedSessionIds: string[], + statuses: SemanticIndexSessionStatus[] + ): void { + const bySessionId = new Map(statuses.map((status) => [status.sessionId, status])) + for (const sessionId of requestedSessionIds) { + const status = bySessionId.get(sessionId) + if (status && this.isActiveBuildStatus(status)) this.activeBuildSessionIds.add(sessionId) + else this.activeBuildSessionIds.delete(sessionId) + } + } + + private isActiveBuildStatus(status: SemanticIndexSessionStatus): boolean { + return status.running || status.queued || status.indexStatus === 'running' + } + + private clearActiveBuildTracking(): void { + this.hasUnknownActiveBuild = false + this.activeBuildSessionIds.clear() + } + + private scheduleIdleCloseIfNeeded(): void { + if ( + !this.transport || + this.pendingRequests > 0 || + this.hasUnknownActiveBuild || + this.activeBuildSessionIds.size > 0 + ) + return + this.clearIdleTimer() + this.idleTimer = this.timers.setTimeout(() => { + void this.closeTransport().catch((err) => { + console.warn('[semantic-index] idle worker close failed:', err instanceof Error ? err.message : String(err)) + }) + }, this.idleTimeoutMs) + } + + private clearIdleTimer(): void { + if (this.idleTimer) { + this.timers.clearTimeout(this.idleTimer) + this.idleTimer = null + } + } + + private async closeTransport(): Promise { + const transport = this.transport + this.transport = null + this.activeModelDownloadProxyUrl = undefined + this.clearActiveBuildTracking() + if (transport) await transport.close() + } + + private async closeFailedProbeTransport(): Promise { + this.clearIdleTimer() + try { + await this.closeTransport() + } catch { + // 语义索引探针失败只降级为不可用,不能打断普通 AI 对话流程。 + } + } +} + +export function createSemanticIndexWorkerClient(options: SemanticIndexWorkerClientOptions): SemanticIndexWorkerClient { + return new SemanticIndexWorkerClient(options) +} + +export function createSemanticIndexWorkerRuntimeClient( + options: SemanticIndexWorkerRuntimeClientOptions +): SemanticIndexWorkerClient { + const configStore = new SemanticIndexConfigStore( + path.join(options.pathProvider.getAiDataDir(), SEMANTIC_INDEX_CONFIG_FILE) + ) + return createSemanticIndexWorkerClient({ + configStore, + idleTimeoutMs: options.idleTimeoutMs, + resolveApiKey: options.resolveApiKey, + writeAuthProfile: options.writeAuthProfile, + getModelDownloadProxyUrl: options.getModelDownloadProxyUrl, + transportFactory: (modelDownloadProxyUrl) => + createSemanticIndexWorkerThreadTransport({ + workerEntryUrl: options.workerEntryUrl, + startup: { + paths: snapshotPathProvider(options.pathProvider), + runtime: options.runtime, + nativeBinding: options.nativeBinding, + sqliteVecLoadablePath: options.sqliteVecLoadablePath, + modelDownloadProxyUrl, + }, + }), + }) +} diff --git a/packages/node-runtime/src/semantic-index/worker-runtime.test.ts b/packages/node-runtime/src/semantic-index/worker-runtime.test.ts new file mode 100644 index 000000000..c881c53c8 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/worker-runtime.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + createSemanticIndexWorkerRuntime, + createSemanticIndexWorkerServiceFactory, + type SemanticIndexWorkerServiceFactory, +} from './worker-runtime' +import type { SemanticIndexRuntime } from './runtime' +import type { SemanticIndexWorkerStartupOptions } from './worker-runtime' + +class FakeSemanticIndexRuntime implements Partial { + statusCalls: string[] = [] + closed = false + + status(sessionId: string) { + this.statusCalls.push(sessionId) + return null + } + + close(): void { + this.closed = true + } + + recover(): void { + /* no-op */ + } +} + +function makeStartupOptions(logsDir: string): SemanticIndexWorkerStartupOptions { + return { + paths: { + systemDir: '/tmp/system', + userDataDir: '/tmp/data', + databaseDir: '/tmp/data/databases', + vectorDir: '/tmp/data/vector', + aiDataDir: '/tmp/system/ai', + settingsDir: '/tmp/system/settings', + cacheDir: '/tmp/system/cache', + tempDir: '/tmp/system/temp', + logsDir, + downloadsDir: '/tmp/downloads', + }, + runtime: { version: '0.0.0-test', kind: 'cli' }, + } +} + +test('worker runtime lazily creates service and forwards RPC calls', async () => { + const services: FakeSemanticIndexRuntime[] = [] + const factory: SemanticIndexWorkerServiceFactory = () => { + const service = new FakeSemanticIndexRuntime() + services.push(service) + return service as unknown as SemanticIndexRuntime + } + const runtime = createSemanticIndexWorkerRuntime({ serviceFactory: factory }) + + assert.equal(services.length, 0) + + const result = await runtime.handleRequest('status', ['session-a']) + + assert.equal(result, null) + assert.equal(services.length, 1) + assert.deepEqual(services[0].statusCalls, ['session-a']) + + await runtime.close() + + assert.equal(services[0].closed, true) +}) + +test('worker service factory initializes app logger with worker logs dir', () => { + const calls: string[] = [] + + createSemanticIndexWorkerServiceFactory(makeStartupOptions('/tmp/system/logs'), { + initLogger: (logsDir) => calls.push(logsDir), + }) + + assert.deepEqual(calls, ['/tmp/system/logs']) +}) diff --git a/packages/node-runtime/src/semantic-index/worker-runtime.ts b/packages/node-runtime/src/semantic-index/worker-runtime.ts new file mode 100644 index 000000000..81b474d20 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/worker-runtime.ts @@ -0,0 +1,95 @@ +import type { RuntimeIdentity } from '../data-dir-compat' +import { DatabaseManager } from '../database-manager' +import { initAppLogger } from '../logging/app-logger' +import { createDatabaseManagerAdapter } from '../services' +import { createSemanticIndexService } from './service' +import type { LoadSqliteVec } from './store' +import { StaticPathProvider, type StaticPathProviderSnapshot } from './static-path-provider' +import type { SemanticIndexRuntime } from './runtime' + +export interface SemanticIndexWorkerStartupOptions { + paths: StaticPathProviderSnapshot + runtime: RuntimeIdentity + nativeBinding?: string + sqliteVecLoadablePath?: string + modelDownloadProxyUrl?: string +} + +export type SemanticIndexWorkerServiceFactory = () => SemanticIndexRuntime + +export interface SemanticIndexWorkerServiceFactoryDeps { + initLogger?: (logsDir: string) => void +} + +export interface SemanticIndexWorkerRuntimeOptions { + serviceFactory: SemanticIndexWorkerServiceFactory +} + +export class SemanticIndexWorkerRuntime { + private service: SemanticIndexRuntime | null = null + private readonly options: SemanticIndexWorkerRuntimeOptions + + constructor(options: SemanticIndexWorkerRuntimeOptions) { + this.options = options + } + + async handleRequest(method: string, args: unknown[]): Promise { + if (method === '__close') { + await this.close() + return null + } + const service = this.ensureService() + const target = service as unknown as Record unknown> + const fn = target[method] + if (typeof fn !== 'function') throw new Error(`Unsupported semantic index worker method: ${method}`) + return await fn.apply(service, args) + } + + async close(): Promise { + const service = this.service + this.service = null + await service?.close() + } + + private ensureService(): SemanticIndexRuntime { + if (!this.service) { + this.service = this.options.serviceFactory() + void this.service.recover() + } + return this.service + } +} + +export function createSemanticIndexWorkerRuntime( + options: SemanticIndexWorkerRuntimeOptions +): SemanticIndexWorkerRuntime { + return new SemanticIndexWorkerRuntime(options) +} + +export function createSemanticIndexWorkerServiceFactory( + options: SemanticIndexWorkerStartupOptions, + deps: SemanticIndexWorkerServiceFactoryDeps = {} +): SemanticIndexWorkerServiceFactory { + const initializeLogger = deps.initLogger ?? initAppLogger + initializeLogger(options.paths.logsDir) + + return () => { + const pathProvider = new StaticPathProvider(options.paths) + const dbManager = new DatabaseManager(pathProvider, { + nativeBinding: options.nativeBinding, + runtime: options.runtime, + }) + const sessionAdapter = createDatabaseManagerAdapter(dbManager) + const loadSqliteVec: LoadSqliteVec | undefined = options.sqliteVecLoadablePath + ? (db) => db.loadExtension(options.sqliteVecLoadablePath!) + : undefined + + return createSemanticIndexService({ + pathProvider, + sessionAdapter, + nativeBinding: options.nativeBinding, + loadSqliteVec, + modelDownloadProxyUrl: options.modelDownloadProxyUrl, + }) + } +} diff --git a/packages/node-runtime/src/semantic-index/worker-thread-entry.ts b/packages/node-runtime/src/semantic-index/worker-thread-entry.ts new file mode 100644 index 000000000..3b4f3bc6b --- /dev/null +++ b/packages/node-runtime/src/semantic-index/worker-thread-entry.ts @@ -0,0 +1,31 @@ +import { parentPort, workerData } from 'node:worker_threads' +import type { SemanticIndexWorkerStartupOptions } from './worker-runtime' + +const startup = workerData as SemanticIndexWorkerStartupOptions + +async function main(): Promise { + const { createSemanticIndexWorkerRuntime, createSemanticIndexWorkerServiceFactory } = + (await import('./worker-runtime.js')) as typeof import('./worker-runtime') + const runtime = createSemanticIndexWorkerRuntime({ + serviceFactory: createSemanticIndexWorkerServiceFactory(startup), + }) + + parentPort?.on('message', async (message) => { + const payload = message as { id: string; method: string; args?: unknown[] } + try { + const result = await runtime.handleRequest(payload.method, payload.args ?? []) + parentPort?.postMessage({ id: payload.id, success: true, result }) + } catch (error) { + parentPort?.postMessage({ + id: payload.id, + success: false, + error: error instanceof Error ? error.message : String(error), + }) + } + }) +} + +void main().catch((error) => { + console.error('[semantic-index] worker failed to start:', error instanceof Error ? error.message : String(error)) + throw error +}) diff --git a/packages/node-runtime/src/semantic-index/worker-thread-transport.test.ts b/packages/node-runtime/src/semantic-index/worker-thread-transport.test.ts new file mode 100644 index 000000000..f1be20b23 --- /dev/null +++ b/packages/node-runtime/src/semantic-index/worker-thread-transport.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import test from 'node:test' +import { createSemanticIndexWorkerThreadTransport, type SemanticIndexWorkerLike } from './worker-thread-transport' + +class FakeWorker extends EventEmitter implements SemanticIndexWorkerLike { + messages: unknown[] = [] + terminated = false + + postMessage(message: unknown): void { + this.messages.push(message) + } + + async terminate(): Promise { + this.terminated = true + return 0 + } +} + +test('worker thread transport resolves matching RPC response and terminates on close', async () => { + const worker = new FakeWorker() + const transport = createSemanticIndexWorkerThreadTransport({ + startup: { + paths: { + systemDir: '/tmp/system', + userDataDir: '/tmp/data', + databaseDir: '/tmp/data/databases', + vectorDir: '/tmp/data/vector', + aiDataDir: '/tmp/system/ai', + settingsDir: '/tmp/system/settings', + cacheDir: '/tmp/system/cache', + tempDir: '/tmp/system/temp', + logsDir: '/tmp/system/logs', + downloadsDir: '/tmp/downloads', + }, + runtime: { version: '0.0.0-test', kind: 'cli' }, + }, + workerFactory: () => worker, + }) + + const pending = transport.request('status', ['session-a']) + const message = worker.messages[0] as { id: string; method: string; args: unknown[] } + + assert.equal(message.method, 'status') + assert.deepEqual(message.args, ['session-a']) + + worker.emit('message', { id: message.id, success: true, result: 'ok' }) + + assert.equal(await pending, 'ok') + + const closing = transport.close() + const closeMessage = worker.messages[1] as { id: string; method: string } + assert.equal(closeMessage.method, '__close') + worker.emit('message', { id: closeMessage.id, success: true }) + await closing + + assert.equal(worker.terminated, true) +}) + +test('worker thread transport asks runtime to close before terminating worker', async () => { + const worker = new FakeWorker() + const transport = createSemanticIndexWorkerThreadTransport({ + startup: { + paths: { + systemDir: '/tmp/system', + userDataDir: '/tmp/data', + databaseDir: '/tmp/data/databases', + vectorDir: '/tmp/data/vector', + aiDataDir: '/tmp/system/ai', + settingsDir: '/tmp/system/settings', + cacheDir: '/tmp/system/cache', + tempDir: '/tmp/system/temp', + logsDir: '/tmp/system/logs', + downloadsDir: '/tmp/downloads', + }, + runtime: { version: '0.0.0-test', kind: 'cli' }, + }, + workerFactory: () => worker, + }) + + const closing = transport.close() + const message = worker.messages[0] as { id: string; method: string } + + assert.equal(message.method, '__close') + assert.equal(worker.terminated, false) + + worker.emit('message', { id: message.id, success: true }) + await closing + + assert.equal(worker.terminated, true) +}) + +test('worker thread transport terminates worker when runtime close does not reply', async () => { + const worker = new FakeWorker() + const transport = createSemanticIndexWorkerThreadTransport({ + startup: { + paths: { + systemDir: '/tmp/system', + userDataDir: '/tmp/data', + databaseDir: '/tmp/data/databases', + vectorDir: '/tmp/data/vector', + aiDataDir: '/tmp/system/ai', + settingsDir: '/tmp/system/settings', + cacheDir: '/tmp/system/cache', + tempDir: '/tmp/system/temp', + logsDir: '/tmp/system/logs', + downloadsDir: '/tmp/downloads', + }, + runtime: { version: '0.0.0-test', kind: 'cli' }, + }, + workerFactory: () => worker, + closeTimeoutMs: 1, + }) + + await transport.close() + + const message = worker.messages[0] as { method: string } + assert.equal(message.method, '__close') + assert.equal(worker.terminated, true) +}) diff --git a/packages/node-runtime/src/semantic-index/worker-thread-transport.ts b/packages/node-runtime/src/semantic-index/worker-thread-transport.ts new file mode 100644 index 000000000..eebaa3b6b --- /dev/null +++ b/packages/node-runtime/src/semantic-index/worker-thread-transport.ts @@ -0,0 +1,133 @@ +import { Worker } from 'node:worker_threads' +import type { WorkerOptions } from 'node:worker_threads' +import type { SemanticIndexWorkerTransport } from './worker-client' +import type { SemanticIndexWorkerStartupOptions } from './worker-runtime' + +export interface SemanticIndexWorkerLike { + postMessage(message: unknown): void + terminate(): Promise + on(event: 'message', listener: (message: unknown) => void): this + on(event: 'error', listener: (error: Error) => void): this + on(event: 'exit', listener: (code: number) => void): this +} + +export interface SemanticIndexWorkerThreadTransportOptions { + startup: SemanticIndexWorkerStartupOptions + workerFactory?: () => SemanticIndexWorkerLike + workerEntryUrl?: string | URL + closeTimeoutMs?: number +} + +interface PendingRequest { + resolve: (value: unknown) => void + reject: (error: Error) => void +} + +const DEFAULT_CLOSE_TIMEOUT_MS = 3_000 + +type ModuleWorkerOptions = WorkerOptions & { type: 'module' } + +function defaultWorkerEntryUrl(): URL { + return import.meta.url.endsWith('.ts') + ? new URL('./worker-thread-entry.ts', import.meta.url) + : new URL('./worker-thread-entry.js', import.meta.url) +} + +function normalizeWorkerEntryUrl(entryUrl?: string | URL): URL { + if (!entryUrl) return defaultWorkerEntryUrl() + return typeof entryUrl === 'string' ? new URL(entryUrl) : entryUrl +} + +function createDefaultWorker( + startup: SemanticIndexWorkerStartupOptions, + entryUrlInput?: string | URL +): SemanticIndexWorkerLike { + const entryUrl = normalizeWorkerEntryUrl(entryUrlInput) + if (!entryUrl.href.endsWith('.ts')) { + return new Worker(entryUrl, { workerData: startup }) as SemanticIndexWorkerLike + } + + const bootstrap = ` + import { register } from 'tsx/esm/api'; + register(); + await import(${JSON.stringify(entryUrl.href)}); + ` + const options: ModuleWorkerOptions = { + eval: true, + type: 'module', + workerData: startup, + execArgv: [], + } + return new Worker(bootstrap, options) as SemanticIndexWorkerLike +} + +export class SemanticIndexWorkerThreadTransport implements SemanticIndexWorkerTransport { + private worker: SemanticIndexWorkerLike + private pending = new Map() + private requestId = 0 + private readonly closeTimeoutMs: number + + constructor(options: SemanticIndexWorkerThreadTransportOptions) { + this.closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS + this.worker = options.workerFactory?.() ?? createDefaultWorker(options.startup, options.workerEntryUrl) + + this.worker.on('message', (message) => this.handleMessage(message)) + this.worker.on('error', (error) => this.rejectAll(error)) + this.worker.on('exit', (code) => { + if (code !== 0) this.rejectAll(new Error(`Semantic index worker exited with code ${code}`)) + }) + } + + request(method: string, args: unknown[]): Promise { + const id = `si_${++this.requestId}` + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject }) + this.worker.postMessage({ id, method, args }) + }) + } + + async close(): Promise { + await this.requestRuntimeClose() + await this.worker.terminate() + this.rejectAll(new Error('Semantic index worker closed')) + } + + private async requestRuntimeClose(): Promise { + let timeout: ReturnType | null = null + const timeoutPromise = new Promise((resolve) => { + timeout = setTimeout(resolve, this.closeTimeoutMs) + }) + + try { + // 先让 worker 内的 service 正常 close,避免 SQLite/vector 资源被直接中断;超时后仍会 terminate。 + await Promise.race([this.request('__close', []).then(() => undefined), timeoutPromise]) + } catch { + // Worker may already be gone; terminate below still clears local state. + } finally { + if (timeout) clearTimeout(timeout) + } + } + + private handleMessage(message: unknown): void { + const payload = message as { id?: string; success?: boolean; result?: unknown; error?: string } + if (!payload.id) return + const pending = this.pending.get(payload.id) + if (!pending) return + this.pending.delete(payload.id) + if (payload.success) pending.resolve(payload.result) + else pending.reject(new Error(payload.error ?? 'Semantic index worker request failed')) + } + + private rejectAll(error: Error): void { + for (const [id, pending] of this.pending.entries()) { + this.pending.delete(id) + pending.reject(error) + } + } +} + +export function createSemanticIndexWorkerThreadTransport( + options: SemanticIndexWorkerThreadTransportOptions +): SemanticIndexWorkerThreadTransport { + return new SemanticIndexWorkerThreadTransport(options) +} diff --git a/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts b/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts new file mode 100644 index 000000000..80b70a834 --- /dev/null +++ b/packages/node-runtime/src/services/adapters/database-manager-adapter.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { PathProvider } from '@openchatlab/core' +import { DatabaseManager } from '../../database-manager' +import { createDatabaseManagerAdapter } from './database-manager-adapter' +import { getContactsFactsCacheDir } from '../contacts/paths' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-db-adapter-')) +} + +function createPathProvider(root: string): PathProvider { + return { + getSystemDir: () => root, + getUserDataDir: () => path.join(root, 'data'), + getDatabaseDir: () => path.join(root, 'data', 'databases'), + getVectorDir: () => path.join(root, 'data', 'vector'), + getAiDataDir: () => path.join(root, 'ai'), + getSettingsDir: () => path.join(root, 'settings'), + getCacheDir: () => path.join(root, 'cache'), + getTempDir: () => path.join(root, 'temp'), + getLogsDir: () => path.join(root, 'logs'), + getDownloadsDir: () => path.join(root, 'downloads'), + } +} + +test('DatabaseManager adapter deletes session database sidecar files and caches', () => { + const root = makeTempDir() + const pathProvider = createPathProvider(root) + const manager = new DatabaseManager(pathProvider, { allowMissingRuntimeForTests: true }) + const adapter = createDatabaseManagerAdapter(manager) + + const sessionId = 'synced-session' + const dbPath = manager.getDbPath(sessionId) + fs.mkdirSync(path.dirname(dbPath), { recursive: true }) + for (const suffix of ['', '-wal', '-shm']) { + fs.writeFileSync(dbPath + suffix, 'stale-data', 'utf-8') + } + + const cachePath = path.join(pathProvider.getCacheDir(), `${sessionId}.cache.json`) + const queryCachePath = path.join(pathProvider.getCacheDir(), 'query', `${sessionId}.cache.json`) + const contactsFactsCachePath = path.join( + getContactsFactsCacheDir(pathProvider.getUserDataDir()), + `${sessionId}.cache.json` + ) + fs.mkdirSync(path.dirname(cachePath), { recursive: true }) + fs.mkdirSync(path.dirname(queryCachePath), { recursive: true }) + fs.mkdirSync(path.dirname(contactsFactsCachePath), { recursive: true }) + fs.writeFileSync(cachePath, '{}', 'utf-8') + fs.writeFileSync(queryCachePath, '{}', 'utf-8') + fs.writeFileSync(contactsFactsCachePath, '{}', 'utf-8') + + assert.equal(adapter.deleteSessionFile(sessionId), true) + assert.equal(fs.existsSync(dbPath), false) + assert.equal(fs.existsSync(dbPath + '-wal'), false) + assert.equal(fs.existsSync(dbPath + '-shm'), false) + assert.equal(fs.existsSync(cachePath), false) + assert.equal(fs.existsSync(queryCachePath), false) + assert.equal(fs.existsSync(contactsFactsCachePath), false) +}) diff --git a/packages/node-runtime/src/services/adapters/database-manager-adapter.ts b/packages/node-runtime/src/services/adapters/database-manager-adapter.ts new file mode 100644 index 000000000..d1316db6b --- /dev/null +++ b/packages/node-runtime/src/services/adapters/database-manager-adapter.ts @@ -0,0 +1,40 @@ +/** + * DatabaseManager adapter for the shared service layer. + * + * Bridges DatabaseManager's connection lifecycle to the SessionRuntimeAdapter + * interface expected by session/member services. + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import type { DatabaseManager } from '../../database-manager' +import type { SessionRuntimeAdapter } from './types' + +export function createDatabaseManagerAdapter(dbManager: DatabaseManager): SessionRuntimeAdapter { + return { + listSessionIds: () => dbManager.listSessionIds(), + openReadonly: (sessionId) => dbManager.open(sessionId), + openWritable: (sessionId) => dbManager.openWritable(sessionId), + closeSession: (sessionId) => dbManager.close(sessionId), + getDbPath: (sessionId) => dbManager.getDbPath(sessionId), + + deleteSessionFile(sessionId: string): boolean { + return dbManager.deleteSessionDatabaseFiles(sessionId) + }, + + ensureReadonly(sessionId: string): DatabaseAdapter { + const db = dbManager.open(sessionId) + if (!db) { + throw Object.assign(new Error(`Session not found: ${sessionId}`), { statusCode: 404 }) + } + return db + }, + + ensureWritable(sessionId: string): DatabaseAdapter { + const db = dbManager.openWritable(sessionId) + if (!db) { + throw Object.assign(new Error(`Session not found: ${sessionId}`), { statusCode: 404 }) + } + return db + }, + } +} diff --git a/packages/node-runtime/src/services/adapters/index.ts b/packages/node-runtime/src/services/adapters/index.ts new file mode 100644 index 000000000..584596205 --- /dev/null +++ b/packages/node-runtime/src/services/adapters/index.ts @@ -0,0 +1,2 @@ +export type { SessionRuntimeAdapter } from './types' +export { createDatabaseManagerAdapter } from './database-manager-adapter' diff --git a/packages/node-runtime/src/services/adapters/types.ts b/packages/node-runtime/src/services/adapters/types.ts new file mode 100644 index 000000000..ae3e84be8 --- /dev/null +++ b/packages/node-runtime/src/services/adapters/types.ts @@ -0,0 +1,25 @@ +/** + * Adapter interfaces for the shared service layer. + * + * Services depend on these interfaces instead of concrete DatabaseManager or + * Electron worker implementations — making them reusable across CLI Web and Electron. + */ + +import type { DatabaseAdapter } from '@openchatlab/core' + +export interface SessionRuntimeAdapter { + listSessionIds(): string[] + openReadonly(sessionId: string): DatabaseAdapter | null + openWritable(sessionId: string): DatabaseAdapter | null + closeSession(sessionId: string): void + getDbPath(sessionId: string): string + + /** Delete the session database files and cache. Returns false if not found. */ + deleteSessionFile(sessionId: string): boolean + + /** Open readonly, throw 404 if not found. */ + ensureReadonly(sessionId: string): DatabaseAdapter + + /** Open writable (with auto-migration), throw 404 if not found. */ + ensureWritable(sessionId: string): DatabaseAdapter +} diff --git a/packages/node-runtime/src/services/analytics.test.ts b/packages/node-runtime/src/services/analytics.test.ts new file mode 100644 index 000000000..bcef3d253 --- /dev/null +++ b/packages/node-runtime/src/services/analytics.test.ts @@ -0,0 +1,79 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AnalyticsService } from './analytics' + +const APP_KEY = 'app-US-test' + +function createTempSystemDir(): string { + return mkdtempSync(join(tmpdir(), 'chatlab-analytics-')) +} + +function readAnalyticsData(systemDir: string): { + firstReportDate?: string | null + lastReportDate?: string | null +} { + return JSON.parse(readFileSync(join(systemDir, 'analytics.json'), 'utf-8')) +} + +test('trackDailyActive does not mark the day as reported when fetch rejects', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + const originalConsoleError = console.error + globalThis.fetch = (() => Promise.reject(new Error('offline'))) as typeof fetch + console.error = () => {} + + try { + await new AnalyticsService(systemDir, APP_KEY, '1.0.0').trackDailyActive() + + assert.equal(existsSync(join(systemDir, 'analytics.json')), false) + } finally { + console.error = originalConsoleError + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('trackDailyActive does not mark the day as reported when Aptabase returns non-OK', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + globalThis.fetch = (() => Promise.resolve(new Response(null, { status: 500 }))) as typeof fetch + + try { + await new AnalyticsService(systemDir, APP_KEY, '1.0.0').trackDailyActive() + + assert.equal(existsSync(join(systemDir, 'analytics.json')), false) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) + +test('trackDailyActive marks the day as reported after a successful post', async () => { + const systemDir = createTempSystemDir() + const originalFetch = globalThis.fetch + const requests: Array<{ url: string; init?: RequestInit }> = [] + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), init }) + return Promise.resolve(new Response(null, { status: 204 })) + }) as typeof fetch + + try { + await new AnalyticsService(systemDir, APP_KEY, '1.0.0').trackDailyActive({ platform: 'cli-web' }) + + const today = new Date().toISOString().slice(0, 10) + assert.equal(requests.length, 1) + assert.equal(requests[0].url, 'https://us.aptabase.com/api/v0/event') + assert.equal(JSON.parse(String(requests[0].init?.body)).eventName, 'app_active_new') + assert.deepEqual(readAnalyticsData(systemDir), { + enabled: true, + firstReportDate: today, + lastReportDate: today, + }) + } finally { + globalThis.fetch = originalFetch + rmSync(systemDir, { recursive: true, force: true }) + } +}) diff --git a/packages/node-runtime/src/services/analytics.ts b/packages/node-runtime/src/services/analytics.ts new file mode 100644 index 000000000..00fc34888 --- /dev/null +++ b/packages/node-runtime/src/services/analytics.ts @@ -0,0 +1,112 @@ +import * as fs from 'fs' +import * as path from 'path' + +const REGIONS: Record = { + US: 'https://us.aptabase.com', + EU: 'https://eu.aptabase.com', +} + +interface AnalyticsData { + lastReportDate: string | null + firstReportDate: string | null + enabled: boolean +} + +const DEFAULT_DATA: AnalyticsData = { lastReportDate: null, firstReportDate: null, enabled: true } + +export class AnalyticsService { + private readonly dataPath: string + private readonly endpoint: string | null + private readonly appKey: string + private readonly appVersion: string + private readonly sessionId: string + + constructor(systemDir: string, appKey: string, appVersion: string) { + this.dataPath = path.join(systemDir, 'analytics.json') + this.appKey = appKey + this.appVersion = appVersion + this.sessionId = `${Math.floor(Date.now() / 1000)}${Math.floor(Math.random() * 1e8) + .toString() + .padStart(8, '0')}` + const parts = appKey.split('-') + const base = parts.length === 3 ? REGIONS[parts[1]] : undefined + this.endpoint = base ? `${base}/api/v0/event` : null + } + + private load(): AnalyticsData { + try { + if (fs.existsSync(this.dataPath)) { + return { ...DEFAULT_DATA, ...JSON.parse(fs.readFileSync(this.dataPath, 'utf-8')) } + } + } catch (_e) { + // Corrupted or unreadable file — fall back to defaults + } + return { ...DEFAULT_DATA } + } + + private save(data: AnalyticsData): void { + try { + fs.writeFileSync(this.dataPath, JSON.stringify(data, null, 2), 'utf-8') + } catch (e) { + console.error('[Analytics] Failed to save:', e) + } + } + + getEnabled(): boolean { + return this.load().enabled + } + + setEnabled(enabled: boolean): void { + const data = this.load() + data.enabled = enabled + this.save(data) + } + + async track(eventName: string, props?: Record): Promise { + if (!this.endpoint) return false + if (!this.getEnabled()) return false + try { + const resp = await fetch(this.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'App-Key': this.appKey }, + body: JSON.stringify({ + timestamp: new Date().toISOString(), + sessionId: this.sessionId, + eventName, + systemProps: { + isDebug: false, + locale: '', + osName: process.platform, + osVersion: '', + engineName: 'Node.js', + engineVersion: process.versions.node, + appVersion: this.appVersion, + sdkVersion: 'chatlab@1', + }, + props, + }), + }) + return resp.ok + } catch (e) { + console.error(`[Analytics] Failed to track ${eventName}:`, e) + return false + } + } + + async trackDailyActive(props?: Record): Promise { + if (!this.endpoint) return + const data = this.load() + if (!data.enabled) return + const today = new Date().toISOString().slice(0, 10) + const isNew = data.firstReportDate === null + if (isNew) data.firstReportDate = today + if (data.lastReportDate === today) { + if (isNew) this.save(data) + return + } + const tracked = await this.track(isNew ? 'app_active_new' : 'app_active', props) + if (!tracked) return + data.lastReportDate = today + this.save(data) + } +} diff --git a/packages/node-runtime/src/services/contacts/compute.ts b/packages/node-runtime/src/services/contacts/compute.ts new file mode 100644 index 000000000..0640886e1 --- /dev/null +++ b/packages/node-runtime/src/services/contacts/compute.ts @@ -0,0 +1,605 @@ +import { ChatType } from '@openchatlab/shared-types' +import type { + ChatPlatform, + ContactItem, + ContactsDiagnostics, + ContactsTimeRangePreset, + ContactsTimeRangeState, + ContactSourceSession, +} from '@openchatlab/shared-types' +import { + MIN_PRIVATE_SESSIONS_FOR_CONTACTS, + computeFriendScores, + computeNonFriendScores, + getGroupContactFacts, + getLatestContactMessageTs, + getPrivateContactFacts, + getSessionMeta, + isChatSessionDb, + isNameMatchPlatform, + resolveOwnerMember, +} from '@openchatlab/core' +import type { ContactMemberRef, SessionMeta } from '@openchatlab/core' +import { getDbFileVersion } from '../../cache/analytics-cache' +import { appLogger } from '../../logging/app-logger' +import type { SessionRuntimeAdapter } from '../adapters' +import { + buildContactsSessionFactsCacheKey, + buildContactsSessionLatestCacheKey, + createEmptyContactsFactsCacheStats, + readCachedContactsSessionFacts, + readCachedContactsSessionLatest, + writeCachedContactsSessionFacts, + writeCachedContactsSessionLatest, + type ContactsCachedGroupFacts, + type ContactsCachedPrivateFacts, + type ContactsFactsCacheStats, + type ContactsSessionFacts, + type ContactsSessionLatestFacts, + type ContactsSessionMetaFacts, +} from './facts-cache' +import { resolveContactsTimeRange } from './time-range' + +export const CONTACTS_ALGORITHM_VERSION = 'contacts-v2' + +export interface ContactsWorkerStats { + durationMs: number + totalSessions: number + processedSessions: number + skippedFailedSessions: number +} + +export interface ContactsSnapshot { + contacts: ContactItem[] + diagnostics: ContactsDiagnostics + algorithmVersion: string + signature: string + timeRange: ContactsTimeRangeState + computedAt: number + workerStats: ContactsWorkerStats +} + +export interface ContactsComputeProgress { + processedSessions: number + totalSessions: number + currentSessionId?: string +} + +export interface ComputeContactsSnapshotOptions { + adapter: SessionRuntimeAdapter + signature: string + timeRangePreset?: ContactsTimeRangePreset + factsCacheDir?: string + now?: () => number + onProgress?: (progress: ContactsComputeProgress) => void +} + +interface ContactAccumulator { + key: string + platform: ChatPlatform + platformId: string + sessionScoped: boolean + sessionId?: string + displayName: string + aliases: Set + avatar: string | null + isFriend: boolean + privateMessageCount: number + activePrivateMonths: Set + commonGroupSessionIds: Set + coOccurrenceCount: number + coOccurrenceRawScore: number + replyInteractionCount: number + repliesFromOwnerToContact: number + repliesFromContactToOwner: number + sourceSessions: ContactSourceSession[] + lastInteractionTs: number | null +} + +interface BuildContactsResult { + contacts: ContactItem[] + diagnostics: ContactsDiagnostics +} + +interface ContactsFactsCacheContext { + dir: string + latestKey: string + stats: ContactsFactsCacheStats +} + +export function computeContactsSnapshot(options: ComputeContactsSnapshotOptions): ContactsSnapshot { + const startedAt = options.now?.() ?? Date.now() + const sessionIds = options.adapter.listSessionIds() + const factsCache = options.factsCacheDir ? createFactsCacheContext(options.factsCacheDir) : null + const timeRange = resolveContactsTimeRange( + options.timeRangePreset, + findGlobalLatestMessageTs(options.adapter, sessionIds, factsCache) + ) + const result = computeContacts({ + adapter: options.adapter, + sessionIds, + timeRange, + factsCache, + onProgress: options.onProgress, + }) + const finishedAt = options.now?.() ?? Date.now() + if (factsCache) { + appLogger.info('contacts', 'contacts session facts cache summary', factsCache.stats) + } + return { + ...result, + algorithmVersion: CONTACTS_ALGORITHM_VERSION, + signature: options.signature, + timeRange, + computedAt: finishedAt, + workerStats: { + durationMs: Math.max(0, finishedAt - startedAt), + totalSessions: sessionIds.length, + processedSessions: sessionIds.length, + skippedFailedSessions: result.diagnostics.skippedFailedSessions, + }, + } +} + +function computeContacts(options: { + adapter: SessionRuntimeAdapter + sessionIds: string[] + timeRange: ContactsTimeRangeState + factsCache: ContactsFactsCacheContext | null + onProgress?: (progress: ContactsComputeProgress) => void +}): BuildContactsResult { + const diagnostics = createEmptyDiagnostics() + const accumulators = new Map() + let processedSessions = 0 + + for (const sessionId of options.sessionIds) { + options.onProgress?.({ processedSessions, totalSessions: options.sessionIds.length, currentSessionId: sessionId }) + try { + const facts = getSessionFacts(options.adapter, sessionId, options.timeRange, options.factsCache) + applySessionFacts(accumulators, diagnostics, sessionId, facts) + } catch (error) { + diagnostics.skippedFailedSessions++ + appLogger.error('contacts', `failed to process contact session: ${sessionId}`, error) + } finally { + processedSessions++ + options.onProgress?.({ processedSessions, totalSessions: options.sessionIds.length, currentSessionId: sessionId }) + } + } + + diagnostics.contactsEnabled = diagnostics.activePrivateSessionCount > MIN_PRIVATE_SESSIONS_FOR_CONTACTS + const contacts = buildContactItems([...accumulators.values()]) + return { contacts, diagnostics } +} + +function findGlobalLatestMessageTs( + adapter: SessionRuntimeAdapter, + sessionIds: string[], + factsCache: ContactsFactsCacheContext | null +): number | null { + let latest: number | null = null + for (const sessionId of sessionIds) { + const dbVersion = getSessionDbVersion(adapter, sessionId) + const cached = readLatestFacts(sessionId, factsCache, dbVersion) + if (cached) { + if (cached.latestMessageTs !== null) latest = Math.max(latest ?? 0, cached.latestMessageTs) + continue + } + try { + const db = adapter.openReadonly(sessionId) + const ts = db && isChatSessionDb(db) ? getLatestContactMessageTs(db) : null + writeLatestFacts(adapter, sessionId, factsCache, { latestMessageTs: ts }, dbVersion) + if (ts !== null) latest = Math.max(latest ?? 0, ts) + } catch (error) { + appLogger.error('contacts', `failed to inspect contact session range: ${sessionId}`, error) + } + } + return latest +} + +function getSessionFacts( + adapter: SessionRuntimeAdapter, + sessionId: string, + timeRange: ContactsTimeRangeState, + factsCache: ContactsFactsCacheContext | null +): ContactsSessionFacts { + const dbVersion = getSessionDbVersion(adapter, sessionId) + const cached = readSessionFacts(sessionId, timeRange, factsCache, dbVersion) + if (cached) return cached + + const facts = computeSessionFacts(adapter, sessionId, timeRange) + writeSessionFacts(adapter, sessionId, timeRange, factsCache, facts, dbVersion) + return facts +} + +function computeSessionFacts( + adapter: SessionRuntimeAdapter, + sessionId: string, + timeRange: ContactsTimeRangeState +): ContactsSessionFacts { + const db = adapter.openReadonly(sessionId) + if (!db || !isChatSessionDb(db)) return { kind: 'not_chat_db', latestMessageTs: null } + + const latestMessageTs = getLatestContactMessageTs(db) + const meta = getSessionMeta(db) + if (!meta) return { kind: 'missing_meta', latestMessageTs } + if (meta.type !== ChatType.PRIVATE && meta.type !== ChatType.GROUP) + return { kind: 'unsupported_type', latestMessageTs } + + const cachedMeta = toContactsSessionMetaFacts(meta) + if (!meta.ownerId?.trim()) return { kind: 'missing_owner', meta: cachedMeta, latestMessageTs } + + const owner = resolveOwnerMember(db) + if (!owner) return { kind: 'unresolved_owner', meta: cachedMeta, latestMessageTs } + + if (meta.type === ChatType.PRIVATE) { + const facts = getPrivateContactFacts(db, owner.id, { startTs: timeRange.startTs }) + if (facts.type === 'missing') return { kind: 'private_missing', meta: cachedMeta, latestMessageTs } + if (facts.type === 'ambiguous') return { kind: 'private_ambiguous', meta: cachedMeta, latestMessageTs } + return { kind: 'private', meta: cachedMeta, latestMessageTs, facts } + } + + return { + kind: 'group', + meta: cachedMeta, + latestMessageTs, + facts: getGroupContactFacts(db, owner.id, { startTs: timeRange.startTs }), + } +} + +function applySessionFacts( + accumulators: Map, + diagnostics: ContactsDiagnostics, + sessionId: string, + sessionFacts: ContactsSessionFacts +): void { + if ('meta' in sessionFacts && sessionFacts.meta.type === ChatType.PRIVATE) diagnostics.privateSessionCount++ + + switch (sessionFacts.kind) { + case 'missing_owner': + diagnostics.skippedMissingOwnerSessions++ + return + case 'unresolved_owner': + diagnostics.skippedUnresolvedOwnerSessions++ + return + case 'private_ambiguous': + diagnostics.skippedAmbiguousPrivateSessions++ + return + case 'private': + applyPrivateFacts(accumulators, diagnostics, sessionId, sessionFacts.meta, sessionFacts.facts) + return + case 'group': + applyGroupFacts(accumulators, sessionId, sessionFacts.meta, sessionFacts.facts) + return + default: + return + } +} + +function applyPrivateFacts( + accumulators: Map, + diagnostics: ContactsDiagnostics, + sessionId: string, + meta: ContactsSessionMetaFacts, + facts: ContactsCachedPrivateFacts +): void { + if (facts.privateMessageCount <= 0) return + diagnostics.activePrivateSessionCount++ + + const acc = getOrCreateAccumulator(accumulators, sessionId, meta, facts.contact) + acc.isFriend = true + acc.privateMessageCount += facts.privateMessageCount + for (const month of facts.activeMonths) acc.activePrivateMonths.add(month) + updateLastInteraction(acc, facts.lastMessageTs) + acc.sourceSessions.push({ + id: sessionId, + name: meta.name, + platform: meta.platform, + type: ChatType.PRIVATE, + messageCount: facts.privateMessageCount, + privateMessageCount: facts.privateMessageCount, + lastMessageTs: facts.lastMessageTs, + }) +} + +function applyGroupFacts( + accumulators: Map, + sessionId: string, + meta: ContactsSessionMetaFacts, + sessionFacts: ContactsCachedGroupFacts[] +): void { + for (const facts of sessionFacts) { + if (!hasGroupContactSignal(facts)) continue + const acc = getOrCreateAccumulator(accumulators, sessionId, meta, facts.contact) + acc.commonGroupSessionIds.add(sessionId) + acc.coOccurrenceCount += facts.coOccurrenceCount + acc.coOccurrenceRawScore += facts.coOccurrenceRawScore + acc.replyInteractionCount += facts.replyInteractionCount + acc.repliesFromOwnerToContact += facts.repliesFromOwnerToContact + acc.repliesFromContactToOwner += facts.repliesFromContactToOwner + updateLastInteraction(acc, facts.lastInteractionTs) + acc.sourceSessions.push({ + id: sessionId, + name: meta.name, + platform: meta.platform, + type: ChatType.GROUP, + messageCount: facts.messageCount, + coOccurrenceCount: facts.coOccurrenceCount, + coOccurrenceRawScore: facts.coOccurrenceRawScore, + replyInteractionCount: facts.replyInteractionCount, + repliesFromOwnerToContact: facts.repliesFromOwnerToContact, + repliesFromContactToOwner: facts.repliesFromContactToOwner, + lastInteractionTs: facts.lastInteractionTs, + }) + } +} + +function hasGroupContactSignal(facts: ContactsCachedGroupFacts): boolean { + return facts.messageCount > 0 || facts.coOccurrenceCount > 0 || facts.replyInteractionCount > 0 +} + +function createFactsCacheContext(dir: string): ContactsFactsCacheContext { + return { + dir, + latestKey: buildContactsSessionLatestCacheKey(CONTACTS_ALGORITHM_VERSION), + stats: createEmptyContactsFactsCacheStats(), + } +} + +function readLatestFacts( + sessionId: string, + factsCache: ContactsFactsCacheContext | null, + dbVersion: string +): ContactsSessionLatestFacts | null { + if (!factsCache) return null + const cached = readCachedContactsSessionLatest(sessionId, factsCache.dir, factsCache.latestKey, dbVersion) + if (!cached.hit) { + factsCache.stats.latestMisses++ + return null + } + factsCache.stats.latestHits++ + return cached.data +} + +function writeLatestFacts( + adapter: SessionRuntimeAdapter, + sessionId: string, + factsCache: ContactsFactsCacheContext | null, + data: ContactsSessionLatestFacts, + expectedDbVersion: string +): void { + if (!factsCache) return + const dbVersion = getSessionDbVersion(adapter, sessionId) + if (dbVersion !== expectedDbVersion) { + appLogger.debug('contacts', 'skipped contacts latest facts cache write because db version changed', { + sessionId, + }) + return + } + writeCachedContactsSessionLatest(sessionId, factsCache.dir, factsCache.latestKey, dbVersion, data) + factsCache.stats.writes++ +} + +function readSessionFacts( + sessionId: string, + timeRange: ContactsTimeRangeState, + factsCache: ContactsFactsCacheContext | null, + dbVersion: string +): ContactsSessionFacts | null { + if (!factsCache) return null + const cached = readCachedContactsSessionFacts( + sessionId, + factsCache.dir, + buildContactsSessionFactsCacheKey(CONTACTS_ALGORITHM_VERSION, timeRange), + dbVersion + ) + if (!cached.hit) { + factsCache.stats.factsMisses++ + return null + } + factsCache.stats.factsHits++ + return cached.data +} + +function writeSessionFacts( + adapter: SessionRuntimeAdapter, + sessionId: string, + timeRange: ContactsTimeRangeState, + factsCache: ContactsFactsCacheContext | null, + facts: ContactsSessionFacts, + expectedDbVersion: string +): void { + if (!factsCache) return + const dbVersion = getSessionDbVersion(adapter, sessionId) + if (dbVersion !== expectedDbVersion) { + appLogger.debug('contacts', 'skipped contacts session facts cache write because db version changed', { + sessionId, + }) + return + } + writeCachedContactsSessionFacts( + sessionId, + factsCache.dir, + buildContactsSessionFactsCacheKey(CONTACTS_ALGORITHM_VERSION, timeRange), + dbVersion, + facts + ) + writeCachedContactsSessionLatest(sessionId, factsCache.dir, factsCache.latestKey, dbVersion, { + latestMessageTs: facts.latestMessageTs, + }) + factsCache.stats.writes += 2 +} + +function getSessionDbVersion(adapter: SessionRuntimeAdapter, sessionId: string): string { + return getDbFileVersion(adapter.getDbPath(sessionId)) +} + +function toContactsSessionMetaFacts(meta: SessionMeta): ContactsSessionMetaFacts { + return { + name: meta.name, + platform: meta.platform, + type: meta.type as ChatType.PRIVATE | ChatType.GROUP, + ownerId: meta.ownerId, + } +} + +function buildContactItems(accumulators: ContactAccumulator[]): ContactItem[] { + const friendInputs = accumulators + .filter((acc) => acc.isFriend) + .map((acc) => ({ + acc, + privateMessageCount: acc.privateMessageCount, + activeMonths: [...acc.activePrivateMonths], + commonGroupCount: acc.commonGroupSessionIds.size, + })) + const nonFriendInputs = accumulators + .filter((acc) => !acc.isFriend) + .map((acc) => ({ + acc, + coOccurrenceRawScore: acc.coOccurrenceRawScore, + commonGroupCount: acc.commonGroupSessionIds.size, + replyInteractionCount: acc.replyInteractionCount, + coOccurrenceCount: acc.coOccurrenceCount, + })) + + const friendScores = computeFriendScores(friendInputs) + const nonFriendScores = computeNonFriendScores(nonFriendInputs) + const contacts: ContactItem[] = [] + + for (const input of friendInputs) { + const score = friendScores.get(input) ?? { score: 0, scoreBreakdown: {} } + contacts.push(toContactItem(input.acc, 'friend', score)) + } + + for (const input of nonFriendInputs) { + const score = nonFriendScores.get(input) ?? { score: 0, scoreBreakdown: {} } + contacts.push(toContactItem(input.acc, 'non_friend', score)) + } + + return contacts.sort((a, b) => b.score - a.score || a.displayName.localeCompare(b.displayName)) +} + +function toContactItem( + acc: ContactAccumulator, + pool: 'friend' | 'non_friend', + scoring: { score: number; scoreBreakdown: ContactItem['scoreBreakdown'] } +): ContactItem { + const aliases = [...acc.aliases].filter((alias) => alias !== acc.displayName) + const searchText = [acc.displayName, acc.platformId, ...aliases].join(' ').toLowerCase() + + const item: ContactItem = { + key: acc.key, + platform: acc.platform, + platformId: acc.platformId, + sessionScoped: acc.sessionScoped, + sessionId: acc.sessionId, + displayName: acc.displayName, + aliases, + avatar: acc.avatar, + isFriend: acc.isFriend, + pool, + score: scoring.score, + scoreBreakdown: { + ...scoring.scoreBreakdown, + privateMessageCount: acc.privateMessageCount || scoring.scoreBreakdown.privateMessageCount, + activePrivateMonths: acc.activePrivateMonths.size || scoring.scoreBreakdown.activePrivateMonths, + commonGroupCount: acc.commonGroupSessionIds.size, + coOccurrenceCount: acc.coOccurrenceCount, + coOccurrenceRawScore: acc.coOccurrenceRawScore, + replyInteractionCount: acc.replyInteractionCount, + repliesFromOwnerToContact: acc.repliesFromOwnerToContact, + repliesFromContactToOwner: acc.repliesFromContactToOwner, + }, + sourceSessions: acc.sourceSessions, + searchText, + lastInteractionTs: acc.lastInteractionTs, + } + if (pool === 'friend') item.friendSource = 'private' + return item +} + +export function createEmptyContactsDiagnostics(): ContactsDiagnostics { + return createEmptyDiagnostics() +} + +function createEmptyDiagnostics(): ContactsDiagnostics { + return { + privateSessionCount: 0, + activePrivateSessionCount: 0, + contactsEnabled: false, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedInvalidPlatformIdMembers: 0, + skippedFailedSessions: 0, + warnings: [], + } +} + +function getOrCreateAccumulator( + accumulators: Map, + sessionId: string, + meta: ContactsSessionMetaFacts, + contact: ContactMemberRef +): ContactAccumulator { + const sessionScoped = shouldScopeContactToSession(meta.platform, contact) + const key = buildContactKey(meta.platform, contact.platformId, sessionScoped ? sessionId : undefined) + const existing = accumulators.get(key) + if (existing) { + mergeContactIdentity(existing, contact) + return existing + } + + const created: ContactAccumulator = { + key, + platform: meta.platform, + platformId: contact.platformId, + sessionScoped, + sessionId: sessionScoped ? sessionId : undefined, + displayName: contact.name || contact.platformId, + aliases: new Set([contact.platformId, contact.name, ...contact.aliases].filter(Boolean)), + avatar: contact.avatar, + isFriend: false, + privateMessageCount: 0, + activePrivateMonths: new Set(), + commonGroupSessionIds: new Set(), + coOccurrenceCount: 0, + coOccurrenceRawScore: 0, + replyInteractionCount: 0, + repliesFromOwnerToContact: 0, + repliesFromContactToOwner: 0, + sourceSessions: [], + lastInteractionTs: null, + } + accumulators.set(key, created) + return created +} + +function shouldScopeContactToSession(platform: ChatPlatform, contact: ContactMemberRef): boolean { + if (isNameMatchPlatform(platform)) return true + return platform.trim().toLowerCase() === 'qq' && contact.platformId.trim() === contact.name.trim() +} + +function buildContactKey(platform: ChatPlatform, platformId: string, sessionId?: string): string { + const normalizedPlatform = platform.trim() + const normalizedPlatformId = platformId.trim() + if (!normalizedPlatform) throw new Error('platform is required') + if (!normalizedPlatformId) throw new Error('platformId is required') + return sessionId?.trim() + ? `${normalizedPlatform}:${sessionId.trim()}:${normalizedPlatformId}` + : `${normalizedPlatform}:${normalizedPlatformId}` +} + +function mergeContactIdentity(acc: ContactAccumulator, contact: ContactMemberRef): void { + if (contact.name) acc.aliases.add(contact.name) + acc.aliases.add(contact.platformId) + for (const alias of contact.aliases) acc.aliases.add(alias) + if ((!acc.displayName || acc.displayName === acc.platformId) && contact.name) { + acc.displayName = contact.name + } + if (!acc.avatar && contact.avatar) acc.avatar = contact.avatar +} + +function updateLastInteraction(acc: ContactAccumulator, ts: number | null): void { + if (ts === null) return + acc.lastInteractionTs = Math.max(acc.lastInteractionTs ?? 0, ts) +} diff --git a/packages/node-runtime/src/services/contacts/facts-cache.test.ts b/packages/node-runtime/src/services/contacts/facts-cache.test.ts new file mode 100644 index 000000000..959bf8f02 --- /dev/null +++ b/packages/node-runtime/src/services/contacts/facts-cache.test.ts @@ -0,0 +1,94 @@ +/** + * Run: pnpm test -- packages/node-runtime/src/services/contacts/facts-cache.test.ts + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { ChatType, type ContactsTimeRangeState } from '@openchatlab/shared-types' +import { getCachePath, setCache } from '../../cache/session-cache' +import { + buildContactsSessionFactsCacheKey, + readCachedContactsSessionFacts, + writeCachedContactsSessionFacts, + type ContactsSessionFacts, +} from './facts-cache' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-facts-cache-')) +} + +function makeTimeRange(overrides: Partial = {}): ContactsTimeRangeState { + return { + preset: '1y', + anchorTs: 1704103200, + startTs: 1672567200, + ...overrides, + } +} + +function makeFacts(): ContactsSessionFacts { + return { + kind: 'private', + latestMessageTs: 1704103200, + meta: { + name: 'private-a', + platform: 'weixin', + type: ChatType.PRIVATE, + ownerId: 'owner', + }, + facts: { + contact: { + id: 2, + platformId: 'alice', + name: 'Alice', + aliases: [], + avatar: null, + }, + privateMessageCount: 10, + activeMonths: ['2024-01'], + lastMessageTs: 1704103200, + }, + } +} + +test('contacts session facts cache key includes algorithm version, preset, and start timestamp', () => { + const oneYear = buildContactsSessionFactsCacheKey('contacts-v1', makeTimeRange()) + const twoYears = buildContactsSessionFactsCacheKey('contacts-v1', makeTimeRange({ preset: '2y' })) + const shiftedStart = buildContactsSessionFactsCacheKey('contacts-v1', makeTimeRange({ startTs: 1600000000 })) + const nextAlgorithm = buildContactsSessionFactsCacheKey('contacts-v2', makeTimeRange()) + + assert.notEqual(oneYear, twoYears) + assert.notEqual(oneYear, shiftedStart) + assert.notEqual(oneYear, nextAlgorithm) +}) + +test('contacts session facts cache is versioned by session db fingerprint', (t) => { + const dir = makeTempDir() + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const key = buildContactsSessionFactsCacheKey('contacts-v1', makeTimeRange()) + + writeCachedContactsSessionFacts('session-a', dir, key, 'db-v1', makeFacts()) + + assert.deepEqual(readCachedContactsSessionFacts('session-a', dir, key, 'db-v1'), { + hit: true, + data: makeFacts(), + }) + assert.deepEqual(readCachedContactsSessionFacts('session-a', dir, key, 'db-v2'), { hit: false }) +}) + +test('contacts session facts cache treats corrupt or malformed entries as misses', (t) => { + const dir = makeTempDir() + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const key = buildContactsSessionFactsCacheKey('contacts-v1', makeTimeRange()) + + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(getCachePath('session-a', dir), '{ broken', 'utf-8') + assert.deepEqual(readCachedContactsSessionFacts('session-a', dir, key, 'db-v1'), { hit: false }) + + setCache('session-a', key, { v: 'db-v1', data: { kind: 'private', latestMessageTs: 1 } }, dir) + assert.deepEqual(readCachedContactsSessionFacts('session-a', dir, key, 'db-v1'), { hit: false }) +}) diff --git a/packages/node-runtime/src/services/contacts/facts-cache.ts b/packages/node-runtime/src/services/contacts/facts-cache.ts new file mode 100644 index 000000000..e0459485c --- /dev/null +++ b/packages/node-runtime/src/services/contacts/facts-cache.ts @@ -0,0 +1,228 @@ +import { ChatType, type ChatPlatform, type ContactsTimeRangeState } from '@openchatlab/shared-types' +import type { ContactMemberRef } from '@openchatlab/core' +import { getCache, setCache, deleteSessionCache } from '../../cache/session-cache' + +export const CONTACTS_FACTS_FORMAT_VERSION = 1 + +export interface ContactsFactsCacheStats { + latestHits: number + latestMisses: number + factsHits: number + factsMisses: number + writes: number +} + +export interface ContactsSessionMetaFacts { + name: string + platform: ChatPlatform + type: ChatType.PRIVATE | ChatType.GROUP + ownerId: string | null +} + +export interface ContactsCachedPrivateFacts { + contact: ContactMemberRef + privateMessageCount: number + activeMonths: string[] + lastMessageTs: number | null +} + +export interface ContactsCachedGroupFacts { + contact: ContactMemberRef + messageCount: number + coOccurrenceCount: number + coOccurrenceRawScore: number + replyInteractionCount: number + repliesFromOwnerToContact: number + repliesFromContactToOwner: number + lastInteractionTs: number | null +} + +export type ContactsSessionFacts = + | { kind: 'not_chat_db'; latestMessageTs: null } + | { kind: 'missing_meta'; latestMessageTs: number | null } + | { kind: 'unsupported_type'; latestMessageTs: number | null } + | { kind: 'missing_owner'; meta: ContactsSessionMetaFacts; latestMessageTs: number | null } + | { kind: 'unresolved_owner'; meta: ContactsSessionMetaFacts; latestMessageTs: number | null } + | { kind: 'private_missing'; meta: ContactsSessionMetaFacts; latestMessageTs: number | null } + | { kind: 'private_ambiguous'; meta: ContactsSessionMetaFacts; latestMessageTs: number | null } + | { + kind: 'private' + meta: ContactsSessionMetaFacts + latestMessageTs: number | null + facts: ContactsCachedPrivateFacts + } + | { + kind: 'group' + meta: ContactsSessionMetaFacts + latestMessageTs: number | null + facts: ContactsCachedGroupFacts[] + } + +export interface ContactsSessionLatestFacts { + latestMessageTs: number | null +} + +export type ContactsCacheReadResult = { hit: true; data: T } | { hit: false } + +interface VersionedContactsCacheEntry { + v: string + data: T +} + +export function createEmptyContactsFactsCacheStats(): ContactsFactsCacheStats { + return { + latestHits: 0, + latestMisses: 0, + factsHits: 0, + factsMisses: 0, + writes: 0, + } +} + +export function buildContactsSessionLatestCacheKey(algorithmVersion: string): string { + return `contacts:latest:v${CONTACTS_FACTS_FORMAT_VERSION}:${algorithmVersion}` +} + +export function buildContactsSessionFactsCacheKey(algorithmVersion: string, timeRange: ContactsTimeRangeState): string { + return [ + 'contacts:facts', + `v${CONTACTS_FACTS_FORMAT_VERSION}`, + algorithmVersion, + `preset:${timeRange.preset}`, + `start:${timeRange.startTs ?? 'all'}`, + ].join(':') +} + +export function readCachedContactsSessionLatest( + sessionId: string, + cacheDir: string, + key: string, + dbVersion: string +): ContactsCacheReadResult { + const cached = getCache>(sessionId, key, cacheDir) + if (!cached || cached.v !== dbVersion || !isContactsSessionLatestFacts(cached.data)) return { hit: false } + return { hit: true, data: cached.data } +} + +export function writeCachedContactsSessionLatest( + sessionId: string, + cacheDir: string, + key: string, + dbVersion: string, + data: ContactsSessionLatestFacts +): void { + setCache>(sessionId, key, { v: dbVersion, data }, cacheDir) +} + +export function readCachedContactsSessionFacts( + sessionId: string, + cacheDir: string, + key: string, + dbVersion: string +): ContactsCacheReadResult { + const cached = getCache>(sessionId, key, cacheDir) + if (!cached || cached.v !== dbVersion || !isContactsSessionFacts(cached.data)) return { hit: false } + return { hit: true, data: cached.data } +} + +export function writeCachedContactsSessionFacts( + sessionId: string, + cacheDir: string, + key: string, + dbVersion: string, + data: ContactsSessionFacts +): void { + setCache>(sessionId, key, { v: dbVersion, data }, cacheDir) +} + +export function deleteContactsSessionFactsCache(sessionId: string, cacheDir: string): void { + deleteSessionCache(sessionId, cacheDir) +} + +function isContactsSessionLatestFacts(value: unknown): value is ContactsSessionLatestFacts { + return isObject(value) && isNullableNumber(value.latestMessageTs) +} + +function isContactsSessionFacts(value: unknown): value is ContactsSessionFacts { + if (!isObject(value) || typeof value.kind !== 'string' || !isNullableNumber(value.latestMessageTs)) return false + switch (value.kind) { + case 'not_chat_db': + case 'missing_meta': + case 'unsupported_type': + return true + case 'missing_owner': + case 'unresolved_owner': + case 'private_missing': + case 'private_ambiguous': + return isContactsSessionMetaFacts(value.meta) + case 'private': + return isContactsSessionMetaFacts(value.meta) && isContactsCachedPrivateFacts(value.facts) + case 'group': + return ( + isContactsSessionMetaFacts(value.meta) && + Array.isArray(value.facts) && + value.facts.every(isContactsCachedGroupFacts) + ) + default: + return false + } +} + +function isContactsSessionMetaFacts(value: unknown): value is ContactsSessionMetaFacts { + return ( + isObject(value) && + typeof value.name === 'string' && + typeof value.platform === 'string' && + (value.type === ChatType.PRIVATE || value.type === ChatType.GROUP) && + (typeof value.ownerId === 'string' || value.ownerId === null) + ) +} + +function isContactsCachedPrivateFacts(value: unknown): value is ContactsCachedPrivateFacts { + return ( + isObject(value) && + isContactMemberRef(value.contact) && + isFiniteNumber(value.privateMessageCount) && + Array.isArray(value.activeMonths) && + value.activeMonths.every((month) => typeof month === 'string') && + isNullableNumber(value.lastMessageTs) + ) +} + +function isContactsCachedGroupFacts(value: unknown): value is ContactsCachedGroupFacts { + return ( + isObject(value) && + isContactMemberRef(value.contact) && + isFiniteNumber(value.messageCount) && + isFiniteNumber(value.coOccurrenceCount) && + isFiniteNumber(value.coOccurrenceRawScore) && + isFiniteNumber(value.replyInteractionCount) && + isFiniteNumber(value.repliesFromOwnerToContact) && + isFiniteNumber(value.repliesFromContactToOwner) && + isNullableNumber(value.lastInteractionTs) + ) +} + +function isContactMemberRef(value: unknown): value is ContactMemberRef { + return ( + isObject(value) && + isFiniteNumber(value.id) && + typeof value.platformId === 'string' && + typeof value.name === 'string' && + Array.isArray(value.aliases) && + value.aliases.every((alias) => typeof alias === 'string') && + (typeof value.avatar === 'string' || value.avatar === null) + ) +} + +function isNullableNumber(value: unknown): value is number | null { + return value === null || isFiniteNumber(value) +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/packages/node-runtime/src/services/contacts/index.ts b/packages/node-runtime/src/services/contacts/index.ts new file mode 100644 index 000000000..77ecfdeb7 --- /dev/null +++ b/packages/node-runtime/src/services/contacts/index.ts @@ -0,0 +1,10 @@ +export { CONTACTS_ALGORITHM_VERSION } from './compute' +export { createContactsService } from './service' +export { getContactsDir, getContactsFactsCacheDir } from './paths' +export type { + ContactsComputeRunner, + ContactsService, + ContactsServiceDeps, + ContactsServiceOptions, + ContactsRunnerOptions, +} from './service' diff --git a/packages/node-runtime/src/services/contacts/overrides.ts b/packages/node-runtime/src/services/contacts/overrides.ts new file mode 100644 index 000000000..d1f6c51ee --- /dev/null +++ b/packages/node-runtime/src/services/contacts/overrides.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs' +import path from 'node:path' +import { appLogger } from '../../logging/app-logger' + +const CONTACT_OVERRIDES_FILE_NAME = 'contact-overrides.json' +const CONTACT_OVERRIDES_TMP_PREFIX = 'contact-overrides.tmp-' +const CONTACT_OVERRIDES_VERSION = 1 + +export interface ManualFriendOverride { + key: string + createdAt: number + updatedAt: number +} + +export interface ContactOverridesFile { + version: 1 + manualFriends: Record +} + +export interface ContactOverrideMutationResult { + success: boolean +} + +export function createEmptyContactOverridesFile(): ContactOverridesFile { + return { + version: CONTACT_OVERRIDES_VERSION, + manualFriends: {}, + } +} + +export function getContactOverridesPath(snapshotDir: string): string { + return path.join(snapshotDir, CONTACT_OVERRIDES_FILE_NAME) +} + +export function readContactOverrides(snapshotDir: string): ContactOverridesFile { + const filePath = getContactOverridesPath(snapshotDir) + if (!fs.existsSync(filePath)) return createEmptyContactOverridesFile() + + try { + return normalizeContactOverrides(JSON.parse(fs.readFileSync(filePath, 'utf-8'))) + } catch (error) { + appLogger.warn('contacts', 'contact overrides file is unreadable', error) + return createEmptyContactOverridesFile() + } +} + +export function writeContactOverrides(snapshotDir: string, overrides: ContactOverridesFile): void { + if (!fs.existsSync(snapshotDir)) fs.mkdirSync(snapshotDir, { recursive: true }) + const tmpPath = path.join(snapshotDir, `${CONTACT_OVERRIDES_TMP_PREFIX}${process.pid}-${Date.now()}`) + fs.writeFileSync(tmpPath, JSON.stringify(normalizeContactOverrides(overrides), null, 2), 'utf-8') + fs.renameSync(tmpPath, getContactOverridesPath(snapshotDir)) +} + +function normalizeContactOverrides(value: unknown): ContactOverridesFile { + const input = isRecord(value) ? value : {} + const manualFriendsInput = isRecord(input.manualFriends) ? input.manualFriends : {} + const manualFriends: Record = {} + + for (const [key, entry] of Object.entries(manualFriendsInput)) { + if (!key || !isRecord(entry)) continue + const createdAt = Number.isFinite(entry.createdAt) ? Number(entry.createdAt) : Date.now() + const updatedAt = Number.isFinite(entry.updatedAt) ? Number(entry.updatedAt) : createdAt + manualFriends[key] = { key, createdAt, updatedAt } + } + + return { + version: CONTACT_OVERRIDES_VERSION, + manualFriends, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/node-runtime/src/services/contacts/paths.ts b/packages/node-runtime/src/services/contacts/paths.ts new file mode 100644 index 000000000..f33e11ca1 --- /dev/null +++ b/packages/node-runtime/src/services/contacts/paths.ts @@ -0,0 +1,12 @@ +import path from 'node:path' + +export const CONTACTS_DIR_NAME = 'contacts' +export const CONTACTS_FACTS_DIR_NAME = 'facts' + +export function getContactsDir(userDataDir: string): string { + return path.join(userDataDir, CONTACTS_DIR_NAME) +} + +export function getContactsFactsCacheDir(userDataDir: string): string { + return path.join(getContactsDir(userDataDir), CONTACTS_FACTS_DIR_NAME) +} diff --git a/packages/node-runtime/src/services/contacts/service.test.ts b/packages/node-runtime/src/services/contacts/service.test.ts new file mode 100644 index 000000000..27e481245 --- /dev/null +++ b/packages/node-runtime/src/services/contacts/service.test.ts @@ -0,0 +1,1072 @@ +/** + * Integration tests for the cross-session contacts service. + * + * Run: pnpm test -- packages/node-runtime/src/services/contacts/service.test.ts + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { CHAT_DB_SCHEMA } from '@openchatlab/core' +import type { DatabaseAdapter, PathProvider } from '@openchatlab/core' +import { ChatType } from '@openchatlab/shared-types' +import type { ContactItem, ContactsResponse, ContactsTimeRangePreset } from '@openchatlab/shared-types' +import { openBetterSqliteDatabase } from '../../better-sqlite3-adapter' +import type { SessionRuntimeAdapter } from '../adapters' +import { CONTACTS_ALGORITHM_VERSION, computeContactsSnapshot, type ContactsSnapshot } from './compute' +import { createContactsService } from './service' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-service-')) +} + +interface SeedMember { + id: number + platformId: string + accountName?: string + groupNickname?: string + aliases?: string[] + avatar?: string | null +} + +interface SeedMessage { + id: number + senderId: number + ts: number + content?: string + platformMessageId?: string | null + replyToMessageId?: string | null +} + +interface SeedSession { + id: string + platform: string + type: 'private' | 'group' + ownerId?: string | null + members: SeedMember[] + messages?: SeedMessage[] +} + +class TestEnv { + readonly dir = makeTempDir() + readonly adapter: SessionRuntimeAdapter + private dbPaths = new Map() + private openDbs: DatabaseAdapter[] = [] + + constructor() { + const open = (sessionId: string, readonly: boolean): DatabaseAdapter | null => { + const dbPath = this.dbPaths.get(sessionId) + if (!dbPath) return null + const db = openBetterSqliteDatabase(dbPath, { readonly, nativeBinding }) + this.openDbs.push(db) + return db + } + + this.adapter = { + listSessionIds: () => [...this.dbPaths.keys()], + openReadonly: (id) => open(id, true), + openWritable: (id) => open(id, false), + closeSession: () => {}, + getDbPath: (id) => this.dbPaths.get(id) ?? '', + deleteSessionFile: () => false, + ensureReadonly: (id) => { + const db = open(id, true) + if (!db) throw Object.assign(new Error(`Session not found: ${id}`), { statusCode: 404 }) + return db + }, + ensureWritable: (id) => { + const db = open(id, false) + if (!db) throw Object.assign(new Error(`Session not found: ${id}`), { statusCode: 404 }) + return db + }, + } + } + + seed(session: SeedSession): void { + const dbPath = path.join(this.dir, `${session.id}.db`) + const db = openBetterSqliteDatabase(dbPath, { nativeBinding }) + db.exec(CHAT_DB_SCHEMA) + db.prepare(`INSERT INTO meta (name, platform, type, imported_at, owner_id) VALUES (?, ?, ?, ?, ?)`).run( + session.id, + session.platform, + session.type, + 1780000000, + session.ownerId ?? null + ) + for (const member of session.members) { + db.prepare( + `INSERT INTO member (id, platform_id, account_name, group_nickname, aliases, avatar) VALUES (?, ?, ?, ?, ?, ?)` + ).run( + member.id, + member.platformId, + member.accountName ?? member.platformId, + member.groupNickname ?? null, + JSON.stringify(member.aliases ?? []), + member.avatar ?? null + ) + } + for (const message of session.messages ?? []) { + db.prepare( + `INSERT INTO message + (id, sender_id, ts, type, content, platform_message_id, reply_to_message_id) + VALUES (?, ?, ?, 0, ?, ?, ?)` + ).run( + message.id, + message.senderId, + message.ts, + message.content ?? `message ${message.id}`, + message.platformMessageId ?? `m-${message.id}`, + message.replyToMessageId ?? null + ) + } + db.close() + this.dbPaths.set(session.id, dbPath) + } + + dbPath(sessionId: string): string { + const dbPath = this.dbPaths.get(sessionId) + assert.ok(dbPath) + return dbPath + } + + pathProvider(options: { systemDir?: string; userDataDir?: string } = {}): PathProvider { + const systemDir = options.systemDir ?? this.dir + const userDataDir = options.userDataDir ?? this.dir + return { + getSystemDir: () => systemDir, + getUserDataDir: () => userDataDir, + getDatabaseDir: () => userDataDir, + getVectorDir: () => path.join(userDataDir, 'vector'), + getAiDataDir: () => path.join(systemDir, 'ai'), + getSettingsDir: () => path.join(systemDir, 'settings'), + getCacheDir: () => path.join(systemDir, 'cache'), + getTempDir: () => path.join(systemDir, 'temp'), + getLogsDir: () => path.join(systemDir, 'logs'), + getDownloadsDir: () => path.join(systemDir, 'downloads'), + } + } + + cleanup(): void { + for (const db of this.openDbs) { + try { + db.close() + } catch { + // already closed + } + } + fs.rmSync(this.dir, { recursive: true, force: true }) + } +} + +function privateMessages(count: number, startId: number, startTs: number): SeedMessage[] { + return Array.from({ length: count }, (_, index) => ({ + id: startId + index, + senderId: index % 2 === 0 ? 1 : 2, + ts: startTs + index, + })) +} + +test('aggregates stable-id contacts across private and group sessions', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'private-a', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'alice', accountName: 'Alice', aliases: ['Ally'], avatar: 'alice.png' }, + ], + messages: privateMessages(60, 1, 1704103200), + }) + env.seed({ + id: 'private-b', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'alice', accountName: 'Alice B' }, + ], + messages: privateMessages(5, 1, 1706781600), + }) + env.seed({ + id: 'group-a', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'alice', accountName: 'Alice' }, + { id: 3, platformId: 'bob', accountName: 'Bob' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200, platformMessageId: 'owner-1' }, + { id: 2, senderId: 2, ts: 1704103201, platformMessageId: 'alice-1', replyToMessageId: 'owner-1' }, + { id: 3, senderId: 3, ts: 1704103800, platformMessageId: 'bob-1' }, + ], + }) + + const result = computeContactsSnapshot({ adapter: env.adapter, signature: 'sig-1' }) + const byKey = new Map(result.contacts.map((contact) => [contact.key, contact])) + const alice = byKey.get('weixin:alice') + const bob = byKey.get('weixin:bob') + + assert.ok(alice) + assert.equal(alice.isFriend, true) + assert.equal(alice.pool, 'friend') + assert.equal(alice.scoreBreakdown.privateMessageCount, 65) + assert.equal(alice.scoreBreakdown.activePrivateMonths, 2) + assert.equal(alice.scoreBreakdown.commonGroupCount, 1) + assert.equal(alice.avatar, 'alice.png') + assert.ok(alice.aliases.includes('Ally')) + assert.ok(alice.searchText.includes('ally')) + assert.deepEqual(alice.sourceSessions.map((source) => source.id).sort(), ['group-a', 'private-a', 'private-b']) + + assert.ok(bob) + assert.equal(bob.isFriend, false) + assert.equal(bob.pool, 'non_friend') + assert.equal(bob.scoreBreakdown.commonGroupCount, 1) + assert.equal(bob.sourceSessions.length, 1) + + assert.equal(result.diagnostics.privateSessionCount, 2) + assert.equal(result.diagnostics.contactsEnabled, false) +}) + +test('records diagnostics for missing owner, unresolved owner, and ambiguous private sessions', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'missing-owner', + platform: 'weixin', + type: 'private', + ownerId: null, + members: [{ id: 1, platformId: 'alice' }], + }) + env.seed({ + id: 'unresolved-owner', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [{ id: 1, platformId: 'alice' }], + }) + env.seed({ + id: 'ambiguous-private', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + { id: 3, platformId: 'bob' }, + ], + }) + + const result = computeContactsSnapshot({ adapter: env.adapter, signature: 'sig-1' }) + + assert.equal(result.contacts.length, 0) + assert.equal(result.diagnostics.privateSessionCount, 3) + assert.equal(result.diagnostics.skippedMissingOwnerSessions, 1) + assert.equal(result.diagnostics.skippedUnresolvedOwnerSessions, 1) + assert.equal(result.diagnostics.skippedAmbiguousPrivateSessions, 1) +}) + +test('keeps name-match platform contacts session-scoped', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + for (const id of ['whatsapp-a', 'whatsapp-b']) { + env.seed({ + id, + platform: 'whatsapp', + type: 'private', + ownerId: 'Me', + members: [ + { id: 1, platformId: 'Me' }, + { id: 2, platformId: 'Alice' }, + ], + messages: privateMessages(10, 1, 1704103200), + }) + } + + const result = computeContactsSnapshot({ adapter: env.adapter, signature: 'sig-1' }) + const keys = result.contacts.map((contact) => contact.key).sort() + + assert.deepEqual(keys, ['whatsapp:whatsapp-a:Alice', 'whatsapp:whatsapp-b:Alice']) +}) + +test('keeps QQ nickname fallback contacts session-scoped', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + for (const id of ['qq-group-a', 'qq-group-b']) { + env.seed({ + id, + platform: 'qq', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Owner' }, + { id: 2, platformId: 'Alice', accountName: 'Alice' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200, platformMessageId: `${id}-owner-1` }, + { id: 2, senderId: 2, ts: 1704103201, platformMessageId: `${id}-alice-1` }, + ], + }) + } + + const result = computeContactsSnapshot({ adapter: env.adapter, signature: 'sig-1' }) + const keys = result.contacts.map((contact) => contact.key).sort() + + assert.deepEqual(keys, ['qq:qq-group-a:Alice', 'qq:qq-group-b:Alice']) +}) + +test('sorts non-friends by score with the public contacts response shape', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'private-a', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + ], + messages: privateMessages(60, 1, 1704103200), + }) + env.seed({ + id: 'group-a', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + { id: 3, platformId: 'bob' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200, platformMessageId: 'owner-1' }, + { id: 2, senderId: 3, ts: 1704103201, platformMessageId: 'bob-1' }, + ], + }) + + const result = computeContactsSnapshot({ adapter: env.adapter, signature: 'sig-1' }) + + assert.deepEqual( + result.contacts.map((contact) => contact.key), + ['weixin:alice', 'weixin:bob'] + ) + assert.deepEqual(Object.keys(result.contacts[1]).sort(), [ + 'aliases', + 'avatar', + 'displayName', + 'isFriend', + 'key', + 'lastInteractionTs', + 'platform', + 'platformId', + 'pool', + 'score', + 'scoreBreakdown', + 'searchText', + 'sessionId', + 'sessionScoped', + 'sourceSessions', + ]) + assert.deepEqual(Object.keys(result.diagnostics).sort(), [ + 'activePrivateSessionCount', + 'contactsEnabled', + 'privateSessionCount', + 'skippedAmbiguousPrivateSessions', + 'skippedFailedSessions', + 'skippedInvalidPlatformIdMembers', + 'skippedMissingOwnerSessions', + 'skippedUnresolvedOwnerSessions', + 'warnings', + ]) +}) + +test('reuses cached contacts session facts for unchanged sessions during recompute', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + const factsCacheDir = path.join(env.dir, 'contacts', 'facts') + + env.seed({ + id: 'private-a', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + ], + messages: privateMessages(20, 1, 1704103200), + }) + env.seed({ + id: 'group-a', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + { id: 3, platformId: 'bob' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200, platformMessageId: 'owner-1' }, + { id: 2, senderId: 3, ts: 1704103201, platformMessageId: 'bob-1' }, + ], + }) + + computeContactsSnapshot({ adapter: env.adapter, signature: 'sig-1', factsCacheDir }) + + const future = new Date(Date.now() + 10_000) + fs.utimesSync(env.dbPath('private-a'), future, future) + + const openedSessionIds: string[] = [] + const countingAdapter: SessionRuntimeAdapter = { + ...env.adapter, + openReadonly: (sessionId) => { + openedSessionIds.push(sessionId) + return env.adapter.openReadonly(sessionId) + }, + } + + const result = computeContactsSnapshot({ adapter: countingAdapter, signature: 'sig-2', factsCacheDir }) + + assert.equal(openedSessionIds.includes('private-a'), true) + assert.equal(openedSessionIds.includes('group-a'), false) + assert.ok(result.contacts.some((contact) => contact.key === 'weixin:bob')) +}) + +test('does not cache contacts session facts under a db version that changed during compute', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + const factsCacheDir = path.join(env.dir, 'contacts', 'facts') + + env.seed({ + id: 'group-a', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'bob' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200, platformMessageId: 'owner-1' }, + { id: 2, senderId: 2, ts: 1704103201, platformMessageId: 'bob-1' }, + ], + }) + + let getDbPathCalls = 0 + const racingAdapter: SessionRuntimeAdapter = { + ...env.adapter, + getDbPath: (sessionId) => { + getDbPathCalls++ + const dbPath = env.adapter.getDbPath(sessionId) + if (sessionId === 'group-a' && getDbPathCalls === 4) { + const future = new Date(Date.now() + 20_000) + fs.utimesSync(dbPath, future, future) + } + return dbPath + }, + } + + computeContactsSnapshot({ adapter: racingAdapter, signature: 'sig-1', factsCacheDir }) + + const openedSessionIds: string[] = [] + const countingAdapter: SessionRuntimeAdapter = { + ...env.adapter, + openReadonly: (sessionId) => { + openedSessionIds.push(sessionId) + return env.adapter.openReadonly(sessionId) + }, + } + + computeContactsSnapshot({ adapter: countingAdapter, signature: 'sig-2', factsCacheDir }) + + assert.deepEqual(openedSessionIds, ['group-a', 'group-a']) +}) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +async function waitForTaskSettled( + service: { + getContacts: (options?: { acceptStale?: boolean; timeRangePreset?: ContactsTimeRangePreset }) => ContactsResponse + }, + options: { timeRangePreset?: ContactsTimeRangePreset } = {} +) { + for (let i = 0; i < 100; i++) { + const response = service.getContacts({ acceptStale: true, timeRangePreset: options.timeRangePreset }) + if (response.task?.status !== 'running') return response + await new Promise((resolve) => setTimeout(resolve, 20)) + } + return service.getContacts({ acceptStale: true, timeRangePreset: options.timeRangePreset }) +} + +function makeRuntimeSnapshot( + signature: string, + computedAt: number, + timeRangePreset: ContactsTimeRangePreset = '1y' +): ContactsSnapshot { + return { + contacts: [ + { + key: 'weixin:alice', + platform: 'weixin', + platformId: 'alice', + sessionScoped: false, + displayName: 'Alice', + aliases: [], + avatar: null, + isFriend: true, + pool: 'friend', + score: 1, + scoreBreakdown: {}, + sourceSessions: [ + { + id: 'private-a', + name: 'private-a', + platform: 'weixin', + type: ChatType.PRIVATE, + }, + ], + searchText: 'alice', + lastInteractionTs: null, + }, + ], + diagnostics: { + privateSessionCount: 1, + activePrivateSessionCount: 1, + contactsEnabled: false, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedInvalidPlatformIdMembers: 0, + skippedFailedSessions: 0, + warnings: [], + }, + algorithmVersion: CONTACTS_ALGORITHM_VERSION, + signature, + timeRange: { + preset: timeRangePreset, + anchorTs: null, + startTs: null, + }, + computedAt, + workerStats: { + durationMs: 10, + totalSessions: 1, + processedSessions: 1, + skippedFailedSessions: 0, + }, + } +} + +function makeContact(overrides: Partial & Pick): ContactItem { + return { + key: overrides.key, + platform: 'weixin', + platformId: overrides.key.split(':').at(-1) ?? overrides.key, + sessionScoped: false, + displayName: overrides.displayName, + aliases: overrides.aliases ?? [], + avatar: null, + isFriend: overrides.pool === 'friend', + pool: overrides.pool, + score: overrides.score ?? 0, + scoreBreakdown: overrides.scoreBreakdown ?? {}, + sourceSessions: overrides.sourceSessions ?? [ + { + id: `${overrides.key}-source`, + name: `${overrides.displayName} Source`, + platform: 'weixin', + type: overrides.pool === 'friend' ? ChatType.PRIVATE : ChatType.GROUP, + }, + ], + searchText: + overrides.searchText ?? + [overrides.displayName, overrides.key, ...(overrides.aliases ?? [])].join(' ').toLowerCase(), + lastInteractionTs: overrides.lastInteractionTs ?? null, + } +} + +test('returns paginated lightweight contacts from a snapshot with full-snapshot search', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + const service = createContactsService({ + adapter: env.adapter, + systemDir: env.dir, + runner: () => Promise.reject(new Error('not used')), + }) + const snapshot = makeRuntimeSnapshot('sig-1', 1000) + snapshot.contacts = [ + makeContact({ key: 'weixin:alice', displayName: 'Alice', pool: 'friend', score: 1 }), + makeContact({ key: 'weixin:bob', displayName: 'Bob', pool: 'non_friend', score: 0.8, aliases: ['Builder'] }), + makeContact({ key: 'weixin:carol', displayName: 'Carol', pool: 'non_friend', score: 0.7 }), + ] + service.replaceSnapshotForTests!(snapshot) + + const response = (service as any).getContactsPage({ + acceptStale: true, + pool: 'non_friend', + page: 1, + pageSize: 1, + query: 'build', + }) + + assert.equal(response.cache.status, 'stale') + assert.deepEqual(response.pagination, { page: 1, pageSize: 1, total: 1, hasMore: false }) + assert.deepEqual(response.stats, { friendsTotal: 1, nonFriendsTotal: 2 }) + assert.equal(response.contacts.length, 1) + assert.equal(response.contacts[0].key, 'weixin:bob') + assert.equal('sourceSessions' in response.contacts[0], false) +}) + +test('applies manual friend overrides to paginated lists, stats, search, and detail without changing the snapshot', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + const service = createContactsService({ + adapter: env.adapter, + systemDir: env.dir, + runner: () => Promise.reject(new Error('not used')), + }) + const snapshot = makeRuntimeSnapshot('sig-1', 1000) + snapshot.contacts = [ + makeContact({ key: 'weixin:bob', displayName: 'Bob', pool: 'non_friend', score: 1, aliases: ['Builder'] }), + makeContact({ key: 'weixin:alice', displayName: 'Alice', pool: 'friend', score: 0.4 }), + makeContact({ key: 'weixin:carol', displayName: 'Carol', pool: 'non_friend', score: 0.3 }), + ] + service.replaceSnapshotForTests!(snapshot) + + assert.deepEqual((service as any).markContactAsFriend('weixin:bob', { acceptStale: true }), { success: true }) + + const friends = service.getContactsPage({ acceptStale: true, pool: 'friend', page: 1, pageSize: 10 }) + assert.deepEqual( + friends.contacts.map((contact) => contact.key), + ['weixin:alice', 'weixin:bob'] + ) + assert.deepEqual(friends.stats, { friendsTotal: 2, nonFriendsTotal: 1 }) + assert.equal((friends.contacts[0] as any).friendSource, 'private') + assert.equal((friends.contacts[1] as any).friendSource, 'manual') + assert.equal(friends.contacts[1].pool, 'friend') + assert.equal(friends.contacts[1].isFriend, true) + + const groupmates = service.getContactsPage({ acceptStale: true, pool: 'non_friend', page: 1, pageSize: 10 }) + assert.deepEqual( + groupmates.contacts.map((contact) => contact.key), + ['weixin:carol'] + ) + + const search = service.getContactsPage({ + acceptStale: true, + pool: 'friend', + page: 1, + pageSize: 10, + query: 'build', + }) + assert.deepEqual( + search.contacts.map((contact) => contact.key), + ['weixin:bob'] + ) + + const detail = service.getContactDetail('weixin:bob', { acceptStale: true }) + assert.equal(detail.contact?.pool, 'friend') + assert.equal(detail.contact?.isFriend, true) + assert.equal((detail.contact as any).friendSource, 'manual') + assert.equal(snapshot.contacts[0]?.pool, 'non_friend') + + assert.deepEqual((service as any).unmarkContactAsFriend('weixin:bob'), { success: true }) + const friendsAfterUnmark = service.getContactsPage({ acceptStale: true, pool: 'friend', page: 1, pageSize: 10 }) + assert.deepEqual( + friendsAfterUnmark.contacts.map((contact) => contact.key), + ['weixin:alice'] + ) + const groupmatesAfterUnmark = service.getContactsPage({ + acceptStale: true, + pool: 'non_friend', + page: 1, + pageSize: 10, + }) + assert.deepEqual( + groupmatesAfterUnmark.contacts.map((contact) => contact.key), + ['weixin:bob', 'weixin:carol'] + ) + assert.equal((groupmatesAfterUnmark.contacts[0] as any).friendSource, undefined) +}) + +test('returns full contact detail from the selected time range snapshot', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + const service = createContactsService({ + adapter: env.adapter, + systemDir: env.dir, + runner: () => Promise.reject(new Error('not used')), + }) + const snapshot = makeRuntimeSnapshot('sig-1', 1000, '2y') + snapshot.contacts = [ + makeContact({ + key: 'weixin:alice', + displayName: 'Alice', + pool: 'friend', + sourceSessions: [ + { id: 'private-a', name: 'Private A', platform: 'weixin', type: ChatType.PRIVATE, privateMessageCount: 30 }, + ], + }), + ] + service.replaceSnapshotForTests!(snapshot) + + const response = (service as any).getContactDetail('weixin:alice', { acceptStale: true, timeRangePreset: '2y' }) + + assert.equal(response.contact.key, 'weixin:alice') + assert.equal(response.contact.sourceSessions.length, 1) + assert.equal(response.cache.status, 'stale') + assert.equal(response.timeRange.preset, '2y') +}) + +test('returns missing snapshot and starts a background contacts task without synchronous compute', async (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + let now = 1000 + + env.seed({ + id: 'private-a', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + ], + messages: privateMessages(5, 1, 1704103200), + }) + + const pending = deferred() + let runCalls = 0 + let runnerSignature = '' + const service = createContactsService({ + adapter: env.adapter, + systemDir: env.dir, + now: () => now, + runner: ({ signature }) => { + runCalls++ + runnerSignature = signature + return pending.promise + }, + }) + + const first = service.getContacts({ acceptStale: true }) + + assert.equal(runCalls, 1) + assert.match(runnerSignature, /range:1y/) + assert.equal(first.cache.status, 'missing') + assert.equal(first.contacts.length, 0) + assert.equal(first.task?.status, 'running') + assert.equal(first.task?.timeRangePreset, '1y') + + now = 2000 + pending.resolve(makeRuntimeSnapshot(runnerSignature, now)) + const finished = await waitForTaskSettled(service) + + assert.equal(finished.cache.status, 'fresh') + assert.equal(finished.cache.computedAt, 2000) + assert.equal(finished.contacts[0].key, 'weixin:alice') + assert.equal(finished.timeRange.preset, '1y') + assert.equal(finished.task?.status, 'succeeded') +}) + +test('keeps contacts snapshots isolated by time range preset', async (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'private-a', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + ], + messages: privateMessages(5, 1, 1704103200), + }) + + const pending = deferred() + let runnerSignature = '' + let runnerRange = '' + const service = createContactsService({ + adapter: env.adapter, + systemDir: env.dir, + runner: ({ signature, timeRangePreset }) => { + runnerSignature = signature + runnerRange = timeRangePreset + return pending.promise + }, + }) + + const first = service.getContacts({ acceptStale: true, timeRangePreset: '2y' }) + assert.equal(first.task?.timeRangePreset, '2y') + assert.equal(runnerRange, '2y') + assert.match(runnerSignature, /range:2y/) + + pending.resolve(makeRuntimeSnapshot(runnerSignature, 2000, '2y')) + const finished = await waitForTaskSettled(service, { timeRangePreset: '2y' }) + + assert.equal(finished.cache.status, 'fresh') + assert.equal(finished.timeRange.preset, '2y') + assert.ok(fs.existsSync(path.join(env.dir, 'contacts-snapshot-2y.json'))) + assert.equal(fs.existsSync(path.join(env.dir, 'contacts-snapshot-1y.json')), false) +}) + +test('returns stale snapshot and reuses one in-flight task after signature changes', async (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + let now = 1000 + + env.seed({ + id: 'private-a', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + ], + messages: privateMessages(5, 1, 1704103200), + }) + + const firstSnapshot = computeContactsSnapshot({ adapter: env.adapter, signature: 'old-signature', now: () => now }) + const pending = deferred() + let runCalls = 0 + const service = createContactsService({ + adapter: env.adapter, + systemDir: env.dir, + now: () => now, + runner: () => { + runCalls++ + return pending.promise + }, + }) + service.replaceSnapshotForTests!(firstSnapshot) + + now = 2000 + fs.utimesSync(env.dbPath('private-a'), new Date(), new Date(Date.now() + 5000)) + + const freshOnly = service.getContacts() + const stale = service.getContacts({ acceptStale: true }) + const recompute = service.startRecompute() + + assert.equal(runCalls, 1) + assert.equal(freshOnly.cache.status, 'stale') + assert.equal(freshOnly.contacts.length, 0) + assert.equal(freshOnly.diagnostics.privateSessionCount, 0) + assert.equal(stale.cache.status, 'stale') + assert.equal(stale.contacts[0].key, 'weixin:alice') + assert.equal(stale.task?.status, 'running') + assert.equal(recompute.task?.status, 'running') +}) + +test('does not reuse contacts snapshots across user data directories that share one system dir', async (t) => { + const sharedSystemDir = makeTempDir() + const firstEnv = new TestEnv() + const secondEnv = new TestEnv() + t.after(() => { + firstEnv.cleanup() + secondEnv.cleanup() + fs.rmSync(sharedSystemDir, { recursive: true, force: true }) + }) + + firstEnv.seed({ + id: 'private-a', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + ], + messages: privateMessages(5, 1, 1704103200), + }) + secondEnv.seed({ + id: 'private-b', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'bob' }, + ], + messages: privateMessages(5, 1, 1704103200), + }) + + let now = 1000 + const firstService = createContactsService({ + adapter: firstEnv.adapter, + pathProvider: firstEnv.pathProvider({ systemDir: sharedSystemDir, userDataDir: firstEnv.dir }), + now: () => now, + runner: ({ signature }) => Promise.resolve(makeRuntimeSnapshot(signature, now)), + }) + + firstService.getContacts({ acceptStale: true }) + const firstFinished = await waitForTaskSettled(firstService) + assert.equal(firstFinished.cache.status, 'fresh') + assert.equal(firstFinished.contacts[0].key, 'weixin:alice') + + const pending = deferred() + let secondRunCalls = 0 + now = 2000 + const secondService = createContactsService({ + adapter: secondEnv.adapter, + pathProvider: secondEnv.pathProvider({ systemDir: sharedSystemDir, userDataDir: secondEnv.dir }), + now: () => now, + runner: () => { + secondRunCalls++ + return pending.promise + }, + }) + + const secondInitial = secondService.getContacts({ acceptStale: true }) + + assert.equal(secondRunCalls, 1) + assert.equal(secondInitial.cache.status, 'missing') + assert.equal(secondInitial.contacts.length, 0) +}) + +test('preserves failed contact task until explicit recompute retry', async (t) => { + const dir = makeTempDir() + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const adapter: SessionRuntimeAdapter = { + listSessionIds: () => [], + openReadonly: () => null, + openWritable: () => null, + closeSession: () => {}, + getDbPath: () => '', + deleteSessionFile: () => false, + ensureReadonly: () => { + throw new Error('not used') + }, + ensureWritable: () => { + throw new Error('not used') + }, + } + + let runCalls = 0 + const service = createContactsService({ + adapter, + systemDir: dir, + runner: () => { + runCalls++ + return Promise.reject(new Error('worker unavailable')) + }, + }) + + const first = service.getContacts({ acceptStale: true }) + assert.equal(first.cache.status, 'missing') + assert.equal(first.task?.status, 'running') + assert.equal(runCalls, 1) + + await new Promise((resolve) => setTimeout(resolve, 0)) + const failed = service.getContacts({ acceptStale: true }) + assert.equal(failed.task?.status, 'failed') + assert.equal(failed.task?.lastError, 'worker unavailable') + + const nextGet = service.getContacts({ acceptStale: true }) + assert.equal(nextGet.task?.status, 'failed') + assert.equal(runCalls, 1) + + const retry = service.startRecompute() + assert.equal(retry.task?.status, 'running') + assert.equal(runCalls, 2) +}) + +test('close aborts an in-flight contacts task', async (t) => { + const dir = makeTempDir() + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const adapter: SessionRuntimeAdapter = { + listSessionIds: () => [], + openReadonly: () => null, + openWritable: () => null, + closeSession: () => {}, + getDbPath: () => '', + deleteSessionFile: () => false, + ensureReadonly: () => { + throw new Error('not used') + }, + ensureWritable: () => { + throw new Error('not used') + }, + } + + let taskSignal: AbortSignal | undefined + const service = createContactsService({ + adapter, + systemDir: dir, + runner: ({ signal }) => { + taskSignal = signal + return new Promise(() => {}) + }, + }) + + const first = service.getContacts({ acceptStale: true }) + assert.equal(first.task?.status, 'running') + assert.equal(taskSignal?.aborted, false) + + await service.close() + + assert.equal(taskSignal?.aborted, true) + assert.equal(service.getContacts({ acceptStale: true }).task?.status, 'failed') +}) + +test('temporary contacts worker computes and persists a fresh snapshot', async (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'private-a', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner' }, + { id: 2, platformId: 'alice' }, + ], + messages: privateMessages(5, 1, 1704103200), + }) + + const service = createContactsService({ + adapter: env.adapter, + pathProvider: env.pathProvider(), + nativeBinding, + }) + + const first = service.getContacts({ acceptStale: true }) + assert.equal(first.cache.status, 'missing') + assert.equal(first.task?.status, 'running') + + const finished = await waitForTaskSettled(service) + assert.equal(finished.cache.status, 'fresh') + assert.equal(finished.contacts[0].key, 'weixin:alice') + assert.equal(finished.timeRange.preset, '1y') + assert.equal(finished.task?.status, 'succeeded') + assert.ok(fs.existsSync(path.join(env.dir, 'contacts', 'contacts-snapshot-1y.json'))) +}) diff --git a/packages/node-runtime/src/services/contacts/service.ts b/packages/node-runtime/src/services/contacts/service.ts new file mode 100644 index 000000000..d352b86fe --- /dev/null +++ b/packages/node-runtime/src/services/contacts/service.ts @@ -0,0 +1,510 @@ +import type { PathProvider } from '@openchatlab/core' +import type { + ContactItem, + ContactListItem, + ContactPool, + ContactDetailResponse, + ContactsCacheState, + ContactsDiagnostics, + ContactsResponse, + ContactsTaskState, + ContactsTimeRangePreset, +} from '@openchatlab/shared-types' +import type { RuntimeIdentity } from '../../data-dir-compat' +import { appLogger } from '../../logging/app-logger' +import type { SessionRuntimeAdapter } from '../adapters' +import { + CONTACTS_ALGORITHM_VERSION, + createEmptyContactsDiagnostics, + type ContactsComputeProgress, + type ContactsSnapshot, +} from './compute' +import { + readContactOverrides, + writeContactOverrides, + type ContactOverrideMutationResult, + type ContactOverridesFile, +} from './overrides' +import { buildContactsSignature } from './signature' +import { cleanupContactsSnapshotTempFiles, readContactsSnapshot, writeContactsSnapshot } from './snapshot' +import { normalizeContactsTimeRangePreset, resolveContactsTimeRange } from './time-range' +import { createContactsWorkerRunner } from './worker-runner' +import { getContactsDir } from './paths' + +const DEFAULT_CONTACTS_PAGE_SIZE = 100 +const MAX_CONTACTS_PAGE_SIZE = 200 + +export interface ContactsServiceOptions { + forceRecompute?: boolean + acceptStale?: boolean + timeRangePreset?: ContactsTimeRangePreset + pool?: ContactPool + page?: number + pageSize?: number + query?: string +} + +export interface ContactsRunnerOptions { + signature: string + timeRangePreset: ContactsTimeRangePreset + onProgress: (progress: ContactsComputeProgress) => void + signal: AbortSignal +} + +export type ContactsComputeRunner = (options: ContactsRunnerOptions) => Promise + +export interface ContactsServiceDeps { + adapter: SessionRuntimeAdapter + systemDir?: string + pathProvider?: PathProvider + runtimeIdentity?: RuntimeIdentity + nativeBinding?: string + workerEntryUrl?: string | URL + runner?: ContactsComputeRunner + now?: () => number +} + +export interface ContactsService { + getContacts(options?: ContactsServiceOptions): ContactsResponse + getContactsPage(options?: ContactsServiceOptions): ContactsResponse + getContactDetail(key: string, options?: ContactsServiceOptions): ContactDetailResponse + markContactAsFriend(key: string, options?: ContactsServiceOptions): ContactOverrideMutationResult + unmarkContactAsFriend(key: string, options?: ContactsServiceOptions): ContactOverrideMutationResult + startRecompute(options?: ContactsServiceOptions): ContactsResponse + invalidateContactsCache(): void + close(): Promise + replaceSnapshotForTests?(snapshot: ContactsSnapshot): void +} + +interface InFlightTask { + id: string + signature: string + promise: Promise + abortController: AbortController +} + +export function createContactsService(deps: ContactsServiceDeps): ContactsService { + return new DefaultContactsService(deps) +} + +class DefaultContactsService implements ContactsService { + private readonly snapshots = new Map() + private inFlight: InFlightTask | null = null + private task: ContactsTaskState = createIdleTaskState() + private readonly snapshotDir: string + private readonly runner: ContactsComputeRunner + + constructor(private readonly deps: ContactsServiceDeps) { + this.snapshotDir = resolveContactsSnapshotDir(deps) + cleanupContactsSnapshotTempFiles(this.snapshotDir) + this.runner = + deps.runner ?? + createContactsWorkerRunner({ + pathProvider: requirePathProvider(deps), + runtimeIdentity: deps.runtimeIdentity, + nativeBinding: deps.nativeBinding, + workerEntryUrl: deps.workerEntryUrl, + }) + } + + getContacts(options: ContactsServiceOptions = {}): ContactsResponse { + return this.getContactsPage(options) + } + + getContactsPage(options: ContactsServiceOptions = {}): ContactsResponse { + const timeRangePreset = normalizeContactsTimeRangePreset(options.timeRangePreset) + const signature = buildContactsSignature(this.deps.adapter, timeRangePreset) + const cacheStatus = this.getCacheStatus(signature, timeRangePreset) + if (this.shouldStartTaskFromRead(options, cacheStatus)) this.ensureTaskStarted(signature, timeRangePreset) + return this.toResponse(signature, { ...options, timeRangePreset }) + } + + getContactDetail(key: string, options: ContactsServiceOptions = {}): ContactDetailResponse { + const timeRangePreset = normalizeContactsTimeRangePreset(options.timeRangePreset) + const signature = buildContactsSignature(this.deps.adapter, timeRangePreset) + const cacheStatus = this.getCacheStatus(signature, timeRangePreset) + if (this.shouldStartTaskFromRead(options, cacheStatus)) this.ensureTaskStarted(signature, timeRangePreset) + const snapshot = this.getSnapshot(timeRangePreset) + const status = this.getCacheStatus(signature, timeRangePreset) + const includeSnapshot = status === 'fresh' || (status === 'stale' && options.acceptStale === true) + const contacts = includeSnapshot ? this.getContactsWithOverrides(snapshot?.contacts ?? []) : [] + const contact = contacts.find((item) => item.key === key) ?? null + return { + contact: contact ? sanitizeContactItem(contact) : null, + algorithmVersion: includeSnapshot + ? (snapshot?.algorithmVersion ?? CONTACTS_ALGORITHM_VERSION) + : CONTACTS_ALGORITHM_VERSION, + timeRange: snapshot?.timeRange ?? resolveContactsTimeRange(timeRangePreset, null), + cache: this.toCacheState(status, snapshot), + task: this.task, + } + } + + markContactAsFriend(key: string, options: ContactsServiceOptions = {}): ContactOverrideMutationResult { + const timeRangePreset = normalizeContactsTimeRangePreset(options.timeRangePreset) + const snapshot = this.getSnapshot(timeRangePreset) + const contact = snapshot?.contacts.find((item) => item.key === key) + if (!contact) throw createContactNotFoundError(key) + if (contact.pool === 'friend') return { success: true } + + const now = this.now() + const overrides = this.readOverrides() + const existing = overrides.manualFriends[key] + overrides.manualFriends[key] = { + key, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + } + this.writeOverrides(overrides, 'mark contact as friend') + return { success: true } + } + + unmarkContactAsFriend(key: string): ContactOverrideMutationResult { + const overrides = this.readOverrides() + if (!overrides.manualFriends[key]) return { success: true } + delete overrides.manualFriends[key] + this.writeOverrides(overrides, 'unmark contact as friend') + return { success: true } + } + + startRecompute(options: ContactsServiceOptions = {}): ContactsResponse { + const timeRangePreset = normalizeContactsTimeRangePreset(options.timeRangePreset) + const signature = buildContactsSignature(this.deps.adapter, timeRangePreset) + this.ensureTaskStarted(signature, timeRangePreset) + return this.toResponse(signature, { ...options, acceptStale: true, timeRangePreset }) + } + + invalidateContactsCache(): void { + this.snapshots.clear() + } + + async close(): Promise { + const inFlight = this.inFlight + if (!inFlight) return + this.inFlight = null + inFlight.abortController.abort() + this.task = { + ...this.task, + status: 'failed', + finishedAt: this.now(), + lastError: 'contacts task aborted', + } + } + + replaceSnapshotForTests(snapshot: ContactsSnapshot): void { + this.snapshots.set(snapshot.timeRange.preset, snapshot) + } + + private shouldStartTaskFromRead(options: ContactsServiceOptions, cacheStatus: ContactsCacheState['status']): boolean { + if (options.forceRecompute) return true + if (cacheStatus === 'fresh') return false + return this.task.status !== 'failed' + } + + private ensureTaskStarted(signature: string, timeRangePreset: ContactsTimeRangePreset): void { + if (this.inFlight) return + + const taskId = `contacts_${this.now()}_${Math.random().toString(36).slice(2)}` + this.task = { + id: taskId, + status: 'running', + startedAt: this.now(), + finishedAt: null, + processedSessions: 0, + totalSessions: this.deps.adapter.listSessionIds().length, + timeRangePreset, + } + + const abortController = new AbortController() + const promise = this.runner({ + signature, + timeRangePreset, + signal: abortController.signal, + onProgress: (progress) => { + if (this.task.id !== taskId || this.task.status !== 'running') return + this.task = { + ...this.task, + processedSessions: progress.processedSessions, + totalSessions: progress.totalSessions, + currentSessionId: progress.currentSessionId, + } + }, + }) + this.inFlight = { id: taskId, signature, promise, abortController } + + promise + .then((snapshot) => this.handleTaskSuccess(taskId, signature, snapshot)) + .catch((error) => this.handleTaskFailure(taskId, error)) + } + + private handleTaskSuccess(taskId: string, inputSignature: string, snapshot: ContactsSnapshot): void { + if (this.inFlight?.id !== taskId) return + this.inFlight = null + const latestSignature = buildContactsSignature(this.deps.adapter, snapshot.timeRange.preset) + const finishedAt = this.now() + + if (inputSignature !== latestSignature || snapshot.signature !== latestSignature) { + this.task = { + ...this.task, + status: 'superseded', + finishedAt, + } + appLogger.info('contacts', 'contacts worker result discarded because signature changed', { + inputSignature, + latestSignature, + }) + return + } + + try { + writeContactsSnapshot(this.snapshotDir, snapshot) + this.snapshots.set(snapshot.timeRange.preset, snapshot) + this.task = { + ...this.task, + status: 'succeeded', + finishedAt, + processedSessions: snapshot.workerStats.processedSessions, + totalSessions: snapshot.workerStats.totalSessions, + currentSessionId: undefined, + } + appLogger.info('contacts', 'contacts worker snapshot persisted', { + contactCount: snapshot.contacts.length, + durationMs: snapshot.workerStats.durationMs, + }) + } catch (error) { + this.handleTaskFailure(taskId, error) + } + } + + private handleTaskFailure(taskId: string, error: unknown): void { + if (this.inFlight?.id === taskId) this.inFlight = null + const message = error instanceof Error ? error.message : String(error) + this.task = { + ...this.task, + status: 'failed', + finishedAt: this.now(), + lastError: message, + } + appLogger.error('contacts', 'contacts worker failed', error) + } + + private getCacheStatus(signature: string, timeRangePreset: ContactsTimeRangePreset): ContactsCacheState['status'] { + const snapshot = this.getSnapshot(timeRangePreset) + if (!snapshot) return 'missing' + return snapshot.signature === signature ? 'fresh' : 'stale' + } + + private toResponse(signature: string, options: ContactsServiceOptions = {}): ContactsResponse { + const timeRangePreset = normalizeContactsTimeRangePreset(options.timeRangePreset) + const snapshot = this.getSnapshot(timeRangePreset) + const status = this.getCacheStatus(signature, timeRangePreset) + const includeSnapshot = status === 'fresh' || (status === 'stale' && options.acceptStale === true) + const allContacts = includeSnapshot ? this.getContactsWithOverrides(snapshot?.contacts ?? []) : [] + const stats = buildContactsStats(allContacts) + const page = normalizePositiveInt(options.page, 1) + const pageSize = normalizePageSize(options.pageSize) + const query = options.query?.trim().toLowerCase() ?? '' + const filteredContacts = allContacts + .filter((contact) => { + if (options.pool && contact.pool !== options.pool) return false + if (query && !contact.searchText.includes(query)) return false + return true + }) + .sort((a, b) => compareContactsForResponse(a, b, options.pool)) + const offset = (page - 1) * pageSize + const pageContacts = filteredContacts.slice(offset, offset + pageSize).map(toContactListItem) + return { + contacts: pageContacts, + diagnostics: includeSnapshot + ? sanitizeContactsDiagnostics(snapshot?.diagnostics ?? createEmptyContactsDiagnostics()) + : createEmptyContactsDiagnostics(), + algorithmVersion: includeSnapshot + ? (snapshot?.algorithmVersion ?? CONTACTS_ALGORITHM_VERSION) + : CONTACTS_ALGORITHM_VERSION, + timeRange: snapshot?.timeRange ?? resolveContactsTimeRange(timeRangePreset, null), + cache: this.toCacheState(status, snapshot), + pagination: { + page, + pageSize, + total: filteredContacts.length, + hasMore: offset + pageContacts.length < filteredContacts.length, + }, + stats, + task: this.task, + } + } + + private toCacheState(status: ContactsCacheState['status'], snapshot: ContactsSnapshot | null): ContactsCacheState { + return { + status, + computedAt: snapshot?.computedAt ?? null, + signature: snapshot?.signature, + staleReason: status === 'stale' ? 'signature_changed' : undefined, + } + } + + private getSnapshot(timeRangePreset: ContactsTimeRangePreset): ContactsSnapshot | null { + if (!this.snapshots.has(timeRangePreset)) { + this.snapshots.set( + timeRangePreset, + readContactsSnapshot(this.snapshotDir, timeRangePreset, { now: this.deps.now }) + ) + } + return this.snapshots.get(timeRangePreset) ?? null + } + + private now(): number { + return this.deps.now?.() ?? Date.now() + } + + private getContactsWithOverrides(contacts: ContactItem[]): ContactItem[] { + return applyContactOverrides(contacts, this.readOverrides()) + } + + private readOverrides(): ContactOverridesFile { + return readContactOverrides(this.snapshotDir) + } + + private writeOverrides(overrides: ContactOverridesFile, action: string): void { + try { + writeContactOverrides(this.snapshotDir, overrides) + appLogger.info('contacts', `contact override saved: ${action}`, { + manualFriendCount: Object.keys(overrides.manualFriends).length, + }) + } catch (error) { + appLogger.error('contacts', `failed to save contact override: ${action}`, error) + throw error + } + } +} + +function applyContactOverrides(contacts: ContactItem[], overrides: ContactOverridesFile): ContactItem[] { + return contacts.map((contact) => { + if (contact.pool === 'friend') { + return { + ...contact, + isFriend: true, + pool: 'friend', + friendSource: 'private', + } + } + if (overrides.manualFriends[contact.key]) { + return { + ...contact, + isFriend: true, + pool: 'friend', + friendSource: 'manual', + } + } + const { friendSource: _friendSource, ...rest } = contact + return { + ...rest, + isFriend: false, + pool: 'non_friend', + } + }) +} + +function compareContactsForResponse(a: ContactItem, b: ContactItem, pool: ContactPool | undefined): number { + if (pool === 'friend') { + const rankDiff = friendSortRank(a) - friendSortRank(b) + if (rankDiff !== 0) return rankDiff + } + return b.score - a.score || a.displayName.localeCompare(b.displayName) +} + +function friendSortRank(contact: ContactItem): number { + return contact.friendSource === 'manual' ? 1 : 0 +} + +function buildContactsStats(contacts: ContactItem[]): { friendsTotal: number; nonFriendsTotal: number } { + let friendsTotal = 0 + let nonFriendsTotal = 0 + for (const contact of contacts) { + if (contact.pool === 'friend') friendsTotal++ + else if (contact.pool === 'non_friend') nonFriendsTotal++ + } + return { friendsTotal, nonFriendsTotal } +} + +function toContactListItem(contact: ContactItem): ContactListItem { + const item = sanitizeContactItem(contact) + const { sourceSessions: _sourceSessions, searchText: _searchText, ...listItem } = item + return listItem +} + +function sanitizeContactItem(contact: ContactItem): ContactItem { + const item: ContactItem = { + key: contact.key, + platform: contact.platform, + platformId: contact.platformId, + sessionScoped: contact.sessionScoped, + displayName: contact.displayName, + aliases: contact.aliases, + avatar: contact.avatar, + isFriend: contact.isFriend, + pool: contact.pool, + score: contact.score, + scoreBreakdown: contact.scoreBreakdown, + sourceSessions: contact.sourceSessions, + searchText: contact.searchText, + lastInteractionTs: contact.lastInteractionTs, + } + if (contact.sessionId) item.sessionId = contact.sessionId + if (contact.friendSource) item.friendSource = contact.friendSource + return item +} + +function createContactNotFoundError(key: string): Error { + return Object.assign(new Error(`Contact not found: ${key}`), { + statusCode: 404, + code: 'CONTACT_NOT_FOUND', + }) +} + +function sanitizeContactsDiagnostics(diagnostics: ContactsDiagnostics): ContactsDiagnostics { + return { + privateSessionCount: diagnostics.privateSessionCount, + activePrivateSessionCount: diagnostics.activePrivateSessionCount, + contactsEnabled: diagnostics.contactsEnabled, + skippedMissingOwnerSessions: diagnostics.skippedMissingOwnerSessions, + skippedUnresolvedOwnerSessions: diagnostics.skippedUnresolvedOwnerSessions, + skippedAmbiguousPrivateSessions: diagnostics.skippedAmbiguousPrivateSessions, + skippedInvalidPlatformIdMembers: diagnostics.skippedInvalidPlatformIdMembers, + skippedFailedSessions: diagnostics.skippedFailedSessions, + warnings: diagnostics.warnings, + } +} + +function normalizePositiveInt(value: number | undefined, fallback: number): number { + if (!Number.isFinite(value)) return fallback + return Math.max(1, Math.trunc(value!)) +} + +function normalizePageSize(value: number | undefined): number { + return Math.min(MAX_CONTACTS_PAGE_SIZE, normalizePositiveInt(value, DEFAULT_CONTACTS_PAGE_SIZE)) +} + +function createIdleTaskState(): ContactsTaskState { + return { + id: null, + status: 'idle', + startedAt: null, + finishedAt: null, + processedSessions: 0, + totalSessions: 0, + } +} + +function requirePathProvider(deps: ContactsServiceDeps): PathProvider { + if (!deps.pathProvider) { + throw new Error('contacts worker runner requires pathProvider') + } + return deps.pathProvider +} + +function resolveContactsSnapshotDir(deps: ContactsServiceDeps): string { + if (deps.pathProvider) return getContactsDir(deps.pathProvider.getUserDataDir()) + if (deps.systemDir) return deps.systemDir + throw new Error('contacts service requires systemDir or pathProvider') +} diff --git a/packages/node-runtime/src/services/contacts/signature.test.ts b/packages/node-runtime/src/services/contacts/signature.test.ts new file mode 100644 index 000000000..d6304a77c --- /dev/null +++ b/packages/node-runtime/src/services/contacts/signature.test.ts @@ -0,0 +1,90 @@ +/** + * Run: pnpm test -- packages/node-runtime/src/services/contacts/signature.test.ts + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { SessionRuntimeAdapter } from '../adapters' +import { CONTACTS_ALGORITHM_VERSION } from './compute' +import { buildContactsSignature } from './signature' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-signature-')) +} + +function createAdapter(pathsById: Map): SessionRuntimeAdapter { + return { + listSessionIds: () => [...pathsById.keys()], + openReadonly: () => { + throw new Error('signature must not open databases') + }, + openWritable: () => { + throw new Error('signature must not open databases') + }, + closeSession: () => {}, + getDbPath: (sessionId) => pathsById.get(sessionId) ?? '', + deleteSessionFile: () => false, + ensureReadonly: () => { + throw new Error('signature must not open databases') + }, + ensureWritable: () => { + throw new Error('signature must not open databases') + }, + } +} + +test('contacts signature is stable for sorted session ids and db file versions', (t) => { + const dir = makeTempDir() + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const first = path.join(dir, 'b.db') + const second = path.join(dir, 'a.db') + fs.writeFileSync(first, 'first') + fs.writeFileSync(second, 'second') + const adapter = createAdapter( + new Map([ + ['session-b', first], + ['session-a', second], + ]) + ) + + const signature = buildContactsSignature(adapter) + + assert.match(signature, new RegExp(`algorithm:${CONTACTS_ALGORITHM_VERSION}`)) + assert.match(signature, /range:1y/) + assert.ok(signature.indexOf('session-a:') < signature.indexOf('session-b:')) + assert.match(signature, /session-a:[^|]+/) + assert.match(signature, /session-b:[^|]+/) +}) + +test('contacts signature changes by time range preset', (t) => { + const dir = makeTempDir() + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const dbPath = path.join(dir, 'session.db') + fs.writeFileSync(dbPath, 'db') + const adapter = createAdapter(new Map([['session', dbPath]])) + + const recent = buildContactsSignature(adapter, '1y') + const all = buildContactsSignature(adapter, 'all') + + assert.notEqual(all, recent) + assert.match(all, /range:all/) +}) + +test('contacts signature changes when wal file version changes', (t) => { + const dir = makeTempDir() + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const dbPath = path.join(dir, 'session.db') + fs.writeFileSync(dbPath, 'db') + const adapter = createAdapter(new Map([['session', dbPath]])) + + const before = buildContactsSignature(adapter) + fs.writeFileSync(`${dbPath}-wal`, 'wal') + const after = buildContactsSignature(adapter) + + assert.notEqual(after, before) + assert.match(after, /session:[^|]+\|[^|]+/) +}) diff --git a/packages/node-runtime/src/services/contacts/signature.ts b/packages/node-runtime/src/services/contacts/signature.ts new file mode 100644 index 000000000..1f4a8d0bf --- /dev/null +++ b/packages/node-runtime/src/services/contacts/signature.ts @@ -0,0 +1,20 @@ +import { getDbFileVersion } from '../../cache/analytics-cache' +import type { SessionRuntimeAdapter } from '../adapters' +import { CONTACTS_ALGORITHM_VERSION } from './compute' +import { normalizeContactsTimeRangePreset } from './time-range' +import type { ContactsTimeRangePreset } from '@openchatlab/shared-types' + +export function buildContactsSignature( + adapter: SessionRuntimeAdapter, + timeRangePreset?: ContactsTimeRangePreset +): string { + const parts = [ + `algorithm:${CONTACTS_ALGORITHM_VERSION}`, + `range:${normalizeContactsTimeRangePreset(timeRangePreset)}`, + ] + for (const sessionId of [...adapter.listSessionIds()].sort()) { + const dbPath = adapter.getDbPath(sessionId) + parts.push(`${sessionId}:${getDbFileVersion(dbPath)}`) + } + return parts.join('|') +} diff --git a/packages/node-runtime/src/services/contacts/snapshot.test.ts b/packages/node-runtime/src/services/contacts/snapshot.test.ts new file mode 100644 index 000000000..5ea3310ca --- /dev/null +++ b/packages/node-runtime/src/services/contacts/snapshot.test.ts @@ -0,0 +1,93 @@ +/** + * Run: pnpm test -- packages/node-runtime/src/services/contacts/snapshot.test.ts + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { ContactsDiagnostics } from '@openchatlab/shared-types' +import { CONTACTS_ALGORITHM_VERSION, type ContactsSnapshot } from './compute' +import { + cleanupContactsSnapshotTempFiles, + getContactsSnapshotPath, + readContactsSnapshot, + writeContactsSnapshot, +} from './snapshot' + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-contacts-snapshot-')) +} + +function emptyDiagnostics(): ContactsDiagnostics { + return { + privateSessionCount: 0, + activePrivateSessionCount: 0, + contactsEnabled: false, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedInvalidPlatformIdMembers: 0, + skippedFailedSessions: 0, + warnings: [], + } +} + +function makeSnapshot(signature = 'sig-1'): ContactsSnapshot { + return { + contacts: [], + diagnostics: emptyDiagnostics(), + algorithmVersion: CONTACTS_ALGORITHM_VERSION, + signature, + timeRange: { + preset: '1y', + anchorTs: null, + startTs: null, + }, + computedAt: 1234, + workerStats: { + durationMs: 10, + totalSessions: 0, + processedSessions: 0, + skippedFailedSessions: 0, + }, + } +} + +test('contacts snapshot reads missing file as null and writes atomically readable json', (t) => { + const dir = makeTempDir() + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + + assert.equal(readContactsSnapshot(dir), null) + + writeContactsSnapshot(dir, makeSnapshot()) + + assert.deepEqual(readContactsSnapshot(dir), makeSnapshot()) + assert.ok(fs.existsSync(getContactsSnapshotPath(dir))) + assert.equal(fs.readdirSync(dir).filter((name) => name.startsWith('contacts-snapshot.tmp-')).length, 0) +}) + +test('contacts snapshot backs up corrupt json and returns null', (t) => { + const dir = makeTempDir() + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + fs.writeFileSync(getContactsSnapshotPath(dir), '{ broken') + + assert.equal(readContactsSnapshot(dir, '1y', { now: () => 5678 }), null) + + assert.equal(fs.existsSync(getContactsSnapshotPath(dir)), false) + assert.ok(fs.existsSync(path.join(dir, 'contacts-snapshot.corrupt-5678.json'))) +}) + +test('contacts snapshot cleanup removes stale temp files only', (t) => { + const dir = makeTempDir() + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + fs.writeFileSync(path.join(dir, 'contacts-snapshot.tmp-old'), 'tmp') + fs.writeFileSync(path.join(dir, 'contacts-snapshot-1y.json'), '{}') + + cleanupContactsSnapshotTempFiles(dir) + + assert.equal(fs.existsSync(path.join(dir, 'contacts-snapshot.tmp-old')), false) + assert.equal(fs.existsSync(path.join(dir, 'contacts-snapshot-1y.json')), true) +}) diff --git a/packages/node-runtime/src/services/contacts/snapshot.ts b/packages/node-runtime/src/services/contacts/snapshot.ts new file mode 100644 index 000000000..372884bbc --- /dev/null +++ b/packages/node-runtime/src/services/contacts/snapshot.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { ContactsTimeRangePreset } from '@openchatlab/shared-types' +import { appLogger } from '../../logging/app-logger' +import type { ContactsSnapshot } from './compute' +import { normalizeContactsTimeRangePreset } from './time-range' + +const CONTACTS_SNAPSHOT_TMP_PREFIX = 'contacts-snapshot.tmp-' + +export interface ReadContactsSnapshotOptions { + now?: () => number +} + +export function getContactsSnapshotPath(snapshotDir: string, timeRangePreset?: ContactsTimeRangePreset): string { + const preset = normalizeContactsTimeRangePreset(timeRangePreset) + return path.join(snapshotDir, `contacts-snapshot-${preset}.json`) +} + +export function readContactsSnapshot( + snapshotDir: string, + timeRangePreset?: ContactsTimeRangePreset, + options: ReadContactsSnapshotOptions = {} +): ContactsSnapshot | null { + const snapshotPath = getContactsSnapshotPath(snapshotDir, timeRangePreset) + if (!fs.existsSync(snapshotPath)) return null + + try { + return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as ContactsSnapshot + } catch (error) { + const ts = options.now?.() ?? Date.now() + const backupPath = path.join(snapshotDir, `contacts-snapshot.corrupt-${ts}.json`) + try { + fs.renameSync(snapshotPath, backupPath) + } catch (renameError) { + appLogger.warn('contacts', 'failed to backup corrupt contacts snapshot', renameError) + } + appLogger.warn('contacts', 'contacts snapshot is corrupt', error) + return null + } +} + +export function writeContactsSnapshot(snapshotDir: string, snapshot: ContactsSnapshot): void { + if (!fs.existsSync(snapshotDir)) fs.mkdirSync(snapshotDir, { recursive: true }) + const tmpPath = path.join(snapshotDir, `${CONTACTS_SNAPSHOT_TMP_PREFIX}${process.pid}-${Date.now()}`) + fs.writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2), 'utf-8') + fs.renameSync(tmpPath, getContactsSnapshotPath(snapshotDir, snapshot.timeRange.preset)) +} + +export function cleanupContactsSnapshotTempFiles(snapshotDir: string): void { + if (!fs.existsSync(snapshotDir)) return + for (const name of fs.readdirSync(snapshotDir)) { + if (!name.startsWith(CONTACTS_SNAPSHOT_TMP_PREFIX)) continue + try { + fs.rmSync(path.join(snapshotDir, name), { force: true }) + } catch (error) { + appLogger.warn('contacts', 'failed to remove contacts snapshot temp file', error) + } + } +} diff --git a/packages/node-runtime/src/services/contacts/time-range.ts b/packages/node-runtime/src/services/contacts/time-range.ts new file mode 100644 index 000000000..90978d093 --- /dev/null +++ b/packages/node-runtime/src/services/contacts/time-range.ts @@ -0,0 +1,36 @@ +import { + CONTACTS_TIME_RANGE_PRESETS, + type ContactsTimeRangePreset, + type ContactsTimeRangeState, +} from '@openchatlab/shared-types' + +const SECONDS_PER_YEAR = 365 * 24 * 60 * 60 +const DEFAULT_CONTACTS_TIME_RANGE_PRESET: ContactsTimeRangePreset = '1y' + +const YEARS_BY_PRESET: Partial> = { + '1y': 1, + '2y': 2, + '3y': 3, + '5y': 5, +} + +export function normalizeContactsTimeRangePreset(value: unknown): ContactsTimeRangePreset { + return CONTACTS_TIME_RANGE_PRESETS.includes(value as ContactsTimeRangePreset) + ? (value as ContactsTimeRangePreset) + : DEFAULT_CONTACTS_TIME_RANGE_PRESET +} + +export function resolveContactsTimeRange( + presetInput: unknown, + anchorTs: number | null | undefined +): ContactsTimeRangeState { + const preset = normalizeContactsTimeRangePreset(presetInput) + const normalizedAnchor = typeof anchorTs === 'number' ? anchorTs : null + const years = YEARS_BY_PRESET[preset] + + return { + preset, + anchorTs: normalizedAnchor, + startTs: normalizedAnchor !== null && years ? normalizedAnchor - years * SECONDS_PER_YEAR : null, + } +} diff --git a/packages/node-runtime/src/services/contacts/worker-entry.ts b/packages/node-runtime/src/services/contacts/worker-entry.ts new file mode 100644 index 000000000..e31c98215 --- /dev/null +++ b/packages/node-runtime/src/services/contacts/worker-entry.ts @@ -0,0 +1,46 @@ +import { parentPort, workerData } from 'node:worker_threads' +import type { ContactsTimeRangePreset } from '@openchatlab/shared-types' +import { DatabaseManager } from '../../database-manager' +import type { RuntimeIdentity } from '../../data-dir-compat' +import { initAppLogger } from '../../logging/app-logger' +import { StaticPathProvider, type StaticPathProviderSnapshot } from '../../semantic-index/static-path-provider' +import { createDatabaseManagerAdapter } from '../adapters' +import { computeContactsSnapshot, type ContactsComputeProgress } from './compute' +import { getContactsFactsCacheDir } from './paths' + +interface ContactsWorkerStartupOptions { + paths: StaticPathProviderSnapshot + runtimeIdentity?: RuntimeIdentity + nativeBinding?: string + signature: string + timeRangePreset?: ContactsTimeRangePreset +} + +async function main(): Promise { + if (!parentPort) throw new Error('contacts worker requires parentPort') + const options = workerData as ContactsWorkerStartupOptions + initAppLogger(options.paths.logsDir) + const pathProvider = new StaticPathProvider(options.paths) + const dbManager = new DatabaseManager(pathProvider, { + nativeBinding: options.nativeBinding, + runtime: options.runtimeIdentity, + allowMissingRuntimeForTests: !options.runtimeIdentity, + }) + const adapter = createDatabaseManagerAdapter(dbManager) + const onProgress = (progress: ContactsComputeProgress) => parentPort?.postMessage({ type: 'progress', progress }) + const snapshot = computeContactsSnapshot({ + adapter, + signature: options.signature, + timeRangePreset: options.timeRangePreset, + factsCacheDir: getContactsFactsCacheDir(pathProvider.getUserDataDir()), + onProgress, + }) + parentPort.postMessage({ type: 'success', snapshot }) +} + +main().catch((error) => { + parentPort?.postMessage({ + type: 'error', + error: error instanceof Error ? error.message : String(error), + }) +}) diff --git a/packages/node-runtime/src/services/contacts/worker-runner.test.ts b/packages/node-runtime/src/services/contacts/worker-runner.test.ts new file mode 100644 index 000000000..226efca63 --- /dev/null +++ b/packages/node-runtime/src/services/contacts/worker-runner.test.ts @@ -0,0 +1,36 @@ +/** + * Run: pnpm test -- packages/node-runtime/src/services/contacts/worker-runner.test.ts + */ + +import assert from 'node:assert/strict' +import test from 'node:test' +import { resolveDefaultContactsWorkerEntryUrl } from './worker-runner' + +test('contacts worker runner resolves source worker entry in TypeScript dev mode', () => { + const entry = resolveDefaultContactsWorkerEntryUrl( + 'file:///repo/packages/node-runtime/src/services/contacts/worker-runner.ts' + ) + + assert.equal(entry.href, 'file:///repo/packages/node-runtime/src/services/contacts/worker-entry.ts') +}) + +test('contacts worker runner resolves bundled CLI worker entry next to mjs bundle', () => { + const entry = resolveDefaultContactsWorkerEntryUrl('file:///app/cli/dist/index.mjs') + + assert.equal(entry.href, 'file:///app/cli/dist/contacts-worker.mjs') +}) + +test('contacts worker runner resolves bundled Desktop worker entry when sibling source entry is absent', () => { + const entry = resolveDefaultContactsWorkerEntryUrl('file:///app/dist/main/index.js', () => false) + + assert.equal(entry.href, 'file:///app/dist/main/contacts-worker.js') +}) + +test('contacts worker runner keeps node-runtime sibling worker entry when it exists', () => { + const entry = resolveDefaultContactsWorkerEntryUrl( + 'file:///repo/packages/node-runtime/dist/services/contacts/worker-runner.js', + (url) => url.href.endsWith('/worker-entry.js') + ) + + assert.equal(entry.href, 'file:///repo/packages/node-runtime/dist/services/contacts/worker-entry.js') +}) diff --git a/packages/node-runtime/src/services/contacts/worker-runner.ts b/packages/node-runtime/src/services/contacts/worker-runner.ts new file mode 100644 index 000000000..3db1ca094 --- /dev/null +++ b/packages/node-runtime/src/services/contacts/worker-runner.ts @@ -0,0 +1,125 @@ +import { Worker } from 'node:worker_threads' +import type { WorkerOptions } from 'node:worker_threads' +import { existsSync } from 'node:fs' +import type { PathProvider } from '@openchatlab/core' +import type { RuntimeIdentity } from '../../data-dir-compat' +import { snapshotPathProvider } from '../../semantic-index/static-path-provider' +import type { ContactsComputeProgress, ContactsSnapshot } from './compute' +import type { ContactsComputeRunner } from './service' + +export interface ContactsWorkerRunnerOptions { + pathProvider: PathProvider + runtimeIdentity?: RuntimeIdentity + nativeBinding?: string + workerEntryUrl?: string | URL +} + +interface ContactsWorkerMessage { + type: 'progress' | 'success' | 'error' + progress?: ContactsComputeProgress + snapshot?: ContactsSnapshot + error?: string +} + +type ModuleWorkerOptions = WorkerOptions & { type: 'module' } +type EntryExists = (url: URL) => boolean + +export function resolveDefaultContactsWorkerEntryUrl( + currentModuleUrl: string | URL = import.meta.url, + entryExists: EntryExists = (url) => existsSync(url) +): URL { + const moduleUrl = typeof currentModuleUrl === 'string' ? currentModuleUrl : currentModuleUrl.href + if (moduleUrl.endsWith('.ts')) return new URL('./worker-entry.ts', moduleUrl) + if (moduleUrl.endsWith('.mjs')) return new URL('./contacts-worker.mjs', moduleUrl) + + const siblingWorkerEntry = new URL('./worker-entry.js', moduleUrl) + return entryExists(siblingWorkerEntry) ? siblingWorkerEntry : new URL('./contacts-worker.js', moduleUrl) +} + +function normalizeWorkerEntryUrl(entryUrl?: string | URL): URL { + if (!entryUrl) return resolveDefaultContactsWorkerEntryUrl() + return typeof entryUrl === 'string' ? new URL(entryUrl) : entryUrl +} + +function createWorker(workerData: unknown, entryUrlInput?: string | URL): Worker { + const entryUrl = normalizeWorkerEntryUrl(entryUrlInput) + if (!entryUrl.href.endsWith('.ts')) return new Worker(entryUrl, { workerData }) + + const bootstrap = ` + import { register } from 'tsx/esm/api'; + register(); + await import(${JSON.stringify(entryUrl.href)}); + ` + const options: ModuleWorkerOptions = { + eval: true, + type: 'module', + workerData, + execArgv: [], + } + return new Worker(bootstrap, options) +} + +export function createContactsWorkerRunner(options: ContactsWorkerRunnerOptions): ContactsComputeRunner { + return ({ signature, timeRangePreset, signal, onProgress }) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(createAbortError()) + return + } + + const worker = createWorker( + { + paths: snapshotPathProvider(options.pathProvider), + runtimeIdentity: options.runtimeIdentity, + nativeBinding: options.nativeBinding, + signature, + timeRangePreset, + }, + options.workerEntryUrl + ) + let settled = false + const abort = () => { + if (settled) return + settled = true + void worker.terminate() + reject(createAbortError()) + } + signal.addEventListener('abort', abort, { once: true }) + + worker.on('message', (message: ContactsWorkerMessage) => { + if (message.type === 'progress' && message.progress) { + onProgress(message.progress) + return + } + if (message.type === 'success' && message.snapshot) { + settled = true + signal.removeEventListener('abort', abort) + resolve(message.snapshot) + void worker.terminate() + return + } + if (message.type === 'error') { + settled = true + signal.removeEventListener('abort', abort) + reject(new Error(message.error ?? 'contacts worker failed')) + void worker.terminate() + } + }) + worker.on('error', (error) => { + if (settled) return + settled = true + signal.removeEventListener('abort', abort) + reject(error) + }) + worker.on('exit', (code) => { + if (settled || code === 0) return + settled = true + signal.removeEventListener('abort', abort) + reject(new Error(`contacts worker exited with code ${code}`)) + }) + }) +} + +function createAbortError(): Error { + return new Error('contacts worker aborted') +} diff --git a/packages/node-runtime/src/services/export-service.ts b/packages/node-runtime/src/services/export-service.ts new file mode 100644 index 000000000..2410e600a --- /dev/null +++ b/packages/node-runtime/src/services/export-service.ts @@ -0,0 +1,45 @@ +/** + * Shared export service. + * + * Wraps node-runtime's exporters with adapter-based DB opening. + */ + +import { exportFilterResultToMarkdown, exportWithFormat, type ExportFilterParams, type ExportResult } from '../export' +import type { ExportFormat, FormatExportResult } from '../export' +import type { SessionRuntimeAdapter } from './adapters' + +export function exportMarkdown( + adapter: SessionRuntimeAdapter, + params: ExportFilterParams +): { result: ExportResult; content: string } { + const chunks: string[] = [] + const result = exportFilterResultToMarkdown( + params, + { + openDatabase(sessionId: string) { + return adapter.openReadonly(sessionId) + }, + }, + { + write(chunk: string) { + chunks.push(chunk) + }, + end() { + /* collected in chunks array */ + }, + } + ) + return { result, content: chunks.join('') } +} + +export function exportFormatted( + adapter: SessionRuntimeAdapter, + params: { + sessionId: string + sessionName: string + format: ExportFormat + timeFilter?: { startTs: number; endTs: number } + } +): FormatExportResult { + return exportWithFormat(params, (sessionId) => adapter.openReadonly(sessionId)) +} diff --git a/packages/node-runtime/src/services/index.ts b/packages/node-runtime/src/services/index.ts new file mode 100644 index 000000000..37186a96a --- /dev/null +++ b/packages/node-runtime/src/services/index.ts @@ -0,0 +1,83 @@ +// Adapters +export type { SessionRuntimeAdapter } from './adapters' +export { createDatabaseManagerAdapter } from './adapters' + +// Session service +export { + listAnalysisSessions, + getAnalysisSession, + renameSession, + updateSessionOwnerId, + deleteSession, +} from './session-service' +export type { AnalysisSessionDTO, ListSessionsOptions } from './session-service' + +// Member service +export { + getMembers, + getMembersPaginated, + updateMemberAliases, + mergeMembers, + deleteMember, + getMemberNameHistory, +} from './member-service' +export type { MembersPaginatedDTO } from './member-service' + +// Owner profile service +export { + tryApplyOwnerProfile, + setOwnerAndApplyProfile, + dismissOwnerPrompt, + clearSessionOwner, +} from './owner-profile-service' +export type { + ApplyOwnerProfileReason, + ApplyOwnerProfileResult, + SetOwnerAndApplyProfileResult, +} from './owner-profile-service' + +// Session index service +export { + generateIndex, + generateIncrementalIndex, + clearIndex, + getFtsStatus, + searchFts, + rebuildFts, + getAllIndexStats, +} from './session-index-service' +export type { SessionIndexStatusItem } from './session-index-service' + +// Summary service +export { generateSummary, generateAllSummaries } from './summary-service' +export type { LlmConfig, SummaryServiceDeps } from './summary-service' + +// Export service +export { exportMarkdown } from './export-service' + +// Contacts service +export { CONTACTS_ALGORITHM_VERSION, createContactsService } from './contacts' +export type { ContactsComputeRunner, ContactsService, ContactsServiceDeps, ContactsServiceOptions } from './contacts' + +// People relationships service +export { PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, createPeopleRelationshipsService } from './people/relationships' +export type { + PeopleRelationshipsComputeRunner, + PeopleRelationshipsService, + PeopleRelationshipsServiceDeps, + PeopleRelationshipsServiceOptions, +} from './people/relationships' + +// Merge cache +export { MergeSessionCache } from './merge-cache' + +// Push import (POST /api/v1/imports/:sessionId) +export { pushImport } from './push-importer' +export type { + PushImportPayload, + PushImportResult, + PushImportOutcome, + PushImportMessage, + PushImportMember, + PushImportMeta, +} from './push-importer' diff --git a/packages/node-runtime/src/services/member-service.ts b/packages/node-runtime/src/services/member-service.ts new file mode 100644 index 000000000..5a76c162c --- /dev/null +++ b/packages/node-runtime/src/services/member-service.ts @@ -0,0 +1,105 @@ +/** + * Shared member service layer. + * + * Delegates to @openchatlab/core member-ops and query functions, + * ensuring CLI Web and Electron use identical business semantics + * (merge strategy, delete scope, name history source, etc.). + */ + +import { + getMembersWithAliases as coreGetMembersWithAliases, + getMembersPaginated as coreGetMembersPaginated, + getMemberNameHistory as coreGetMemberNameHistory, + updateMemberAliases as coreUpdateMemberAliases, + mergeMembers as coreMergeMembers, + deleteMember as coreDeleteMember, +} from '@openchatlab/core' +import type { MemberWithAliases, MembersPaginationParams, MembersPaginatedResult } from '@openchatlab/core' +import type { SessionRuntimeAdapter } from './adapters' + +export type { MemberWithAliases, MembersPaginationParams, MembersPaginatedResult } + +export interface MembersPaginatedDTO { + items: MemberWithAliases[] + total: number + page: number + pageSize: number + totalPages: number +} + +/** + * Get all members with aliases for a session. + */ +export function getMembers(adapter: SessionRuntimeAdapter, sessionId: string): MemberWithAliases[] { + const db = adapter.ensureReadonly(sessionId) + return coreGetMembersWithAliases(db) +} + +/** + * Get paginated member list with search and sort. + */ +export function getMembersPaginated( + adapter: SessionRuntimeAdapter, + sessionId: string, + params: MembersPaginationParams +): MembersPaginatedDTO { + const db = adapter.ensureReadonly(sessionId) + const result = coreGetMembersPaginated(db, params) + return { + items: result.members, + total: result.total, + page: result.page, + pageSize: result.pageSize, + totalPages: result.totalPages, + } +} + +/** + * Update member aliases. + * Uses core's updateMemberAliases which properly serializes the JSON array. + */ +export function updateMemberAliases( + adapter: SessionRuntimeAdapter, + sessionId: string, + memberId: number, + aliases: string[] +): boolean { + const db = adapter.ensureWritable(sessionId) + return coreUpdateMemberAliases(db, memberId, aliases) +} + +/** + * Merge two members using core semantics: + * - Primary determined by message count (higher wins, lower id on tie) + * - Merges aliases, avatar, account_name, group_nickname + * - Reassigns messages and member_name_history + * - Updates meta.owner_id if secondary was owner + * - Runs in a transaction + */ +export function mergeMembers( + adapter: SessionRuntimeAdapter, + sessionId: string, + memberId1: number, + memberId2: number +): boolean { + const db = adapter.ensureWritable(sessionId) + return coreMergeMembers(db, memberId1, memberId2) +} + +/** + * Delete a member and all associated data: + * - messages, member_name_history, member row (in transaction) + */ +export function deleteMember(adapter: SessionRuntimeAdapter, sessionId: string, memberId: number): boolean { + const db = adapter.ensureWritable(sessionId) + return coreDeleteMember(db, memberId) +} + +/** + * Get member name change history. + * Core prefers member_name_history and falls back to message-derived names for legacy sessions. + */ +export function getMemberNameHistory(adapter: SessionRuntimeAdapter, sessionId: string, memberId: number) { + const db = adapter.ensureReadonly(sessionId) + return coreGetMemberNameHistory(db, memberId) +} diff --git a/packages/node-runtime/src/services/merge-cache.ts b/packages/node-runtime/src/services/merge-cache.ts new file mode 100644 index 000000000..da47992e3 --- /dev/null +++ b/packages/node-runtime/src/services/merge-cache.ts @@ -0,0 +1,99 @@ +/** + * Server-side merge session cache. + * + * Manages temporary databases for the multi-file merge workflow. + * Each uploaded/parsed file gets a temp DB handle that persists + * across HTTP requests until merge completes or cache is cleared. + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import type { PathProvider, DatabaseAdapter } from '@openchatlab/core' +import { openBetterSqliteDatabase } from '../better-sqlite3-adapter' +import { TempDbReader, deleteTempDatabase, cleanupTempDatabases } from '../merger/temp-db' + +interface CacheEntry { + tempDbPath: string + filename: string + createdAt: number +} + +export class MergeSessionCache { + private cache = new Map() + private tempDir: string + private nativeBinding?: string + + constructor(pathProvider: PathProvider, options?: { nativeBinding?: string }) { + this.tempDir = pathProvider.getTempDir() + this.nativeBinding = options?.nativeBinding + if (!fs.existsSync(this.tempDir)) { + fs.mkdirSync(this.tempDir, { recursive: true }) + } + } + + generateTempDbPath(filename: string): string { + const ts = Date.now() + const rand = Math.random().toString(36).slice(2, 8) + const safeName = filename.replace(/[/\\?%*:|"<>]/g, '_').slice(0, 40) + return path.join(this.tempDir, `merge_${safeName}_${ts}_${rand}.db`) + } + + /** Store a parsed file's temp DB and return a handle for subsequent requests. */ + store(filename: string, tempDbPath: string): string { + const handle = crypto.randomUUID() + this.cache.set(handle, { tempDbPath, filename, createdAt: Date.now() }) + return handle + } + + /** Open a TempDbReader for a given handle. Caller must close the reader. */ + openReader(handle: string): { reader: TempDbReader; filename: string } | null { + const entry = this.cache.get(handle) + if (!entry || !fs.existsSync(entry.tempDbPath)) return null + const adapter = openBetterSqliteDatabase(entry.tempDbPath, { + readonly: true, + nativeBinding: this.nativeBinding, + }) + return { reader: new TempDbReader(adapter), filename: entry.filename } + } + + /** Create a writable temp DB for streaming parse. */ + createTempDatabase(filename: string): { db: DatabaseAdapter; tempDbPath: string } { + const tempDbPath = this.generateTempDbPath(filename) + const adapter = openBetterSqliteDatabase(tempDbPath, { nativeBinding: this.nativeBinding }) + return { db: adapter, tempDbPath } + } + + /** Remove a single entry and its temp DB file. */ + delete(handle: string): void { + const entry = this.cache.get(handle) + if (entry) { + deleteTempDatabase(entry.tempDbPath) + this.cache.delete(handle) + } + } + + /** Clear all entries and delete their temp DB files. */ + clear(): void { + for (const entry of this.cache.values()) { + deleteTempDatabase(entry.tempDbPath) + } + this.cache.clear() + } + + /** Cleanup orphan temp DBs from previous runs. */ + cleanupOrphans(): void { + cleanupTempDatabases(this.tempDir) + } + + /** Auto-cleanup entries older than maxAge (default 1 hour). */ + cleanupExpired(maxAgeMs = 3600_000): void { + const now = Date.now() + for (const [handle, entry] of this.cache) { + if (now - entry.createdAt > maxAgeMs) { + deleteTempDatabase(entry.tempDbPath) + this.cache.delete(handle) + } + } + } +} diff --git a/packages/node-runtime/src/services/owner-profile-service.test.ts b/packages/node-runtime/src/services/owner-profile-service.test.ts new file mode 100644 index 000000000..8f557e65a --- /dev/null +++ b/packages/node-runtime/src/services/owner-profile-service.test.ts @@ -0,0 +1,318 @@ +/** + * Integration tests for the owner profile service, against real SQLite + * session databases and a real preferences.json file. + * + * Run: pnpm test -- packages/node-runtime/src/services/owner-profile-service.test.ts + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { CHAT_DB_SCHEMA, getSessionMeta } from '@openchatlab/core' +import type { DatabaseAdapter } from '@openchatlab/core' +import { openBetterSqliteDatabase } from '../better-sqlite3-adapter' +import { PreferencesManager } from '../preferences' +import type { SessionRuntimeAdapter } from './adapters' +import { + tryApplyOwnerProfile, + setOwnerAndApplyProfile, + dismissOwnerPrompt, + clearSessionOwner, +} from './owner-profile-service' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-owner-profile-')) +} + +interface SeedMember { + platformId: string + accountName?: string + groupNickname?: string + aliases?: string[] +} + +interface SeedSession { + id: string + platform: string + type?: 'group' | 'private' + ownerId?: string | null + members: SeedMember[] +} + +class TestEnv { + readonly dir: string + readonly preferences: PreferencesManager + readonly adapter: SessionRuntimeAdapter + private dbPaths = new Map() + private openDbs: DatabaseAdapter[] = [] + + constructor() { + this.dir = makeTempDir() + this.preferences = new PreferencesManager(this.dir) + const open = (sessionId: string, readonly: boolean): DatabaseAdapter | null => { + const dbPath = this.dbPaths.get(sessionId) + if (!dbPath) return null + const db = openBetterSqliteDatabase(dbPath, { readonly, nativeBinding }) + this.openDbs.push(db) + return db + } + this.adapter = { + listSessionIds: () => [...this.dbPaths.keys()], + openReadonly: (id) => open(id, true), + openWritable: (id) => open(id, false), + closeSession: () => {}, + getDbPath: (id) => this.dbPaths.get(id) ?? '', + deleteSessionFile: () => false, + ensureReadonly: (id) => { + const db = open(id, true) + if (!db) throw Object.assign(new Error(`Session not found: ${id}`), { statusCode: 404 }) + return db + }, + ensureWritable: (id) => { + const db = open(id, false) + if (!db) throw Object.assign(new Error(`Session not found: ${id}`), { statusCode: 404 }) + return db + }, + } + } + + seed(session: SeedSession): void { + const dbPath = path.join(this.dir, `${session.id}.db`) + const db = openBetterSqliteDatabase(dbPath, { nativeBinding }) + db.exec(CHAT_DB_SCHEMA) + db.prepare(`INSERT INTO meta (name, platform, type, imported_at, owner_id) VALUES (?, ?, ?, ?, ?)`).run( + session.id, + session.platform, + session.type ?? 'group', + 1780000000, + session.ownerId ?? null + ) + for (const member of session.members) { + db.prepare(`INSERT INTO member (platform_id, account_name, group_nickname, aliases) VALUES (?, ?, ?, ?)`).run( + member.platformId, + member.accountName ?? null, + member.groupNickname ?? null, + JSON.stringify(member.aliases ?? []) + ) + } + db.close() + this.dbPaths.set(session.id, dbPath) + } + + ownerOf(sessionId: string): string | null { + const db = this.adapter.ensureReadonly(sessionId) + return getSessionMeta(db)?.ownerId ?? null + } + + cleanup(): void { + for (const db of this.openDbs) { + try { + db.close() + } catch { + // already closed + } + } + fs.rmSync(this.dir, { recursive: true, force: true }) + } +} + +test('setOwnerAndApplyProfile writes owner, saves platform profile and merges names', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 's1', + platform: 'whatsapp', + members: [ + { platformId: 'Alice', accountName: 'Alice', groupNickname: 'Ali', aliases: ['Allie'] }, + { platformId: 'Bob' }, + ], + }) + + const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice') + assert.equal(result.ownerId, 'Alice') + assert.equal(result.platform, 'whatsapp') + assert.deepEqual(result.updatedSessionIds, []) + assert.deepEqual(result.updatedSessionOwnerIds, {}) + assert.equal(env.ownerOf('s1'), 'Alice') + + env.preferences.invalidateCache() + const profile = env.preferences.load().ownerProfilesByPlatform['whatsapp'] + assert.ok(profile) + assert.equal(profile.platformId, 'Alice') + assert.equal(profile.matchMode, 'name') + assert.deepEqual(profile.confirmedNames, ['Alice', 'Ali', 'Allie']) +}) + +test('setOwnerAndApplyProfile batch-applies to unowned same-platform sessions only', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 'current', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] }) + // Name fallback match (different platformId, same normalized name) + env.seed({ id: 'name-match', platform: 'whatsapp', members: [{ platformId: 'alice' }, { platformId: 'Carol' }] }) + // Already owned: never overridden + env.seed({ id: 'owned', platform: 'whatsapp', ownerId: 'Bob', members: [{ platformId: 'Alice' }] }) + // Different platform: untouched + env.seed({ id: 'other-platform', platform: 'telegram', members: [{ platformId: 'Alice' }] }) + // Ambiguous: two members normalize to the same confirmed name + env.seed({ + id: 'ambiguous', + platform: 'whatsapp', + members: [{ platformId: 'alice' }, { platformId: 'u2', accountName: 'ALICE' }], + }) + // No match + env.seed({ id: 'no-match', platform: 'whatsapp', members: [{ platformId: 'Dave' }] }) + + const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'current', 'Alice') + assert.deepEqual(result.updatedSessionIds, ['name-match']) + // name-match session has platformId 'alice' (lowercase) — must differ from source ownerId 'Alice' + assert.deepEqual(result.updatedSessionOwnerIds, { 'name-match': 'alice' }) + assert.equal(env.ownerOf('name-match'), 'alice') + assert.equal(env.ownerOf('owned'), 'Bob') + assert.equal(env.ownerOf('other-platform'), null) + assert.equal(env.ownerOf('ambiguous'), null) + assert.equal(env.ownerOf('no-match'), null) +}) + +test('re-selecting a different member replaces the profile instead of merging names', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] }) + + setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice') + setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Bob') + + env.preferences.invalidateCache() + const profile = env.preferences.load().ownerProfilesByPlatform['whatsapp'] + assert.equal(profile.platformId, 'Bob') + assert.deepEqual(profile.confirmedNames, ['Bob']) +}) + +test('setOwnerAndApplyProfile rejects a platformId that is not a session member', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }] }) + assert.throws(() => setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Nobody'), /Member not found/) +}) + +test('tryApplyOwnerProfile applies stored profile and reports reasons', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 'source', platform: 'whatsapp', members: [{ platformId: 'Alice' }] }) + env.seed({ id: 'unowned', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] }) + env.seed({ id: 'no-profile', platform: 'telegram', members: [{ platformId: 'Alice' }] }) + + // No profile saved yet + assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'unowned'), { + applied: false, + reason: 'no_profile', + dismissed: false, + }) + + setOwnerAndApplyProfile(env.adapter, env.preferences, 'source', 'Alice') + // 'unowned' was already auto-filled by batch apply + assert.equal(env.ownerOf('unowned'), 'Alice') + assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'unowned'), { + applied: false, + ownerId: 'Alice', + reason: 'already_set', + dismissed: false, + }) + + // New session imported later: profile applies on demand + env.seed({ id: 'later', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Carol' }] }) + const applied = tryApplyOwnerProfile(env.adapter, env.preferences, 'later') + assert.deepEqual(applied, { applied: true, ownerId: 'Alice', dismissed: false }) + assert.equal(env.ownerOf('later'), 'Alice') + + assert.deepEqual(tryApplyOwnerProfile(env.adapter, env.preferences, 'no-profile'), { + applied: false, + reason: 'no_profile', + dismissed: false, + }) + assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 'missing').reason, 'missing_session') +}) + +test('dismissOwnerPrompt persists and is cleared by manual owner selection', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }] }) + + dismissOwnerPrompt(env.preferences, 's1') + dismissOwnerPrompt(env.preferences, 's1') + env.preferences.invalidateCache() + assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, ['s1']) + assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 's1').dismissed, true) + + setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice') + env.preferences.invalidateCache() + assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, []) +}) + +test('batch apply removes auto-filled sessions from the dismissed list', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 'current', platform: 'whatsapp', members: [{ platformId: 'Alice' }] }) + env.seed({ id: 'other', platform: 'whatsapp', members: [{ platformId: 'Alice' }, { platformId: 'Bob' }] }) + + dismissOwnerPrompt(env.preferences, 'other') + const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'current', 'Alice') + assert.deepEqual(result.updatedSessionIds, ['other']) + + env.preferences.invalidateCache() + assert.deepEqual(env.preferences.load().ownerPromptDismissedSessionIds, []) +}) + +test('clearSessionOwner clears the session owner but keeps the platform profile', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 's1', platform: 'whatsapp', members: [{ platformId: 'Alice' }] }) + setOwnerAndApplyProfile(env.adapter, env.preferences, 's1', 'Alice') + assert.equal(env.ownerOf('s1'), 'Alice') + + clearSessionOwner(env.adapter, 's1') + assert.equal(env.ownerOf('s1'), null) + + env.preferences.invalidateCache() + assert.ok(env.preferences.load().ownerProfilesByPlatform['whatsapp']) +}) + +test('exact platformId match works on platforms without name fallback', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ id: 'source', platform: 'weixin', members: [{ platformId: 'wx_me', accountName: 'Me' }] }) + env.seed({ + id: 'other', + platform: 'weixin', + members: [ + { platformId: 'wx_me', accountName: 'Renamed' }, + { platformId: 'wx_other', accountName: 'Me' }, + ], + }) + + const result = setOwnerAndApplyProfile(env.adapter, env.preferences, 'source', 'wx_me') + assert.equal(result.platform, 'weixin') + assert.deepEqual(result.updatedSessionIds, ['other']) + assert.deepEqual(result.updatedSessionOwnerIds, { other: 'wx_me' }) + assert.equal(env.ownerOf('other'), 'wx_me') + + env.preferences.invalidateCache() + assert.equal(env.preferences.load().ownerProfilesByPlatform['weixin'].matchMode, 'platform_id') + + // Name-only coincidence must NOT match on weixin + env.seed({ id: 'name-only', platform: 'weixin', members: [{ platformId: 'wx_x', accountName: 'Me' }] }) + assert.equal(tryApplyOwnerProfile(env.adapter, env.preferences, 'name-only').reason, 'no_match') +}) diff --git a/packages/node-runtime/src/services/owner-profile-service.ts b/packages/node-runtime/src/services/owner-profile-service.ts new file mode 100644 index 000000000..792433556 --- /dev/null +++ b/packages/node-runtime/src/services/owner-profile-service.ts @@ -0,0 +1,184 @@ +/** + * Shared owner profile service. + * + * Manages the platform-level "who am I" identity: writes per-session + * meta.owner_id, maintains ownerProfilesByPlatform in preferences.json, and + * applies a confirmed profile to other unowned sessions of the same platform. + * Used by both CLI Web routes and Electron IPC. + */ + +import { + getSessionMeta, + getMembersWithAliases, + isChatSessionDb, + updateSessionOwnerId as coreUpdateSessionOwnerId, + matchOwnerProfile, + mergeConfirmedNames, + isNameMatchPlatform, +} from '@openchatlab/core' +import type { MemberWithAliases } from '@openchatlab/core' +import type { + OwnerProfile, + ApplyOwnerProfileReason, + ApplyOwnerProfileResult, + SetOwnerAndApplyProfileResult, +} from '@openchatlab/shared-types' +import type { PreferencesManager } from '../preferences' +import type { SessionRuntimeAdapter } from './adapters' + +export type { ApplyOwnerProfileReason, ApplyOwnerProfileResult, SetOwnerAndApplyProfileResult } + +function memberDisplayName(member: MemberWithAliases): string { + return member.groupNickname || member.accountName || member.platformId +} + +function isDismissed(preferences: PreferencesManager, sessionId: string): boolean { + return preferences.load().ownerPromptDismissedSessionIds.includes(sessionId) +} + +/** + * Try to apply the stored platform owner profile to one session. + * Writes meta.owner_id only on a unique (exact or name) match. + * Never overrides an existing owner. + */ +export function tryApplyOwnerProfile( + adapter: SessionRuntimeAdapter, + preferences: PreferencesManager, + sessionId: string +): ApplyOwnerProfileResult { + const dismissed = isDismissed(preferences, sessionId) + + const db = adapter.openReadonly(sessionId) + if (!db || !isChatSessionDb(db)) { + return { applied: false, reason: 'missing_session', dismissed } + } + const meta = getSessionMeta(db) + if (!meta) { + return { applied: false, reason: 'missing_session', dismissed } + } + if (meta.ownerId) { + return { applied: false, ownerId: meta.ownerId, reason: 'already_set', dismissed } + } + + const profile = preferences.load().ownerProfilesByPlatform[meta.platform] + if (!profile) { + return { applied: false, reason: 'no_profile', dismissed } + } + + const result = matchOwnerProfile(meta.platform, profile, getMembersWithAliases(db)) + if (result.type === 'exact' || result.type === 'name') { + const writable = adapter.ensureWritable(sessionId) + coreUpdateSessionOwnerId(writable, result.platformId) + return { applied: true, ownerId: result.platformId, dismissed } + } + return { applied: false, reason: result.type === 'ambiguous' ? 'ambiguous' : 'no_match', dismissed } +} + +/** + * Manually set the owner of one session, update the platform profile, and + * batch-apply the profile to other unowned sessions of the same platform. + * + * Re-selecting the same identity merges new names into confirmedNames; + * selecting a different member replaces the profile (names start fresh, + * so the previous person's names cannot cause wrong matches). + */ +export function setOwnerAndApplyProfile( + adapter: SessionRuntimeAdapter, + preferences: PreferencesManager, + sessionId: string, + ownerPlatformId: string +): SetOwnerAndApplyProfileResult { + const db = adapter.ensureWritable(sessionId) + const meta = getSessionMeta(db) + if (!meta) { + throw Object.assign(new Error(`Session has no meta: ${sessionId}`), { statusCode: 404 }) + } + + const members = getMembersWithAliases(db) + const selected = members.find((m) => m.platformId === ownerPlatformId) + if (!selected) { + throw Object.assign(new Error(`Member not found in session: ${ownerPlatformId}`), { statusCode: 400 }) + } + + coreUpdateSessionOwnerId(db, ownerPlatformId) + + const prefs = preferences.load() + const existing = prefs.ownerProfilesByPlatform[meta.platform] + const baseNames = existing && existing.platformId === ownerPlatformId ? existing.confirmedNames : [] + const profile: OwnerProfile = { + platformId: ownerPlatformId, + displayName: memberDisplayName(selected), + confirmedNames: mergeConfirmedNames(baseNames, { ...selected, displayName: memberDisplayName(selected) }), + matchMode: isNameMatchPlatform(meta.platform) ? 'name' : 'platform_id', + updatedAt: Date.now(), + } + + const updatedSessions = applyProfileToOtherSessions(adapter, meta.platform, profile, sessionId) + const updatedSessionIds = updatedSessions.map((s) => s.id) + const updatedSessionOwnerIds: Record = {} + for (const s of updatedSessions) { + updatedSessionOwnerIds[s.id] = s.ownerId + } + + const noLongerDismissed = new Set([sessionId, ...updatedSessionIds]) + preferences.save({ + ownerProfilesByPlatform: { ...prefs.ownerProfilesByPlatform, [meta.platform]: profile }, + ownerPromptDismissedSessionIds: prefs.ownerPromptDismissedSessionIds.filter((id) => !noLongerDismissed.has(id)), + }) + + return { sessionId, platform: meta.platform, ownerId: ownerPlatformId, updatedSessionIds, updatedSessionOwnerIds } +} + +/** + * Apply a profile to all other unowned sessions of the same platform. + * Unique match only; sessions with an existing owner are never touched. + * Returns the id and the actual platformId written for each updated session + * (may differ from profile.platformId on name-match platforms). + */ +function applyProfileToOtherSessions( + adapter: SessionRuntimeAdapter, + platform: string, + profile: OwnerProfile, + excludeSessionId: string +): Array<{ id: string; ownerId: string }> { + const updated: Array<{ id: string; ownerId: string }> = [] + for (const id of adapter.listSessionIds()) { + if (id === excludeSessionId) continue + try { + const db = adapter.openReadonly(id) + if (!db || !isChatSessionDb(db)) continue + const meta = getSessionMeta(db) + if (!meta || meta.platform !== platform || meta.ownerId) continue + + const result = matchOwnerProfile(platform, profile, getMembersWithAliases(db)) + if (result.type === 'exact' || result.type === 'name') { + coreUpdateSessionOwnerId(adapter.ensureWritable(id), result.platformId) + updated.push({ id, ownerId: result.platformId }) + } + } catch (err) { + console.warn(`[OwnerProfile] Failed to apply profile to session ${id}:`, err) + } + } + return updated +} + +/** + * Suppress the owner prompt for one session. UI-only: does not imply owner + * knowledge and does not block later automatic profile application. + */ +export function dismissOwnerPrompt(preferences: PreferencesManager, sessionId: string): void { + const prefs = preferences.load() + if (prefs.ownerPromptDismissedSessionIds.includes(sessionId)) return + preferences.save({ + ownerPromptDismissedSessionIds: [...prefs.ownerPromptDismissedSessionIds, sessionId], + }) +} + +/** + * Clear the owner of one session only. The platform profile is kept and may + * re-apply automatically later if it still uniquely matches. + */ +export function clearSessionOwner(adapter: SessionRuntimeAdapter, sessionId: string): void { + const db = adapter.ensureWritable(sessionId) + coreUpdateSessionOwnerId(db, null) +} diff --git a/packages/node-runtime/src/services/people/relationships/compute.test.ts b/packages/node-runtime/src/services/people/relationships/compute.test.ts new file mode 100644 index 000000000..fe4db785a --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/compute.test.ts @@ -0,0 +1,1162 @@ +/** + * Tests for People relationships graph snapshot computation. + * + * Run: pnpm test -- packages/node-runtime/src/services/people/relationships/compute.test.ts + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { CHAT_DB_SCHEMA } from '@openchatlab/core' +import type { PeopleRelationshipGraphEdge, PeopleRelationshipGraphNode } from '@openchatlab/shared-types' +import type { DatabaseAdapter } from '@openchatlab/core' +import { openBetterSqliteDatabase } from '../../../better-sqlite3-adapter' +import type { SessionRuntimeAdapter } from '../../adapters' +import { + buildPeopleRelationshipsNeighborhoodGraph, + computePeopleRelationshipsSnapshot, + PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, + type PeopleRelationshipsSnapshot, +} from './compute' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +interface SeedMember { + id: number + platformId: string + accountName?: string + groupNickname?: string + aliases?: string[] + avatar?: string | null +} + +interface SeedMessage { + id: number + senderId: number + ts: number + content?: string + platformMessageId?: string | null + replyToMessageId?: string | null +} + +interface SeedSession { + id: string + platform: string + type: 'private' | 'group' + ownerId?: string | null + members: SeedMember[] + messages?: SeedMessage[] +} + +class TestEnv { + readonly dir = fs.mkdtempSync(path.join(fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir(), 'chatlab-rel-')) + readonly adapter: SessionRuntimeAdapter + private dbPaths = new Map() + private openDbs: DatabaseAdapter[] = [] + + constructor() { + const open = (sessionId: string, readonly: boolean): DatabaseAdapter | null => { + const dbPath = this.dbPaths.get(sessionId) + if (!dbPath) return null + const db = openBetterSqliteDatabase(dbPath, { readonly, nativeBinding }) + this.openDbs.push(db) + return db + } + + this.adapter = { + listSessionIds: () => [...this.dbPaths.keys()], + openReadonly: (id) => open(id, true), + openWritable: (id) => open(id, false), + closeSession: () => {}, + getDbPath: (id) => this.dbPaths.get(id) ?? '', + deleteSessionFile: () => false, + ensureReadonly: (id) => { + const db = open(id, true) + if (!db) throw Object.assign(new Error(`Session not found: ${id}`), { statusCode: 404 }) + return db + }, + ensureWritable: (id) => { + const db = open(id, false) + if (!db) throw Object.assign(new Error(`Session not found: ${id}`), { statusCode: 404 }) + return db + }, + } + } + + seed(session: SeedSession): void { + const dbPath = path.join(this.dir, `${session.id}.db`) + const db = openBetterSqliteDatabase(dbPath, { nativeBinding }) + db.exec(CHAT_DB_SCHEMA) + db.prepare(`INSERT INTO meta (name, platform, type, imported_at, owner_id) VALUES (?, ?, ?, ?, ?)`).run( + session.id, + session.platform, + session.type, + 1780000000, + session.ownerId ?? null + ) + for (const member of session.members) { + db.prepare( + `INSERT INTO member (id, platform_id, account_name, group_nickname, aliases, avatar) VALUES (?, ?, ?, ?, ?, ?)` + ).run( + member.id, + member.platformId, + member.accountName ?? member.platformId, + member.groupNickname ?? null, + JSON.stringify(member.aliases ?? []), + member.avatar ?? null + ) + } + for (const message of session.messages ?? []) { + db.prepare( + `INSERT INTO message + (id, sender_id, ts, type, content, platform_message_id, reply_to_message_id) + VALUES (?, ?, ?, 0, ?, ?, ?)` + ).run( + message.id, + message.senderId, + message.ts, + message.content ?? `message ${message.id}`, + message.platformMessageId ?? `m-${message.id}`, + message.replyToMessageId ?? null + ) + } + db.close() + this.dbPaths.set(session.id, dbPath) + } + + cleanup(): void { + for (const db of this.openDbs) { + try { + db.close() + } catch { + // already closed + } + } + fs.rmSync(this.dir, { recursive: true, force: true }) + } +} + +function makeGraphNode(overrides: Partial & { key: string }): PeopleRelationshipGraphNode { + return { + key: overrides.key, + kind: overrides.kind ?? 'contact', + platform: overrides.platform ?? 'weixin', + platformId: overrides.platformId ?? overrides.key.split(':').at(-1) ?? overrides.key, + sessionScoped: false, + displayName: overrides.displayName ?? overrides.key, + aliases: [], + avatar: null, + pool: overrides.pool ?? 'non_friend', + friendSource: overrides.friendSource, + score: overrides.score ?? 0.5, + rank: overrides.rank ?? 10, + communityId: overrides.communityId ?? 'group:small', + x: overrides.x ?? 0, + y: overrides.y ?? 0, + size: overrides.size ?? 8, + color: overrides.color ?? '#7dd3fc', + labelVisibility: overrides.labelVisibility ?? 1, + lastInteractionTs: overrides.lastInteractionTs ?? null, + privateMessageCount: overrides.privateMessageCount ?? 0, + groupMessageCount: overrides.groupMessageCount ?? 0, + commonGroupCount: overrides.commonGroupCount ?? 1, + searchText: overrides.searchText ?? overrides.key, + } +} + +function makeGraphEdge(sourceKey: string, targetKey: string, weight: number): PeopleRelationshipGraphEdge { + return { + id: `${sourceKey}__${targetKey}`, + sourceKey, + targetKey, + weight, + coOccurrenceCount: Math.max(1, Math.round(weight)), + coOccurrenceRawScore: weight, + replyInteractionCount: 0, + repliesFromSourceToTarget: 0, + repliesFromTargetToSource: 0, + sourceGroupCount: 1, + sourceSessionIds: ['group-small'], + lastInteractionTs: 1704103200, + visibility: weight >= 8 ? 2 : 1, + } +} + +test('computes a cropped relationship galaxy from private contacts and group interaction edges', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'private-alice', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'alice', accountName: 'Alice', avatar: 'alice.png' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200 }, + { id: 2, senderId: 2, ts: 1704103201 }, + ], + }) + env.seed({ + id: 'group-a', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'alice', accountName: 'Alice' }, + { id: 3, platformId: 'bob', accountName: 'Bob' }, + { id: 4, platformId: 'carol', accountName: 'Carol' }, + { id: 5, platformId: 'group-a', accountName: 'group-a' }, + ], + messages: [ + { id: 1, senderId: 2, ts: 1704103200, platformMessageId: 'alice-1' }, + { id: 2, senderId: 3, ts: 1704103201, platformMessageId: 'bob-1', replyToMessageId: 'alice-1' }, + { id: 3, senderId: 4, ts: 1704103900, platformMessageId: 'carol-1' }, + { id: 4, senderId: 5, ts: 1704103901, platformMessageId: 'group-self-1' }, + ], + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + now: () => 1800000000, + limits: { + coreNodeLimit: 3, + coreEdgeLimit: 2, + perNodeEdgeLimit: 1, + }, + }) + + assert.equal(snapshot.algorithmVersion, PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION) + const owner = snapshot.nodes.find((node) => node.kind === 'owner') + assert.ok(owner) + assert.equal(owner.displayName, 'Me') + assert.equal(owner.searchText.includes('我'), true) + assert.equal(owner.searchText.includes('me'), true) + assert.equal( + snapshot.graph.nodes.some((node) => node.key === owner.key), + true + ) + assert.equal( + snapshot.nodes.some((node) => node.platformId === 'carol'), + true + ) + assert.equal( + snapshot.nodes.some((node) => node.platformId === 'group-a'), + false + ) + assert.equal( + snapshot.graph.nodes.some((node) => node.platformId === 'carol'), + false + ) + + const alice = snapshot.nodes.find((node) => node.platformId === 'alice') + assert.ok(alice) + assert.equal(alice.pool, 'friend') + assert.equal(alice.avatar, 'alice.png') + + const edge = snapshot.graph.edges.find((item) => { + const endpoints = [item.sourceKey, item.targetKey].sort() + return endpoints.join('|') === ['weixin:alice', 'weixin:bob'].sort().join('|') + }) + assert.ok(edge) + assert.ok(edge.coOccurrenceCount > 0) + assert.equal(edge.replyInteractionCount, 1) + assert.equal( + snapshot.graph.edges.filter((item) => item.sourceKey === owner.key || item.targetKey === owner.key).length <= 1, + true + ) + assert.equal(snapshot.diagnostics.processedGroupSessions, 1) + assert.equal(snapshot.diagnostics.processedPrivateSessions, 1) +}) + +test('counts owner group messages on the owner relationship node', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'group-owner-messages', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'alice', accountName: 'Alice' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200, platformMessageId: 'owner-1' }, + { id: 2, senderId: 1, ts: 1704103201, platformMessageId: 'owner-2' }, + { id: 3, senderId: 1, ts: 1704103202, platformMessageId: 'owner-3' }, + { id: 4, senderId: 2, ts: 1704103203, platformMessageId: 'alice-1' }, + ], + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-owner-group-count', + timeRangePreset: 'all', + }) + + const owner = snapshot.graph.nodes.find((node) => node.kind === 'owner') + assert.ok(owner) + assert.equal(owner.groupMessageCount, 3) +}) + +test('prefers recent owner edges when cropping dense relationship graph edges', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + const day = 24 * 60 * 60 + const staleTs = 1700000000 + const recentTs = staleTs + day * 240 + + env.seed({ + id: 'private-stale', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'stale', accountName: 'Stale' }, + ], + messages: Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + senderId: index % 2 === 0 ? 1 : 2, + ts: staleTs + index, + })), + }) + env.seed({ + id: 'private-recent', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'recent', accountName: 'Recent' }, + ], + messages: Array.from({ length: 60 }, (_, index) => ({ + id: index + 1, + senderId: index % 2 === 0 ? 1 : 2, + ts: recentTs + index, + })), + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + limits: { + coreNodeLimit: 10, + coreEdgeLimit: 10, + perNodeEdgeLimit: 1, + }, + }) + + const owner = snapshot.graph.nodes.find((node) => node.kind === 'owner') + assert.ok(owner) + const ownerEdges = snapshot.graph.edges.filter((edge) => edge.sourceKey === owner.key || edge.targetKey === owner.key) + + assert.equal(ownerEdges.length, 1) + assert.equal(ownerEdges[0]?.targetKey, 'weixin:recent') +}) + +test('keeps enough owner edges for the collapsed connection ranking', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + for (let index = 0; index < 11; index++) { + const contactId = `contact-${index + 1}` + env.seed({ + id: `private-${contactId}`, + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: contactId, accountName: contactId }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200 + index * 100 }, + { id: 2, senderId: 2, ts: 1704103201 + index * 100 }, + ], + }) + } + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + }) + + const owner = snapshot.graph.nodes.find((node) => node.kind === 'owner') + assert.ok(owner) + + assert.equal( + snapshot.graph.edges.filter((edge) => edge.sourceKey === owner.key || edge.targetKey === owner.key).length >= 10, + true + ) +}) + +test('prioritizes contact score over noisy group activity for the default panorama', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'private-close', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'close', accountName: 'Close Friend' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200 }, + { id: 2, senderId: 2, ts: 1704103201 }, + ], + }) + env.seed({ + id: 'group-noisy', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'noisy', accountName: 'Noisy Groupmate' }, + { id: 3, platformId: 'speaker', accountName: 'Speaker' }, + ], + messages: [ + ...Array.from({ length: 80 }, (_, index) => ({ + id: index + 1, + senderId: index % 2 === 0 ? 2 : 3, + ts: 1704103300 + index, + platformMessageId: `group-${index + 1}`, + })), + ], + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + limits: { + coreNodeLimit: 2, + coreEdgeLimit: 10, + perNodeEdgeLimit: 10, + }, + }) + + assert.equal( + snapshot.nodes.some((node) => node.platformId === 'noisy'), + true + ) + assert.equal( + snapshot.graph.nodes.some((node) => node.platformId === 'close'), + true + ) + assert.equal( + snapshot.graph.nodes.some((node) => node.platformId === 'noisy'), + false + ) +}) + +test('keeps top friend contacts ahead of non-friend contacts in the default panorama', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + for (const [index, contactId] of ['friend-1', 'friend-2', 'friend-3'].entries()) { + env.seed({ + id: `private-${contactId}`, + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: contactId, accountName: contactId }, + ], + messages: Array.from({ length: 8 - index }, (_, messageIndex) => ({ + id: messageIndex + 1, + senderId: messageIndex % 2 === 0 ? 1 : 2, + ts: 1704103200 + index * 100 + messageIndex, + })), + }) + } + + env.seed({ + id: 'group-strong-non-friend', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'groupmate', accountName: 'Strong Groupmate' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704104000, platformMessageId: 'owner-1' }, + { id: 2, senderId: 2, ts: 1704104001, platformMessageId: 'groupmate-1', replyToMessageId: 'owner-1' }, + ], + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + limits: { + coreNodeLimit: 4, + coreEdgeLimit: 10, + perNodeEdgeLimit: 10, + }, + }) + + const corePlatformIds = snapshot.graph.nodes.map((node) => node.platformId) + assert.deepEqual(corePlatformIds, ['owner', 'friend-1', 'friend-2', 'friend-3']) +}) + +test('lays out the panorama around owner and pushes large noisy groups outward', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'private-close', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'close-friend', accountName: 'Close Friend' }, + ], + messages: Array.from({ length: 40 }, (_, index) => ({ + id: index + 1, + senderId: index % 2 === 0 ? 1 : 2, + ts: 1704103200 + index, + })), + }) + env.seed({ + id: 'private-anchor', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'anchor-friend', accountName: 'Anchor Friend' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103000 }, + { id: 2, senderId: 2, ts: 1704103001 }, + ], + }) + env.seed({ + id: 'group-small', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'anchor-friend', accountName: 'Anchor Friend' }, + { id: 3, platformId: 'small-peer', accountName: 'Small Peer' }, + ], + messages: [ + ...Array.from({ length: 22 }, (_, index) => ({ + id: index + 1, + senderId: 1, + ts: 1704104000 + index, + platformMessageId: `small-owner-${index + 1}`, + })), + { id: 23, senderId: 3, ts: 1704104100, platformMessageId: 'small-peer-1', replyToMessageId: 'small-owner-1' }, + ], + }) + env.seed({ + id: 'group-large', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'anchor-friend', accountName: 'Anchor Friend' }, + { id: 3, platformId: 'large-peer', accountName: 'Large Peer' }, + ...Array.from({ length: 45 }, (_, index) => ({ + id: index + 4, + platformId: `large-quiet-${index + 1}`, + accountName: `Large Quiet ${index + 1}`, + })), + ], + messages: [ + ...Array.from({ length: 22 }, (_, index) => ({ + id: index + 1, + senderId: 1, + ts: 1704105000 + index, + platformMessageId: `large-owner-${index + 1}`, + })), + { id: 23, senderId: 3, ts: 1704105100, platformMessageId: 'large-peer-1', replyToMessageId: 'large-owner-1' }, + ...Array.from({ length: 45 }, (_, index) => ({ + id: index + 24, + senderId: index + 4, + ts: 1704105200 + index, + platformMessageId: `large-quiet-${index + 1}`, + })), + ], + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + limits: { + coreNodeLimit: 80, + coreEdgeLimit: 80, + perNodeEdgeLimit: 20, + }, + }) + + const owner = snapshot.nodes.find((node) => node.kind === 'owner') + const closeFriend = snapshot.nodes.find((node) => node.platformId === 'close-friend') + const smallPeer = snapshot.nodes.find((node) => node.platformId === 'small-peer') + const largePeer = snapshot.nodes.find((node) => node.platformId === 'large-peer') + assert.ok(owner) + assert.ok(closeFriend) + assert.ok(smallPeer) + assert.ok(largePeer) + + const distanceOf = (node: { x: number; y: number }) => Math.hypot(node.x, node.y) + + assert.deepEqual([owner.x, owner.y], [0, 0]) + assert.ok(distanceOf(closeFriend) < distanceOf(smallPeer)) + assert.ok(distanceOf(smallPeer) < distanceOf(largePeer)) +}) + +test('merges owner nodes across platforms into one relationship graph center', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'weixin-private', + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'wx-friend', accountName: 'Weixin Friend' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103200 }, + { id: 2, senderId: 2, ts: 1704103201 }, + ], + }) + env.seed({ + id: 'telegram-private', + platform: 'telegram', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'tg-friend', accountName: 'Telegram Friend' }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103300 }, + { id: 2, senderId: 2, ts: 1704103301 }, + ], + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + limits: { + coreNodeLimit: 10, + coreEdgeLimit: 10, + perNodeEdgeLimit: 10, + }, + }) + + const owners = snapshot.nodes.filter((node) => node.kind === 'owner') + assert.equal(owners.length, 1) + assert.equal(owners[0]?.key, 'owner') + assert.equal(owners[0]?.x, 0) + assert.equal(owners[0]?.y, 0) + + const ownerEdges = snapshot.graph.edges.filter((edge) => edge.sourceKey === 'owner' || edge.targetKey === 'owner') + assert.equal(ownerEdges.length, 2) + assert.equal( + ownerEdges.some((edge) => edge.sourceKey === 'weixin:wx-friend' || edge.targetKey === 'weixin:wx-friend'), + true + ) + assert.equal( + ownerEdges.some((edge) => edge.sourceKey === 'telegram:tg-friend' || edge.targetKey === 'telegram:tg-friend'), + true + ) +}) + +test('excludes no-friend groups from the default panorama while keeping full relationship data', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + env.seed({ + id: 'group-no-friends', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'alpha', accountName: 'Alpha' }, + { id: 3, platformId: 'beta', accountName: 'Beta' }, + ], + messages: [ + { id: 1, senderId: 2, ts: 1704103200, platformMessageId: 'alpha-1' }, + { id: 2, senderId: 3, ts: 1704103201, platformMessageId: 'beta-1', replyToMessageId: 'alpha-1' }, + ], + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + limits: { + coreNodeLimit: 10, + coreEdgeLimit: 10, + perNodeEdgeLimit: 10, + }, + }) + + const alpha = snapshot.nodes.find((node) => node.platformId === 'alpha') + assert.ok(alpha) + assert.equal( + snapshot.graph.nodes.some((node) => node.platformId === 'alpha'), + false + ) + assert.equal(snapshot.graph.edges.length, 0) + + const neighborhood = buildPeopleRelationshipsNeighborhoodGraph(snapshot, alpha.key) + assert.equal( + neighborhood.nodes.some((node) => node.platformId === 'beta'), + true + ) + assert.equal(neighborhood.edges.length > 0, true) + assert.equal(snapshot.diagnostics.panoramaExcludedLowValueGroupSessions, 1) + assert.equal(snapshot.diagnostics.panoramaIncludedGroupSessions, 0) +}) + +test('relayouts neighborhood graphs around the focused contact', () => { + const center = makeGraphNode({ + key: 'weixin:alice', + platformId: 'alice', + displayName: 'Alice', + pool: 'friend', + rank: 6, + score: 0.88, + communityId: 'group:small', + x: 900, + y: 700, + privateMessageCount: 12, + }) + const closeSmallGroupPeer = makeGraphNode({ + key: 'weixin:bob', + platformId: 'bob', + displayName: 'Bob', + rank: 12, + score: 0.76, + communityId: 'group:small', + x: 920, + y: 720, + }) + const weakPeer = makeGraphNode({ + key: 'weixin:carol', + platformId: 'carol', + displayName: 'Carol', + rank: 90, + score: 0.22, + communityId: 'group:large', + x: 940, + y: 730, + }) + const unrelated = makeGraphNode({ + key: 'weixin:dave', + platformId: 'dave', + displayName: 'Dave', + rank: 60, + score: 0.4, + communityId: 'group:small', + x: 960, + y: 740, + }) + const snapshot = { + nodes: [center, closeSmallGroupPeer, weakPeer, unrelated], + edges: [ + makeGraphEdge(center.key, closeSmallGroupPeer.key, 12), + makeGraphEdge(center.key, weakPeer.key, 0.4), + makeGraphEdge(closeSmallGroupPeer.key, unrelated.key, 5), + ], + communities: [ + { id: 'group:small', label: 'Small Group', size: 3, x: 0, y: 0, color: '#7dd3fc' }, + { id: 'group:large', label: 'Large Group', size: 1, x: 0, y: 0, color: '#f0abfc' }, + ], + graph: { nodes: [], edges: [], communities: [] }, + diagnostics: { + processedPrivateSessions: 0, + processedGroupSessions: 0, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedFailedSessions: 0, + totalNodes: 4, + totalEdges: 3, + panoramaIncludedGroupSessions: 0, + panoramaExcludedLowValueGroupSessions: 0, + panoramaIncludedGroupMembers: 0, + panoramaExcludedGroupMembers: 0, + panoramaCandidateNodes: 4, + panoramaGroupInclusionReasons: {}, + coreNodeCount: 0, + coreEdgeCount: 0, + warnings: [], + }, + algorithmVersion: PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, + signature: 'sig-local-layout', + timeRange: { preset: 'all', anchorTs: null, startTs: null }, + computedAt: 1800000000, + workerStats: { durationMs: 1, totalSessions: 0, processedSessions: 0, skippedFailedSessions: 0 }, + limits: { + coreNodeLimit: 10, + coreEdgeLimit: 10, + perNodeEdgeLimit: 10, + neighborhoodNodeLimit: 10, + neighborhoodEdgeLimit: 10, + searchResultLimit: 20, + }, + } satisfies PeopleRelationshipsSnapshot + + const neighborhood = buildPeopleRelationshipsNeighborhoodGraph(snapshot, center.key) + const localCenter = neighborhood.nodes.find((node) => node.key === center.key) + const localClosePeer = neighborhood.nodes.find((node) => node.key === closeSmallGroupPeer.key) + const localWeakPeer = neighborhood.nodes.find((node) => node.key === weakPeer.key) + assert.ok(localCenter) + assert.ok(localClosePeer) + assert.ok(localWeakPeer) + + const distanceOf = (node: PeopleRelationshipGraphNode) => Math.hypot(node.x, node.y) + + assert.deepEqual([localCenter.x, localCenter.y], [0, 0]) + assert.ok(distanceOf(localClosePeer) < distanceOf(localWeakPeer)) + assert.equal(center.x, 900) + assert.equal(center.y, 700) +}) + +test('prioritizes direct relationships in neighborhood graphs while respecting display limits', () => { + const center = makeGraphNode({ + key: 'weixin:center', + platformId: 'center', + displayName: 'Center', + pool: 'friend', + rank: 1, + score: 1, + communityId: 'group:core', + }) + const directPeers = Array.from({ length: 6 }, (_, index) => + makeGraphNode({ + key: `weixin:peer-${index}`, + platformId: `peer-${index}`, + displayName: `Peer ${index}`, + rank: index + 2, + score: 0.8 - index * 0.05, + communityId: index % 2 === 0 ? 'group:core' : 'group:outer', + }) + ) + const unrelated = makeGraphNode({ + key: 'weixin:unrelated', + platformId: 'unrelated', + displayName: 'Unrelated', + rank: 20, + score: 0.2, + communityId: 'group:outer', + }) + const snapshot = { + nodes: [center, ...directPeers, unrelated], + edges: [ + ...directPeers.map((peer, index) => makeGraphEdge(center.key, peer.key, 6 - index)), + makeGraphEdge(directPeers[0]!.key, unrelated.key, 20), + ], + communities: [ + { id: 'group:core', label: 'Core Group', size: 4, x: 0, y: 0, color: '#7dd3fc' }, + { id: 'group:outer', label: 'Outer Group', size: 4, x: 0, y: 0, color: '#f0abfc' }, + ], + graph: { nodes: [], edges: [], communities: [] }, + diagnostics: { + processedPrivateSessions: 0, + processedGroupSessions: 0, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedFailedSessions: 0, + totalNodes: 8, + totalEdges: 7, + panoramaIncludedGroupSessions: 0, + panoramaExcludedLowValueGroupSessions: 0, + panoramaIncludedGroupMembers: 0, + panoramaExcludedGroupMembers: 0, + panoramaCandidateNodes: 8, + panoramaGroupInclusionReasons: {}, + coreNodeCount: 0, + coreEdgeCount: 0, + warnings: [], + }, + algorithmVersion: PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, + signature: 'sig-complete-neighborhood', + timeRange: { preset: 'all', anchorTs: null, startTs: null }, + computedAt: 1800000000, + workerStats: { durationMs: 1, totalSessions: 0, processedSessions: 0, skippedFailedSessions: 0 }, + limits: { + coreNodeLimit: 10, + coreEdgeLimit: 10, + perNodeEdgeLimit: 10, + neighborhoodNodeLimit: 4, + neighborhoodEdgeLimit: 3, + searchResultLimit: 20, + }, + } satisfies PeopleRelationshipsSnapshot + + const neighborhood = buildPeopleRelationshipsNeighborhoodGraph(snapshot, center.key) + const nodeKeys = neighborhood.nodes.map((node) => node.key) + const directEdgeKeys = new Set(neighborhood.edges.map((edge) => [edge.sourceKey, edge.targetKey].sort().join(':'))) + + assert.deepEqual(nodeKeys, [center.key, directPeers[0]!.key, directPeers[1]!.key, directPeers[2]!.key]) + assert.equal(neighborhood.edges.length, 3) + for (const peer of directPeers.slice(0, 3)) { + assert.equal(directEdgeKeys.has([center.key, peer.key].sort().join(':')), true) + } + for (const peer of directPeers.slice(3)) { + assert.equal(directEdgeKeys.has([center.key, peer.key].sort().join(':')), false) + } + assert.equal(nodeKeys.includes(unrelated.key), false) +}) + +test('returns only focused contact source groups in neighborhood communities', () => { + const center = makeGraphNode({ + key: 'weixin:center', + platformId: 'center', + displayName: 'Center', + pool: 'friend', + rank: 1, + score: 1, + communityId: 'group:direct-a', + }) + const peer = makeGraphNode({ + key: 'weixin:peer', + platformId: 'peer', + displayName: 'Peer', + rank: 2, + score: 0.8, + communityId: 'group:unrelated-primary', + }) + const edge = { + ...makeGraphEdge(center.key, peer.key, 8), + sourceGroupCount: 2, + sourceSessionIds: ['direct-a', 'direct-b'], + } + const snapshot = { + nodes: [center, peer], + edges: [edge], + communities: [ + { id: 'group:direct-a', label: 'Direct A', size: 10, x: 0, y: 0, color: '#7dd3fc' }, + { id: 'group:direct-b', label: 'Direct B', size: 8, x: 0, y: 0, color: '#facc15' }, + { id: 'group:unrelated-primary', label: 'Unrelated Primary', size: 20, x: 0, y: 0, color: '#f0abfc' }, + ], + graph: { nodes: [], edges: [], communities: [] }, + diagnostics: { + processedPrivateSessions: 0, + processedGroupSessions: 0, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedFailedSessions: 0, + totalNodes: 2, + totalEdges: 1, + panoramaIncludedGroupSessions: 0, + panoramaExcludedLowValueGroupSessions: 0, + panoramaIncludedGroupMembers: 0, + panoramaExcludedGroupMembers: 0, + panoramaCandidateNodes: 2, + panoramaGroupInclusionReasons: {}, + coreNodeCount: 0, + coreEdgeCount: 0, + warnings: [], + }, + algorithmVersion: PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, + signature: 'sig-neighborhood-communities', + timeRange: { preset: 'all', anchorTs: null, startTs: null }, + computedAt: 1800000000, + workerStats: { durationMs: 1, totalSessions: 0, processedSessions: 0, skippedFailedSessions: 0 }, + limits: { + coreNodeLimit: 10, + coreEdgeLimit: 10, + perNodeEdgeLimit: 10, + neighborhoodNodeLimit: 10, + neighborhoodEdgeLimit: 10, + searchResultLimit: 20, + }, + } satisfies PeopleRelationshipsSnapshot + + const neighborhood = buildPeopleRelationshipsNeighborhoodGraph(snapshot, center.key) + + assert.deepEqual( + neighborhood.communities.map((community) => community.id), + ['group:direct-a', 'group:direct-b'] + ) +}) + +test('includes groups with few friends when owner activity is high but trims low-signal members', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + for (const friendId of ['friend-1', 'friend-2']) { + env.seed({ + id: `private-${friendId}`, + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: friendId, accountName: friendId }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103000 }, + { id: 2, senderId: 2, ts: 1704103001 }, + ], + }) + } + + env.seed({ + id: 'group-owner-active', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'friend-1', accountName: 'friend-1' }, + { id: 3, platformId: 'friend-2', accountName: 'friend-2' }, + { id: 4, platformId: 'active-peer', accountName: 'Active Peer' }, + ...Array.from({ length: 24 }, (_, index) => ({ + id: index + 5, + platformId: `quiet-${index + 1}`, + accountName: `Quiet ${index + 1}`, + })), + ], + messages: [ + ...Array.from({ length: 22 }, (_, index) => ({ + id: index + 1, + senderId: 1, + ts: 1704104000 + index, + platformMessageId: `owner-${index + 1}`, + })), + { id: 23, senderId: 4, ts: 1704104100, platformMessageId: 'active-peer-1', replyToMessageId: 'owner-1' }, + ], + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + limits: { + coreNodeLimit: 30, + coreEdgeLimit: 30, + perNodeEdgeLimit: 10, + }, + }) + + assert.equal( + snapshot.graph.nodes.some((node) => node.platformId === 'friend-1'), + true + ) + assert.equal( + snapshot.graph.nodes.some((node) => node.platformId === 'friend-2'), + true + ) + assert.equal( + snapshot.graph.nodes.some((node) => node.platformId === 'active-peer'), + true + ) + assert.equal( + snapshot.nodes.some((node) => node.platformId === 'quiet-1'), + true + ) + assert.equal( + snapshot.graph.nodes.some((node) => node.platformId === 'quiet-1'), + false + ) + assert.equal(snapshot.diagnostics.panoramaIncludedGroupSessions, 1) + assert.equal(snapshot.diagnostics.panoramaGroupInclusionReasons.owner_activity, 1) + assert.ok(snapshot.diagnostics.panoramaExcludedGroupMembers > 0) +}) + +test('excludes large low-value groups with only a few friends from the default edge set', (t) => { + const env = new TestEnv() + t.after(() => env.cleanup()) + + for (const friendId of ['friend-1', 'friend-2']) { + env.seed({ + id: `private-low-${friendId}`, + platform: 'weixin', + type: 'private', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: friendId, accountName: friendId }, + ], + messages: [ + { id: 1, senderId: 1, ts: 1704103000 }, + { id: 2, senderId: 2, ts: 1704103001 }, + ], + }) + } + + env.seed({ + id: 'group-large-low-value', + platform: 'weixin', + type: 'group', + ownerId: 'owner', + members: [ + { id: 1, platformId: 'owner', accountName: 'Me' }, + { id: 2, platformId: 'friend-1', accountName: 'friend-1' }, + { id: 3, platformId: 'friend-2', accountName: 'friend-2' }, + { id: 4, platformId: 'stranger', accountName: 'Stranger' }, + ...Array.from({ length: 32 }, (_, index) => ({ + id: index + 5, + platformId: `large-quiet-${index + 1}`, + accountName: `Large Quiet ${index + 1}`, + })), + ], + messages: [ + { id: 1, senderId: 2, ts: 1704105000, platformMessageId: 'friend-1-group' }, + { id: 2, senderId: 4, ts: 1704105001, platformMessageId: 'stranger-group', replyToMessageId: 'friend-1-group' }, + ], + }) + + const snapshot = computePeopleRelationshipsSnapshot({ + adapter: env.adapter, + signature: 'sig-1', + timeRangePreset: 'all', + limits: { + coreNodeLimit: 20, + coreEdgeLimit: 20, + perNodeEdgeLimit: 10, + }, + }) + + assert.equal( + snapshot.nodes.some((node) => node.platformId === 'stranger'), + true + ) + assert.equal( + snapshot.graph.nodes.some((node) => node.platformId === 'stranger'), + false + ) + assert.equal( + snapshot.graph.edges.some((edge) => edge.sourceKey === 'weixin:stranger' || edge.targetKey === 'weixin:stranger'), + false + ) + assert.equal(snapshot.diagnostics.panoramaExcludedLowValueGroupSessions, 1) +}) diff --git a/packages/node-runtime/src/services/people/relationships/compute.ts b/packages/node-runtime/src/services/people/relationships/compute.ts new file mode 100644 index 000000000..d0e08fdfe --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/compute.ts @@ -0,0 +1,1677 @@ +import { + ChatType, + type ChatPlatform, + type ContactsTimeRangePreset, + type ContactsTimeRangeState, + type PeopleRelationshipCommunity, + type PeopleRelationshipsGraphData, + type PeopleRelationshipGraphEdge, + type PeopleRelationshipGraphNode, + type PeopleRelationshipsDiagnostics, +} from '@openchatlab/shared-types' +import { + computeFriendScores, + computeNonFriendScores, + getGroupContactFacts, + getGroupRelationshipGraphFacts, + getLatestContactMessageTs, + getPrivateContactFacts, + getSessionMeta, + isChatSessionDb, + isNameMatchPlatform, + resolveOwnerMember, +} from '@openchatlab/core' +import type { ContactMemberRef, SessionMeta } from '@openchatlab/core' +import { getDbFileVersion } from '../../../cache/analytics-cache' +import { appLogger } from '../../../logging/app-logger' +import type { SessionRuntimeAdapter } from '../../adapters' +import { + buildPeopleRelationshipsSessionFactsCacheKey, + buildPeopleRelationshipsSessionLatestCacheKey, + createEmptyPeopleRelationshipsFactsCacheStats, + readCachedPeopleRelationshipsSessionFacts, + readCachedPeopleRelationshipsSessionLatest, + writeCachedPeopleRelationshipsSessionFacts, + writeCachedPeopleRelationshipsSessionLatest, + type PeopleRelationshipsCachedGroupFacts, + type PeopleRelationshipsCachedPrivateFacts, + type PeopleRelationshipsFactsCacheStats, + type PeopleRelationshipsSessionFacts, + type PeopleRelationshipsSessionLatestFacts, + type PeopleRelationshipsSessionMetaFacts, +} from './facts-cache' +import { resolvePeopleRelationshipsTimeRange } from './time-range' + +export const PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION = 'people-relationships-v2' + +const PRIVATE_COMMUNITY_ID = 'private' +const OWNER_KEY_PREFIX = 'owner' +const REPLY_WEIGHT = 3 +const CO_OCCURRENCE_COUNT_WEIGHT = 0.05 +const EDGE_RECENCY_HALF_LIFE_SECONDS = 120 * 24 * 60 * 60 +const EDGE_RECENCY_FLOOR = 0.1 +const MIN_GROUP_FRIENDS_FOR_PANORAMA = 3 +const MIN_GROUP_FRIEND_RATIO_FOR_PANORAMA = 0.2 +const MIN_OWNER_CONNECTED_MEMBERS_FOR_PANORAMA = 3 +const MIN_OWNER_GROUP_MESSAGES_FOR_PANORAMA = 20 +const MIN_OWNER_GROUP_MESSAGE_RATIO_FOR_PANORAMA = 0.05 +const MIN_OWNER_GROUP_MESSAGES_FOR_RATIO = 8 +const SMALL_GROUP_MEMBER_LIMIT_FOR_FRIEND_EDGE = 12 +const MAX_OWNER_CONNECTED_NON_FRIENDS_PER_GROUP = 30 +const MAX_FRIEND_CONNECTED_NON_FRIENDS_PER_GROUP = 30 +const MAX_HIGH_SIGNAL_NON_FRIENDS_PER_GROUP = 10 +const MAX_MEMBERS_PER_GROUP_FOR_PANORAMA = 80 + +const COMMUNITY_COLORS = [ + '#7dd3fc', + '#f0abfc', + '#facc15', + '#34d399', + '#fb7185', + '#a78bfa', + '#2dd4bf', + '#f97316', + '#60a5fa', + '#e879f9', +] + +export interface PeopleRelationshipsWorkerStats { + durationMs: number + totalSessions: number + processedSessions: number + skippedFailedSessions: number +} + +export interface PeopleRelationshipsComputeLimits { + coreNodeLimit?: number + coreEdgeLimit?: number + perNodeEdgeLimit?: number + neighborhoodNodeLimit?: number + neighborhoodEdgeLimit?: number + searchResultLimit?: number +} + +export interface PeopleRelationshipsSnapshot { + nodes: PeopleRelationshipGraphNode[] + edges: PeopleRelationshipGraphEdge[] + communities: PeopleRelationshipCommunity[] + graph: PeopleRelationshipsGraphData + diagnostics: PeopleRelationshipsDiagnostics + algorithmVersion: string + signature: string + timeRange: ContactsTimeRangeState + computedAt: number + workerStats: PeopleRelationshipsWorkerStats + limits: Required +} + +export interface PeopleRelationshipsComputeProgress { + processedSessions: number + totalSessions: number + currentSessionId?: string +} + +export interface ComputePeopleRelationshipsSnapshotOptions { + adapter: SessionRuntimeAdapter + signature: string + timeRangePreset?: ContactsTimeRangePreset + factsCacheDir?: string + limits?: PeopleRelationshipsComputeLimits + now?: () => number + onProgress?: (progress: PeopleRelationshipsComputeProgress) => void +} + +interface NodeAccumulator { + key: string + kind: 'contact' | 'owner' + platform: ChatPlatform + platformId: string + sessionScoped: boolean + sessionId?: string + displayName: string + aliases: Set + avatar: string | null + isFriend: boolean + friendSource?: 'private' + privateMessageCount: number + activePrivateMonths: Set + groupMessageCount: number + commonGroupSessionIds: Set + ownerCoOccurrenceCount: number + ownerCoOccurrenceRawScore: number + ownerReplyInteractionCount: number + ownerRepliesFromOwnerToContact: number + ownerRepliesFromContactToOwner: number + communityWeights: Map + edgeWeight: number + panoramaCandidate: boolean + lastInteractionTs: number | null +} + +interface EdgeAccumulator { + sourceKey: string + targetKey: string + coOccurrenceCount: number + coOccurrenceRawScore: number + replyInteractionCount: number + repliesFromSourceToTarget: number + repliesFromTargetToSource: number + sourceSessionIds: Set + panoramaEligible: boolean + lastInteractionTs: number | null +} + +interface GroupPanoramaContribution { + sessionId: string + memberKeys: Set + memberMessageCounts: Map + ownerConnectedKeys: Set + ownerEdgeScores: Map + relationshipEdgeIds: Set + relationshipEdgeEndpoints: Map + relationshipEdgeScores: Map + friendConnectedScores: Map + ownerMessageCount: number +} + +interface BuildGraphResult { + nodes: PeopleRelationshipGraphNode[] + edges: PeopleRelationshipGraphEdge[] + communities: PeopleRelationshipCommunity[] + graph: PeopleRelationshipsGraphData + diagnostics: PeopleRelationshipsDiagnostics +} + +interface PeopleRelationshipsFactsCacheContext { + dir: string + latestKey: string + stats: PeopleRelationshipsFactsCacheStats +} + +export function computePeopleRelationshipsSnapshot( + options: ComputePeopleRelationshipsSnapshotOptions +): PeopleRelationshipsSnapshot { + const startedAt = options.now?.() ?? Date.now() + const sessionIds = options.adapter.listSessionIds() + const limits = normalizeLimits(options.limits) + const factsCache = options.factsCacheDir ? createFactsCacheContext(options.factsCacheDir) : null + const timeRange = resolvePeopleRelationshipsTimeRange( + options.timeRangePreset, + findGlobalLatestMessageTs(options.adapter, sessionIds, factsCache) + ) + const result = computePeopleRelationships({ + adapter: options.adapter, + sessionIds, + timeRange, + factsCache, + limits, + onProgress: options.onProgress, + }) + const finishedAt = options.now?.() ?? Date.now() + if (factsCache) { + appLogger.info('people-relationships', 'people relationships session facts cache summary', factsCache.stats) + } + appLogger.info('people-relationships', 'people relationships panorama filter summary', { + includedGroupSessions: result.diagnostics.panoramaIncludedGroupSessions, + excludedLowValueGroupSessions: result.diagnostics.panoramaExcludedLowValueGroupSessions, + includedGroupMembers: result.diagnostics.panoramaIncludedGroupMembers, + excludedGroupMembers: result.diagnostics.panoramaExcludedGroupMembers, + candidateNodes: result.diagnostics.panoramaCandidateNodes, + reasons: result.diagnostics.panoramaGroupInclusionReasons, + }) + return { + ...result, + algorithmVersion: PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, + signature: options.signature, + timeRange, + computedAt: finishedAt, + limits, + workerStats: { + durationMs: Math.max(0, finishedAt - startedAt), + totalSessions: sessionIds.length, + processedSessions: sessionIds.length, + skippedFailedSessions: result.diagnostics.skippedFailedSessions, + }, + } +} + +export function buildPeopleRelationshipsNeighborhoodGraph( + snapshot: PeopleRelationshipsSnapshot, + contactKey: string, + limits: PeopleRelationshipsComputeLimits = {} +): PeopleRelationshipsGraphData { + const normalizedLimits = normalizeLimits({ ...snapshot.limits, ...limits }) + const center = snapshot.nodes.find((node) => node.key === contactKey) + if (!center) return { nodes: [], edges: [], communities: [] } + + const relatedEdges = sortEdgesForDisplay( + snapshot.edges.filter((edge) => edge.sourceKey === contactKey || edge.targetKey === contactKey) + ) + const { directEdges, nodeKeys } = selectNeighborhoodDirectEdges(relatedEdges, contactKey, normalizedLimits) + + const directEdgeIds = new Set(relatedEdges.map((edge) => edge.id)) + const secondaryEdgeLimit = Math.max(0, normalizedLimits.neighborhoodEdgeLimit - directEdges.length) + const secondaryEdges = sortEdgesForDisplay( + snapshot.edges.filter( + (edge) => !directEdgeIds.has(edge.id) && nodeKeys.has(edge.sourceKey) && nodeKeys.has(edge.targetKey) + ) + ).slice(0, secondaryEdgeLimit) + const edges = sortEdgesForDisplay([...directEdges, ...secondaryEdges]) + const nodes = layoutNeighborhoodNodes( + snapshot.nodes.filter((node) => nodeKeys.has(node.key)), + directEdges, + contactKey + ).sort(compareNodes) + return { + nodes, + edges, + communities: filterNeighborhoodCommunities(snapshot.communities, directEdges), + } +} + +function selectNeighborhoodDirectEdges( + relatedEdges: PeopleRelationshipGraphEdge[], + contactKey: string, + limits: Required +): { directEdges: PeopleRelationshipGraphEdge[]; nodeKeys: Set } { + // 邻域图先按直接关系排序选择节点,避免高连接节点绕过节点/边上限。 + const nodeKeys = new Set([contactKey]) + const directEdges: PeopleRelationshipGraphEdge[] = [] + + for (const edge of relatedEdges) { + if (directEdges.length >= limits.neighborhoodEdgeLimit) break + const neighborKey = getNeighborhoodNeighborKey(edge, contactKey) + if (!neighborKey) continue + if (!nodeKeys.has(neighborKey)) { + if (nodeKeys.size >= limits.neighborhoodNodeLimit) continue + nodeKeys.add(neighborKey) + } + directEdges.push(edge) + } + + return { directEdges, nodeKeys } +} + +function getNeighborhoodNeighborKey(edge: PeopleRelationshipGraphEdge, contactKey: string): string | null { + if (edge.sourceKey === contactKey) return edge.targetKey + if (edge.targetKey === contactKey) return edge.sourceKey + return null +} + +function layoutNeighborhoodNodes( + nodes: PeopleRelationshipGraphNode[], + relatedEdges: PeopleRelationshipGraphEdge[], + contactKey: string +): PeopleRelationshipGraphNode[] { + const center = nodes.find((node) => node.key === contactKey) + if (!center) return nodes.map((node) => ({ ...node })) + + const directEdgeByNodeKey = new Map() + for (const edge of relatedEdges) { + const neighborKey = + edge.sourceKey === contactKey ? edge.targetKey : edge.targetKey === contactKey ? edge.sourceKey : null + if (neighborKey) directEdgeByNodeKey.set(neighborKey, edge) + } + const maxDirectWeight = Math.max(...[...directEdgeByNodeKey.values()].map((edge) => edge.weight), 1) + const communityGroups = new Map() + for (const node of nodes) { + if (node.key === contactKey) continue + const group = communityGroups.get(node.communityId) ?? [] + group.push(node) + communityGroups.set(node.communityId, group) + } + + const sortedCommunityGroups = [...communityGroups.entries()].sort((a, b) => { + if (a[0] === center.communityId) return -1 + if (b[0] === center.communityId) return 1 + const weightA = sumNeighborhoodCommunityWeight(a[1], directEdgeByNodeKey) + const weightB = sumNeighborhoodCommunityWeight(b[1], directEdgeByNodeKey) + return weightB - weightA || a[0].localeCompare(b[0]) + }) + + const localNodes: PeopleRelationshipGraphNode[] = [{ ...center, x: 0, y: 0 }] + for (const [communityIndex, [communityId, communityNodes]] of sortedCommunityGroups.entries()) { + const baseAngle = neighborhoodCommunityAngle(communityIndex, communityId, center.communityId) + communityNodes.sort((a, b) => { + const edgeA = directEdgeByNodeKey.get(a.key)?.weight ?? 0 + const edgeB = directEdgeByNodeKey.get(b.key)?.weight ?? 0 + return edgeB - edgeA || compareNodes(a, b) + }) + + for (const [index, node] of communityNodes.entries()) { + const angle = neighborhoodNodeAngle(baseAngle, communityId, node.key, index, communityNodes.length) + const radius = neighborhoodNodeRadius( + node, + center, + directEdgeByNodeKey.get(node.key), + maxDirectWeight, + communityNodes.length + ) + localNodes.push({ + ...node, + x: roundNum(Math.cos(angle) * radius, 2), + y: roundNum(Math.sin(angle) * radius, 2), + }) + } + } + return localNodes +} + +function sumNeighborhoodCommunityWeight( + nodes: PeopleRelationshipGraphNode[], + directEdgeByNodeKey: Map +): number { + return nodes.reduce((sum, node) => sum + (directEdgeByNodeKey.get(node.key)?.weight ?? 0), 0) +} + +function neighborhoodCommunityAngle(index: number, communityId: string, centerCommunityId: string): number { + if (communityId === centerCommunityId) return -Math.PI / 2 + return index * 2.399963229728653 +} + +function neighborhoodNodeAngle( + baseAngle: number, + communityId: string, + nodeKey: string, + index: number, + communitySize: number +): number { + const seedOffset = ((stableHash(`${communityId}:${nodeKey}:neighborhood-angle`) % 1000) / 1000 - 0.5) * 0.12 + const centeredIndex = index - (communitySize - 1) / 2 + const spacing = communitySize <= 4 ? 0.42 : communitySize <= 10 ? 0.24 : 0.14 + const maxSpread = communitySize <= 4 ? 0.72 : communitySize <= 20 ? 1.08 : 1.32 + return baseAngle + clamp(centeredIndex * spacing + seedOffset, -maxSpread, maxSpread) +} + +function neighborhoodNodeRadius( + node: PeopleRelationshipGraphNode, + center: PeopleRelationshipGraphNode, + directEdge: PeopleRelationshipGraphEdge | undefined, + maxDirectWeight: number, + communitySize: number +): number { + const directStrength = directEdge ? Math.min(1, directEdge.weight / maxDirectWeight) : 0 + const sameCommunityBonus = node.communityId === center.communityId ? 110 : 0 + const smallCommunityBonus = node.communityId === center.communityId && communitySize <= 6 ? 80 : 0 + const friendBonus = node.pool === 'friend' ? 70 : 0 + const scoreBonus = Math.min(90, Math.sqrt(Math.max(0, Math.min(1, node.score))) * 90) + const rankPenalty = Math.sqrt(Math.max(0, node.rank - center.rank)) * 8 + return roundNum( + clamp( + 260 + + (1 - directStrength) * 560 + + rankPenalty - + sameCommunityBonus - + smallCommunityBonus - + friendBonus - + scoreBonus, + 120, + 1100 + ), + 2 + ) +} + +function computePeopleRelationships(options: { + adapter: SessionRuntimeAdapter + sessionIds: string[] + timeRange: ContactsTimeRangeState + factsCache: PeopleRelationshipsFactsCacheContext | null + limits: Required + onProgress?: (progress: PeopleRelationshipsComputeProgress) => void +}): BuildGraphResult { + const diagnostics = createEmptyDiagnostics() + const nodes = new Map() + const edges = new Map() + const groupContributions: GroupPanoramaContribution[] = [] + const communityLabels = new Map([[PRIVATE_COMMUNITY_ID, 'Private contacts']]) + let processedSessions = 0 + + for (const sessionId of options.sessionIds) { + options.onProgress?.({ processedSessions, totalSessions: options.sessionIds.length, currentSessionId: sessionId }) + try { + const facts = getSessionFacts(options.adapter, sessionId, options.timeRange, options.factsCache) + applySessionFacts(nodes, edges, groupContributions, communityLabels, diagnostics, sessionId, facts) + } catch (error) { + diagnostics.skippedFailedSessions++ + appLogger.error('people-relationships', `failed to process people relationship session: ${sessionId}`, error) + } finally { + processedSessions++ + options.onProgress?.({ processedSessions, totalSessions: options.sessionIds.length, currentSessionId: sessionId }) + } + } + + applyPanoramaContributions([...nodes.values()], edges, groupContributions, diagnostics) + return buildGraph( + [...nodes.values()], + [...edges.values()], + communityLabels, + groupContributions, + options.limits, + diagnostics + ) +} + +function findGlobalLatestMessageTs( + adapter: SessionRuntimeAdapter, + sessionIds: string[], + factsCache: PeopleRelationshipsFactsCacheContext | null +): number | null { + let latest: number | null = null + for (const sessionId of sessionIds) { + const dbVersion = getSessionDbVersion(adapter, sessionId) + const cached = readLatestFacts(sessionId, factsCache, dbVersion) + if (cached) { + if (cached.latestMessageTs !== null) latest = Math.max(latest ?? 0, cached.latestMessageTs) + continue + } + try { + const db = adapter.openReadonly(sessionId) + const ts = db && isChatSessionDb(db) ? getLatestContactMessageTs(db) : null + writeLatestFacts(adapter, sessionId, factsCache, { latestMessageTs: ts }, dbVersion) + if (ts !== null) latest = Math.max(latest ?? 0, ts) + } catch (error) { + appLogger.error( + 'people-relationships', + `failed to inspect people relationship session range: ${sessionId}`, + error + ) + } + } + return latest +} + +function getSessionFacts( + adapter: SessionRuntimeAdapter, + sessionId: string, + timeRange: ContactsTimeRangeState, + factsCache: PeopleRelationshipsFactsCacheContext | null +): PeopleRelationshipsSessionFacts { + const dbVersion = getSessionDbVersion(adapter, sessionId) + const cached = readSessionFacts(sessionId, timeRange, factsCache, dbVersion) + if (cached) return cached + + const facts = computeSessionFacts(adapter, sessionId, timeRange) + writeSessionFacts(adapter, sessionId, timeRange, factsCache, facts, dbVersion) + return facts +} + +function computeSessionFacts( + adapter: SessionRuntimeAdapter, + sessionId: string, + timeRange: ContactsTimeRangeState +): PeopleRelationshipsSessionFacts { + const db = adapter.openReadonly(sessionId) + if (!db || !isChatSessionDb(db)) return { kind: 'not_chat_db', latestMessageTs: null } + + const latestMessageTs = getLatestContactMessageTs(db) + const meta = getSessionMeta(db) + if (!meta) return { kind: 'missing_meta', latestMessageTs } + if (meta.type !== ChatType.PRIVATE && meta.type !== ChatType.GROUP) + return { kind: 'unsupported_type', latestMessageTs } + + const cachedMeta = toPeopleRelationshipsSessionMetaFacts(meta) + if (!meta.ownerId?.trim()) return { kind: 'missing_owner', meta: cachedMeta, latestMessageTs } + + const owner = resolveOwnerMember(db) + if (!owner) return { kind: 'unresolved_owner', meta: cachedMeta, latestMessageTs } + const cachedMetaWithOwner = toPeopleRelationshipsSessionMetaFacts(meta, owner) + + if (meta.type === ChatType.PRIVATE) { + const facts = getPrivateContactFacts(db, owner.id, { startTs: timeRange.startTs }) + if (facts.type === 'missing') return { kind: 'private_missing', meta: cachedMetaWithOwner, latestMessageTs } + if (facts.type === 'ambiguous') return { kind: 'private_ambiguous', meta: cachedMetaWithOwner, latestMessageTs } + return { kind: 'private', meta: cachedMetaWithOwner, latestMessageTs, facts } + } + + return { + kind: 'group', + meta: cachedMetaWithOwner, + latestMessageTs, + facts: { + ...getGroupRelationshipGraphFacts(db, owner.id, { startTs: timeRange.startTs }), + ownerEdges: getGroupContactFacts(db, owner.id, { startTs: timeRange.startTs }), + }, + } +} + +function applySessionFacts( + nodes: Map, + edges: Map, + groupContributions: GroupPanoramaContribution[], + communityLabels: Map, + diagnostics: PeopleRelationshipsDiagnostics, + sessionId: string, + sessionFacts: PeopleRelationshipsSessionFacts +): void { + switch (sessionFacts.kind) { + case 'missing_owner': + diagnostics.skippedMissingOwnerSessions++ + return + case 'unresolved_owner': + diagnostics.skippedUnresolvedOwnerSessions++ + return + case 'private_ambiguous': + diagnostics.skippedAmbiguousPrivateSessions++ + return + case 'private': + diagnostics.processedPrivateSessions++ + applyPrivateFacts(nodes, edges, sessionId, sessionFacts.meta, sessionFacts.facts) + return + case 'group': + diagnostics.processedGroupSessions++ + applyGroupFacts( + nodes, + edges, + groupContributions, + communityLabels, + sessionId, + sessionFacts.meta, + sessionFacts.facts + ) + return + default: + return + } +} + +function applyPrivateFacts( + nodes: Map, + edges: Map, + sessionId: string, + meta: PeopleRelationshipsSessionMetaFacts, + facts: PeopleRelationshipsCachedPrivateFacts +): void { + if (facts.privateMessageCount <= 0) return + const ownerNode = getOrCreateOwnerNode(nodes, meta) + const node = getOrCreateNode(nodes, sessionId, meta, facts.contact) + node.isFriend = true + node.friendSource = 'private' + node.panoramaCandidate = true + node.privateMessageCount += facts.privateMessageCount + for (const month of facts.activeMonths) node.activePrivateMonths.add(month) + node.communityWeights.set(PRIVATE_COMMUNITY_ID, (node.communityWeights.get(PRIVATE_COMMUNITY_ID) ?? 0) + 1) + updateLastInteraction(node, facts.lastMessageTs) + + if (!ownerNode) return + ownerNode.isFriend = true + ownerNode.panoramaCandidate = true + ownerNode.privateMessageCount += facts.privateMessageCount + for (const month of facts.activeMonths) ownerNode.activePrivateMonths.add(month) + ownerNode.communityWeights.set(PRIVATE_COMMUNITY_ID, (ownerNode.communityWeights.get(PRIVATE_COMMUNITY_ID) ?? 0) + 1) + updateLastInteraction(ownerNode, facts.lastMessageTs) + applyOwnerContactEdge(edges, ownerNode, node, { + coOccurrenceCount: facts.privateMessageCount, + coOccurrenceRawScore: facts.privateMessageCount * 1.8, + replyInteractionCount: 0, + repliesFromOwnerToContact: 0, + repliesFromContactToOwner: 0, + lastInteractionTs: facts.lastMessageTs, + sessionId, + panoramaEligible: true, + }) +} + +function applyGroupFacts( + nodes: Map, + edges: Map, + groupContributions: GroupPanoramaContribution[], + communityLabels: Map, + sessionId: string, + meta: PeopleRelationshipsSessionMetaFacts, + facts: PeopleRelationshipsCachedGroupFacts +): void { + const communityId = `group:${sessionId}` + communityLabels.set(communityId, meta.name) + const ownerNode = getOrCreateOwnerNode(nodes, meta) + const contribution: GroupPanoramaContribution = { + sessionId, + memberKeys: new Set(), + memberMessageCounts: new Map(), + ownerConnectedKeys: new Set(), + ownerEdgeScores: new Map(), + relationshipEdgeIds: new Set(), + relationshipEdgeEndpoints: new Map(), + relationshipEdgeScores: new Map(), + friendConnectedScores: new Map(), + ownerMessageCount: facts.ownerMessageCount, + } + for (const member of facts.members) { + const node = getOrCreateNode(nodes, sessionId, meta, member.contact) + contribution.memberKeys.add(node.key) + contribution.memberMessageCounts.set(node.key, member.messageCount) + node.groupMessageCount += member.messageCount + node.commonGroupSessionIds.add(sessionId) + node.communityWeights.set( + communityId, + (node.communityWeights.get(communityId) ?? 0) + Math.max(1, member.messageCount) + ) + updateLastInteraction(node, member.lastMessageTs) + } + + if (ownerNode) { + ownerNode.groupMessageCount += facts.ownerMessageCount + for (const fact of facts.ownerEdges) { + if (fact.coOccurrenceCount <= 0 && fact.replyInteractionCount <= 0) continue + const contactNode = getOrCreateNode(nodes, sessionId, meta, fact.contact) + const ownerEdgeScore = computeRawEdgeWeight({ + coOccurrenceCount: fact.coOccurrenceCount, + coOccurrenceRawScore: fact.coOccurrenceRawScore, + replyInteractionCount: fact.replyInteractionCount, + }) + contribution.memberKeys.add(contactNode.key) + contribution.ownerConnectedKeys.add(contactNode.key) + contribution.ownerEdgeScores.set( + contactNode.key, + (contribution.ownerEdgeScores.get(contactNode.key) ?? 0) + ownerEdgeScore + ) + ownerNode.commonGroupSessionIds.add(sessionId) + ownerNode.communityWeights.set(communityId, (ownerNode.communityWeights.get(communityId) ?? 0) + 1) + updateLastInteraction(ownerNode, fact.lastInteractionTs) + applyOwnerContactEdge(edges, ownerNode, contactNode, { + coOccurrenceCount: fact.coOccurrenceCount, + coOccurrenceRawScore: fact.coOccurrenceRawScore, + replyInteractionCount: fact.replyInteractionCount, + repliesFromOwnerToContact: fact.repliesFromOwnerToContact, + repliesFromContactToOwner: fact.repliesFromContactToOwner, + lastInteractionTs: fact.lastInteractionTs, + sessionId, + }) + } + } + + for (const fact of facts.edges) { + const sourceNode = getOrCreateNode(nodes, sessionId, meta, fact.source) + const targetNode = getOrCreateNode(nodes, sessionId, meta, fact.target) + const sourceKey = sourceNode.key < targetNode.key ? sourceNode.key : targetNode.key + const targetKey = sourceNode.key < targetNode.key ? targetNode.key : sourceNode.key + const edge = getOrCreateEdge(edges, sourceKey, targetKey) + const currentEdgeId = edgeId(sourceKey, targetKey) + const relationshipEdgeScore = computeRawEdgeWeight(fact) + contribution.memberKeys.add(sourceNode.key) + contribution.memberKeys.add(targetNode.key) + contribution.relationshipEdgeIds.add(currentEdgeId) + contribution.relationshipEdgeEndpoints.set(currentEdgeId, [sourceKey, targetKey]) + contribution.relationshipEdgeScores.set( + currentEdgeId, + (contribution.relationshipEdgeScores.get(currentEdgeId) ?? 0) + relationshipEdgeScore + ) + edge.coOccurrenceCount += fact.coOccurrenceCount + edge.coOccurrenceRawScore += fact.coOccurrenceRawScore + edge.replyInteractionCount += fact.replyInteractionCount + if (sourceNode.key === sourceKey) { + edge.repliesFromSourceToTarget += fact.repliesFromSourceToTarget + edge.repliesFromTargetToSource += fact.repliesFromTargetToSource + } else { + edge.repliesFromSourceToTarget += fact.repliesFromTargetToSource + edge.repliesFromTargetToSource += fact.repliesFromSourceToTarget + } + edge.sourceSessionIds.add(sessionId) + edge.lastInteractionTs = maxNullableTs(edge.lastInteractionTs, fact.lastInteractionTs) + sourceNode.commonGroupSessionIds.add(sessionId) + targetNode.commonGroupSessionIds.add(sessionId) + sourceNode.communityWeights.set(communityId, (sourceNode.communityWeights.get(communityId) ?? 0) + 1) + targetNode.communityWeights.set(communityId, (targetNode.communityWeights.get(communityId) ?? 0) + 1) + updateLastInteraction(sourceNode, fact.lastInteractionTs) + updateLastInteraction(targetNode, fact.lastInteractionTs) + } + groupContributions.push(contribution) +} + +function buildGraph( + nodeAccumulators: NodeAccumulator[], + edgeAccumulators: EdgeAccumulator[], + communityLabels: Map, + groupContributions: GroupPanoramaContribution[], + limits: Required, + baseDiagnostics: PeopleRelationshipsDiagnostics +): BuildGraphResult { + const edgeWeights = new Map() + for (const edge of edgeAccumulators) { + const weight = computeEdgeWeight(edge) + edgeWeights.set(edgeId(edge.sourceKey, edge.targetKey), weight) + } + + for (const edge of edgeAccumulators) { + const weight = edgeWeights.get(edgeId(edge.sourceKey, edge.targetKey)) ?? 0 + const source = nodeAccumulators.find((node) => node.key === edge.sourceKey) + const target = nodeAccumulators.find((node) => node.key === edge.targetKey) + if (source) source.edgeWeight += weight + if (target) target.edgeWeight += weight + } + + const nodeScores = computeContactPriorityScores(nodeAccumulators) + const rankedNodes = nodeAccumulators + .map((node) => ({ + node, + score: nodeScores.get(node) ?? 0, + relationshipActivityScore: computeRelationshipActivityScore(node), + })) + .sort(compareRankedNodes) + const panoramaCandidateKeys = new Set( + rankedNodes.filter((item) => item.node.panoramaCandidate).map((item) => item.node.key) + ) + const panoramaEligibleEdgeIds = new Set( + edgeAccumulators.filter((edge) => edge.panoramaEligible).map((edge) => edgeId(edge.sourceKey, edge.targetKey)) + ) + const maxScore = Math.max(...rankedNodes.map((item) => item.score), 1) + const nodeByKey = new Map() + + rankedNodes.forEach((item, index) => { + const communityId = pickCommunityId(item.node) + const color = colorForCommunity(communityId) + nodeByKey.set(item.node.key, { + key: item.node.key, + kind: item.node.kind, + platform: item.node.platform, + platformId: item.node.platformId, + sessionScoped: item.node.sessionScoped, + sessionId: item.node.sessionId, + displayName: item.node.displayName, + aliases: [...item.node.aliases].filter((alias) => alias !== item.node.displayName), + avatar: item.node.avatar, + pool: item.node.isFriend ? 'friend' : 'non_friend', + friendSource: item.node.friendSource, + score: roundNum(item.score), + rank: index + 1, + communityId, + x: 0, + y: 0, + size: roundNum(4 + Math.sqrt(item.score / maxScore) * 18, 2), + color, + labelVisibility: item.node.kind === 'owner' || index < 30 ? 2 : index < 160 ? 1 : 0, + lastInteractionTs: item.node.lastInteractionTs, + privateMessageCount: item.node.privateMessageCount, + groupMessageCount: item.node.groupMessageCount, + commonGroupCount: item.node.commonGroupSessionIds.size, + searchText: [ + item.node.displayName, + item.node.platformId, + ...item.node.aliases, + ...(item.node.kind === 'owner' ? ['我', 'me', 'myself', 'owner'] : []), + ] + .join(' ') + .toLowerCase(), + }) + }) + + const allNodes = layoutNodes([...nodeByKey.values()], communityLabels) + const allEdges = edgeAccumulators + .map((edge) => toGraphEdge(edge, edgeWeights.get(edgeId(edge.sourceKey, edge.targetKey)) ?? 0)) + .filter((edge) => nodeByKey.has(edge.sourceKey) && nodeByKey.has(edge.targetKey)) + .sort(compareEdges) + const communities = buildCommunities(allNodes, communityLabels, groupContributions) + const coreNodeKeys = new Set( + allNodes + .filter((node) => panoramaCandidateKeys.has(node.key)) + .slice(0, limits.coreNodeLimit) + .map((node) => node.key) + ) + const coreEdges = cropEdges( + allEdges.filter( + (edge) => + coreNodeKeys.has(edge.sourceKey) && coreNodeKeys.has(edge.targetKey) && panoramaEligibleEdgeIds.has(edge.id) + ), + limits + ) + const graphNodes = allNodes.filter((node) => coreNodeKeys.has(node.key)) + const graph: PeopleRelationshipsGraphData = { + nodes: graphNodes, + edges: coreEdges, + communities: filterCommunities(communities, graphNodes), + } + const diagnostics: PeopleRelationshipsDiagnostics = { + ...baseDiagnostics, + totalNodes: allNodes.length, + totalEdges: allEdges.length, + panoramaCandidateNodes: panoramaCandidateKeys.size, + coreNodeCount: graph.nodes.length, + coreEdgeCount: graph.edges.length, + } + return { nodes: allNodes, edges: allEdges, communities, graph, diagnostics } +} + +function cropEdges( + edges: PeopleRelationshipGraphEdge[], + limits: Required +): PeopleRelationshipGraphEdge[] { + const counts = new Map() + const kept: PeopleRelationshipGraphEdge[] = [] + for (const edge of sortEdgesForDisplay(edges)) { + if (kept.length >= limits.coreEdgeLimit) break + const sourceCount = counts.get(edge.sourceKey) ?? 0 + const targetCount = counts.get(edge.targetKey) ?? 0 + if (sourceCount >= limits.perNodeEdgeLimit || targetCount >= limits.perNodeEdgeLimit) continue + kept.push(edge) + counts.set(edge.sourceKey, sourceCount + 1) + counts.set(edge.targetKey, targetCount + 1) + } + return kept +} + +function layoutNodes( + nodes: PeopleRelationshipGraphNode[], + communityLabels: Map +): PeopleRelationshipGraphNode[] { + const byCommunity = new Map() + for (const node of nodes) { + const group = byCommunity.get(node.communityId) ?? [] + group.push(node) + byCommunity.set(node.communityId, group) + } + + const sortedCommunities = [...byCommunity.entries()].sort((a, b) => { + if (a[0] === PRIVATE_COMMUNITY_ID) return -1 + if (b[0] === PRIVATE_COMMUNITY_ID) return 1 + const scoreA = a[1].reduce((sum, node) => sum + node.score, 0) + const scoreB = b[1].reduce((sum, node) => sum + node.score, 0) + return scoreB - scoreA || (communityLabels.get(a[0]) ?? a[0]).localeCompare(communityLabels.get(b[0]) ?? b[0]) + }) + + for (const [communityIndex, [communityId, communityNodes]] of sortedCommunities.entries()) { + const baseAngle = communityAngle(communityIndex, communityId) + communityNodes.sort(compareNodes) + for (const [index, node] of communityNodes.entries()) { + if (node.kind === 'owner') { + node.x = 0 + node.y = 0 + continue + } + + const angle = egoNodeAngle(baseAngle, communityId, node.key, index, communityNodes.length) + const radius = egoNodeRadius(node, communityId, communityNodes.length) + node.x = roundNum(Math.cos(angle) * radius, 2) + node.y = roundNum(Math.sin(angle) * radius, 2) + } + } + return nodes.sort(compareNodes) +} + +function communityAngle(index: number, communityId: string): number { + if (communityId === PRIVATE_COMMUNITY_ID) return -Math.PI / 2 + return index * 2.399963229728653 +} + +function egoNodeAngle( + baseAngle: number, + communityId: string, + nodeKey: string, + index: number, + communitySize: number +): number { + const seedOffset = ((stableHash(`${communityId}:${nodeKey}:angle`) % 1000) / 1000 - 0.5) * 0.16 + const centeredIndex = index - (communitySize - 1) / 2 + const spacing = communitySize <= 4 ? 0.38 : communitySize <= 12 ? 0.24 : 0.12 + const maxSpread = communitySize <= 4 ? 0.75 : communitySize <= 20 ? 1.1 : 1.35 + return baseAngle + clamp(centeredIndex * spacing + seedOffset, -maxSpread, maxSpread) +} + +function egoNodeRadius(node: PeopleRelationshipGraphNode, communityId: string, communitySize: number): number { + const rankPenalty = Math.sqrt(Math.max(0, node.rank - 1)) * 26 + const scoreBonus = Math.min(260, Math.sqrt(Math.max(0, node.score)) * 22) + const privateBonus = node.privateMessageCount > 0 ? Math.min(220, Math.log1p(node.privateMessageCount) * 48) : 0 + const friendBonus = node.pool === 'friend' ? 280 : 0 + const commonGroupBonus = Math.min(180, node.commonGroupCount * 18) + // 大群更可能是同事群、兴趣群或通知群;这里只把它作为布局噪声惩罚,不改变关系评分。 + const groupSizePenalty = communityId.startsWith('group:') + ? Math.min(1400, Math.max(0, communitySize - 6) * 22 + Math.sqrt(communitySize) * 24) + : 0 + const baseRadius = node.pool === 'friend' ? 430 : 760 + const minRadius = node.pool === 'friend' ? 160 : 340 + return roundNum( + clamp( + baseRadius + rankPenalty + groupSizePenalty - scoreBonus - privateBonus - friendBonus - commonGroupBonus, + minRadius, + 2600 + ), + 2 + ) +} + +function buildCommunities( + nodes: PeopleRelationshipGraphNode[], + communityLabels: Map, + groupContributions: GroupPanoramaContribution[] +): PeopleRelationshipCommunity[] { + const groups = new Map() + for (const node of nodes) { + const group = groups.get(node.communityId) ?? [] + group.push(node) + groups.set(node.communityId, group) + } + const groupMemberCounts = new Map(groupContributions.map((item) => [`group:${item.sessionId}`, item.memberKeys.size])) + const communitiesById = new Map() + + for (const [id, group] of groups) { + const x = group.reduce((sum, node) => sum + node.x, 0) / group.length + const y = group.reduce((sum, node) => sum + node.y, 0) / group.length + communitiesById.set(id, { + id, + label: communityLabels.get(id) ?? id, + size: groupMemberCounts.get(id) ?? group.length, + x: roundNum(x, 2), + y: roundNum(y, 2), + color: colorForCommunity(id), + }) + } + + for (const [id, label] of communityLabels) { + if (communitiesById.has(id) || !id.startsWith('group:')) continue + communitiesById.set(id, { + id, + label, + size: groupMemberCounts.get(id) ?? 0, + x: 0, + y: 0, + color: colorForCommunity(id), + }) + } + + return [...communitiesById.values()].sort((a, b) => b.size - a.size || a.label.localeCompare(b.label)) +} + +function filterNeighborhoodCommunities( + communities: PeopleRelationshipCommunity[], + relatedEdges: PeopleRelationshipGraphEdge[] +): PeopleRelationshipCommunity[] { + const communityById = new Map(communities.map((community) => [community.id, community])) + const sourceCommunityIds = new Set() + for (const edge of relatedEdges) { + for (const sessionId of edge.sourceSessionIds) sourceCommunityIds.add(`group:${sessionId}`) + } + return [...sourceCommunityIds] + .map((id) => communityById.get(id)) + .filter((community): community is PeopleRelationshipCommunity => Boolean(community)) + .sort((a, b) => b.size - a.size || a.label.localeCompare(b.label)) +} + +function filterCommunities( + communities: PeopleRelationshipCommunity[], + nodes: PeopleRelationshipGraphNode[] +): PeopleRelationshipCommunity[] { + const ids = new Set(nodes.map((node) => node.communityId)) + return communities.filter((community) => ids.has(community.id)) +} + +function applyPanoramaContributions( + nodes: NodeAccumulator[], + edges: Map, + groupContributions: GroupPanoramaContribution[], + diagnostics: PeopleRelationshipsDiagnostics +): void { + const nodeByKey = new Map(nodes.map((node) => [node.key, node])) + for (const contribution of groupContributions) { + const decision = evaluateGroupPanoramaContribution(contribution, nodeByKey) + if (!decision.included) { + diagnostics.panoramaExcludedLowValueGroupSessions++ + diagnostics.panoramaExcludedGroupMembers += contribution.memberKeys.size + continue + } + + diagnostics.panoramaIncludedGroupSessions++ + incrementPanoramaReason(diagnostics, decision.reason) + + for (const key of decision.selectedMemberKeys) { + const node = nodeByKey.get(key) + if (node) node.panoramaCandidate = true + } + for (const edgeId of contribution.relationshipEdgeIds) { + const endpoints = contribution.relationshipEdgeEndpoints.get(edgeId) + if (!endpoints) continue + if (!decision.selectedMemberKeys.has(endpoints[0]) || !decision.selectedMemberKeys.has(endpoints[1])) continue + const edge = edges.get(edgeId) + if (edge) edge.panoramaEligible = true + } + for (const key of contribution.ownerConnectedKeys) { + if (!decision.selectedMemberKeys.has(key)) continue + const ownerKey = buildOwnerKeyForContribution(nodeByKey, key) + if (!ownerKey) continue + const ownerEdgeId = sortedEdgeId(ownerKey, key) + const edge = edges.get(ownerEdgeId) + if (edge) edge.panoramaEligible = true + } + + diagnostics.panoramaIncludedGroupMembers += decision.selectedMemberKeys.size + diagnostics.panoramaExcludedGroupMembers += Math.max( + 0, + contribution.memberKeys.size - decision.selectedMemberKeys.size + ) + } +} + +function evaluateGroupPanoramaContribution( + contribution: GroupPanoramaContribution, + nodeByKey: Map +): { included: boolean; reason: string; selectedMemberKeys: Set } { + const friendKeys = [...contribution.memberKeys].filter((key) => nodeByKey.get(key)?.isFriend === true) + const memberCount = contribution.memberKeys.size + const friendCount = friendKeys.length + const friendRatio = memberCount > 0 ? friendCount / memberCount : 0 + const ownerConnectedCount = contribution.ownerConnectedKeys.size + const totalGroupMessages = + contribution.ownerMessageCount + + [...contribution.memberMessageCounts.values()].reduce((sum, count) => sum + count, 0) + const ownerMessageRatio = totalGroupMessages > 0 ? contribution.ownerMessageCount / totalGroupMessages : 0 + const friendConnectedScores = computeFriendConnectedScores(contribution, new Set(friendKeys)) + contribution.friendConnectedScores.clear() + for (const [key, score] of friendConnectedScores) contribution.friendConnectedScores.set(key, score) + + let reason: string | null = null + if (friendCount === 0) { + reason = null + } else if (friendCount >= MIN_GROUP_FRIENDS_FOR_PANORAMA) { + reason = 'friend_count' + } else if (friendCount >= 2 && friendRatio >= MIN_GROUP_FRIEND_RATIO_FOR_PANORAMA) { + reason = 'friend_ratio' + } else if (ownerConnectedCount >= MIN_OWNER_CONNECTED_MEMBERS_FOR_PANORAMA) { + reason = 'owner_connected' + } else if ( + contribution.ownerMessageCount >= MIN_OWNER_GROUP_MESSAGES_FOR_PANORAMA || + (contribution.ownerMessageCount >= MIN_OWNER_GROUP_MESSAGES_FOR_RATIO && + ownerMessageRatio >= MIN_OWNER_GROUP_MESSAGE_RATIO_FOR_PANORAMA) + ) { + reason = 'owner_activity' + } else if (memberCount <= SMALL_GROUP_MEMBER_LIMIT_FOR_FRIEND_EDGE && friendConnectedScores.size > 0) { + reason = 'friend_connected' + } + + if (!reason) return { included: false, reason: 'low_value', selectedMemberKeys: new Set() } + return { + included: true, + reason, + selectedMemberKeys: selectPanoramaMembers(contribution, nodeByKey, new Set(friendKeys)), + } +} + +function computeFriendConnectedScores( + contribution: GroupPanoramaContribution, + friendKeys: Set +): Map { + const scores = new Map() + for (const [edgeId, endpoints] of contribution.relationshipEdgeEndpoints) { + const score = contribution.relationshipEdgeScores.get(edgeId) ?? 0 + const [sourceKey, targetKey] = endpoints + if (friendKeys.has(sourceKey) && !friendKeys.has(targetKey)) { + scores.set(targetKey, (scores.get(targetKey) ?? 0) + score) + } else if (friendKeys.has(targetKey) && !friendKeys.has(sourceKey)) { + scores.set(sourceKey, (scores.get(sourceKey) ?? 0) + score) + } + } + return scores +} + +function selectPanoramaMembers( + contribution: GroupPanoramaContribution, + nodeByKey: Map, + friendKeys: Set +): Set { + const selected = new Set(friendKeys) + const remainingLimit = () => Math.max(0, MAX_MEMBERS_PER_GROUP_FOR_PANORAMA - selected.size) + + addTopScoredKeys(selected, contribution.ownerEdgeScores, MAX_OWNER_CONNECTED_NON_FRIENDS_PER_GROUP, remainingLimit()) + addTopScoredKeys( + selected, + contribution.friendConnectedScores, + MAX_FRIEND_CONNECTED_NON_FRIENDS_PER_GROUP, + remainingLimit() + ) + addTopScoredKeys( + selected, + computeHighSignalNonFriendScores(contribution, nodeByKey, friendKeys), + MAX_HIGH_SIGNAL_NON_FRIENDS_PER_GROUP, + remainingLimit() + ) + return selected +} + +function computeHighSignalNonFriendScores( + contribution: GroupPanoramaContribution, + nodeByKey: Map, + friendKeys: Set +): Map { + const scores = new Map() + for (const key of contribution.memberKeys) { + const node = nodeByKey.get(key) + if (!node || node.kind === 'owner' || friendKeys.has(key)) continue + const messageScore = Math.log1p(contribution.memberMessageCounts.get(key) ?? 0) + const ownerScore = contribution.ownerEdgeScores.get(key) ?? 0 + const friendScore = contribution.friendConnectedScores.get(key) ?? 0 + const relationshipScore = sumRelationshipScoresForKey(contribution, key) + const score = ownerScore * 2 + friendScore * 1.5 + relationshipScore + messageScore + if (score > 0) scores.set(key, score) + } + return scores +} + +function sumRelationshipScoresForKey(contribution: GroupPanoramaContribution, key: string): number { + let total = 0 + for (const [edgeId, endpoints] of contribution.relationshipEdgeEndpoints) { + if (endpoints[0] === key || endpoints[1] === key) total += contribution.relationshipEdgeScores.get(edgeId) ?? 0 + } + return total +} + +function addTopScoredKeys( + selected: Set, + scores: Map, + limit: number, + remainingLimit: number +): void { + if (limit <= 0 || remainingLimit <= 0) return + const items = [...scores.entries()] + .filter(([key, score]) => !selected.has(key) && score > 0) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + for (const [key] of items.slice(0, Math.min(limit, remainingLimit))) { + selected.add(key) + } +} + +function incrementPanoramaReason(diagnostics: PeopleRelationshipsDiagnostics, reason: string): void { + diagnostics.panoramaGroupInclusionReasons[reason] = (diagnostics.panoramaGroupInclusionReasons[reason] ?? 0) + 1 +} + +function buildOwnerKeyForContribution(nodeByKey: Map, contactKey: string): string { + const contact = nodeByKey.get(contactKey) + return contact ? buildOwnerKey() : '' +} + +function toGraphEdge(edge: EdgeAccumulator, weight: number): PeopleRelationshipGraphEdge { + return { + id: edgeId(edge.sourceKey, edge.targetKey), + sourceKey: edge.sourceKey, + targetKey: edge.targetKey, + weight: roundNum(weight), + coOccurrenceCount: edge.coOccurrenceCount, + coOccurrenceRawScore: roundNum(edge.coOccurrenceRawScore), + replyInteractionCount: edge.replyInteractionCount, + repliesFromSourceToTarget: edge.repliesFromSourceToTarget, + repliesFromTargetToSource: edge.repliesFromTargetToSource, + sourceGroupCount: edge.sourceSessionIds.size, + sourceSessionIds: [...edge.sourceSessionIds].sort(), + lastInteractionTs: edge.lastInteractionTs, + visibility: weight >= 8 ? 2 : 1, + } +} + +function createFactsCacheContext(dir: string): PeopleRelationshipsFactsCacheContext { + return { + dir, + latestKey: buildPeopleRelationshipsSessionLatestCacheKey(PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION), + stats: createEmptyPeopleRelationshipsFactsCacheStats(), + } +} + +function readLatestFacts( + sessionId: string, + factsCache: PeopleRelationshipsFactsCacheContext | null, + dbVersion: string +): PeopleRelationshipsSessionLatestFacts | null { + if (!factsCache) return null + const cached = readCachedPeopleRelationshipsSessionLatest(sessionId, factsCache.dir, factsCache.latestKey, dbVersion) + if (!cached.hit) { + factsCache.stats.latestMisses++ + return null + } + factsCache.stats.latestHits++ + return cached.data +} + +function writeLatestFacts( + adapter: SessionRuntimeAdapter, + sessionId: string, + factsCache: PeopleRelationshipsFactsCacheContext | null, + data: PeopleRelationshipsSessionLatestFacts, + expectedDbVersion: string +): void { + if (!factsCache) return + const dbVersion = getSessionDbVersion(adapter, sessionId) + if (dbVersion !== expectedDbVersion) { + appLogger.debug('people-relationships', 'skipped latest facts cache write because db version changed', { + sessionId, + }) + return + } + writeCachedPeopleRelationshipsSessionLatest(sessionId, factsCache.dir, factsCache.latestKey, dbVersion, data) + factsCache.stats.writes++ +} + +function readSessionFacts( + sessionId: string, + timeRange: ContactsTimeRangeState, + factsCache: PeopleRelationshipsFactsCacheContext | null, + dbVersion: string +): PeopleRelationshipsSessionFacts | null { + if (!factsCache) return null + const cached = readCachedPeopleRelationshipsSessionFacts( + sessionId, + factsCache.dir, + buildPeopleRelationshipsSessionFactsCacheKey(PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, timeRange), + dbVersion + ) + if (!cached.hit) { + factsCache.stats.factsMisses++ + return null + } + factsCache.stats.factsHits++ + return cached.data +} + +function writeSessionFacts( + adapter: SessionRuntimeAdapter, + sessionId: string, + timeRange: ContactsTimeRangeState, + factsCache: PeopleRelationshipsFactsCacheContext | null, + facts: PeopleRelationshipsSessionFacts, + expectedDbVersion: string +): void { + if (!factsCache) return + const dbVersion = getSessionDbVersion(adapter, sessionId) + if (dbVersion !== expectedDbVersion) { + appLogger.debug('people-relationships', 'skipped session facts cache write because db version changed', { + sessionId, + }) + return + } + writeCachedPeopleRelationshipsSessionFacts( + sessionId, + factsCache.dir, + buildPeopleRelationshipsSessionFactsCacheKey(PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, timeRange), + dbVersion, + facts + ) + writeCachedPeopleRelationshipsSessionLatest(sessionId, factsCache.dir, factsCache.latestKey, dbVersion, { + latestMessageTs: facts.latestMessageTs, + }) + factsCache.stats.writes += 2 +} + +function getSessionDbVersion(adapter: SessionRuntimeAdapter, sessionId: string): string { + return getDbFileVersion(adapter.getDbPath(sessionId)) +} + +function getOrCreateNode( + nodes: Map, + sessionId: string, + meta: PeopleRelationshipsSessionMetaFacts, + contact: ContactMemberRef +): NodeAccumulator { + const sessionScoped = shouldScopeContactToSession(meta.platform, contact) + const key = buildContactKey(meta.platform, contact.platformId, sessionScoped ? sessionId : undefined) + const existing = nodes.get(key) + if (existing) { + mergeContactIdentity(existing, contact) + return existing + } + + const created: NodeAccumulator = { + key, + kind: 'contact', + platform: meta.platform, + platformId: contact.platformId, + sessionScoped, + sessionId: sessionScoped ? sessionId : undefined, + displayName: contact.name || contact.platformId, + aliases: new Set([contact.platformId, contact.name, ...contact.aliases].filter(Boolean)), + avatar: contact.avatar, + isFriend: false, + privateMessageCount: 0, + activePrivateMonths: new Set(), + groupMessageCount: 0, + commonGroupSessionIds: new Set(), + ownerCoOccurrenceCount: 0, + ownerCoOccurrenceRawScore: 0, + ownerReplyInteractionCount: 0, + ownerRepliesFromOwnerToContact: 0, + ownerRepliesFromContactToOwner: 0, + communityWeights: new Map(), + edgeWeight: 0, + panoramaCandidate: false, + lastInteractionTs: null, + } + nodes.set(key, created) + return created +} + +function getOrCreateOwnerNode( + nodes: Map, + meta: PeopleRelationshipsSessionMetaFacts +): NodeAccumulator | null { + if (!meta.owner) return null + const key = buildOwnerKey() + const existing = nodes.get(key) + if (existing) { + mergeContactIdentity(existing, meta.owner) + return existing + } + + const created: NodeAccumulator = { + key, + kind: 'owner', + platform: meta.platform, + platformId: meta.owner.platformId || meta.ownerId || OWNER_KEY_PREFIX, + sessionScoped: false, + displayName: meta.owner.name || meta.owner.platformId || 'Me', + aliases: new Set( + [meta.owner.platformId, meta.owner.name, ...meta.owner.aliases, '我', 'me', 'owner'].filter(Boolean) + ), + avatar: meta.owner.avatar, + isFriend: true, + privateMessageCount: 0, + activePrivateMonths: new Set(), + groupMessageCount: 0, + commonGroupSessionIds: new Set(), + ownerCoOccurrenceCount: 0, + ownerCoOccurrenceRawScore: 0, + ownerReplyInteractionCount: 0, + ownerRepliesFromOwnerToContact: 0, + ownerRepliesFromContactToOwner: 0, + communityWeights: new Map(), + edgeWeight: 0, + panoramaCandidate: true, + lastInteractionTs: null, + } + nodes.set(key, created) + return created +} + +function applyOwnerContactEdge( + edges: Map, + ownerNode: NodeAccumulator, + contactNode: NodeAccumulator, + facts: { + coOccurrenceCount: number + coOccurrenceRawScore: number + replyInteractionCount: number + repliesFromOwnerToContact: number + repliesFromContactToOwner: number + lastInteractionTs: number | null + sessionId: string + panoramaEligible?: boolean + } +): void { + const sourceKey = ownerNode.key < contactNode.key ? ownerNode.key : contactNode.key + const targetKey = ownerNode.key < contactNode.key ? contactNode.key : ownerNode.key + const edge = getOrCreateEdge(edges, sourceKey, targetKey) + edge.coOccurrenceCount += facts.coOccurrenceCount + edge.coOccurrenceRawScore += facts.coOccurrenceRawScore + edge.replyInteractionCount += facts.replyInteractionCount + if (sourceKey === ownerNode.key) { + edge.repliesFromSourceToTarget += facts.repliesFromOwnerToContact + edge.repliesFromTargetToSource += facts.repliesFromContactToOwner + } else { + edge.repliesFromSourceToTarget += facts.repliesFromContactToOwner + edge.repliesFromTargetToSource += facts.repliesFromOwnerToContact + } + edge.sourceSessionIds.add(facts.sessionId) + if (facts.panoramaEligible) edge.panoramaEligible = true + edge.lastInteractionTs = maxNullableTs(edge.lastInteractionTs, facts.lastInteractionTs) + contactNode.ownerCoOccurrenceCount += facts.coOccurrenceCount + contactNode.ownerCoOccurrenceRawScore += facts.coOccurrenceRawScore + contactNode.ownerReplyInteractionCount += facts.replyInteractionCount + contactNode.ownerRepliesFromOwnerToContact += facts.repliesFromOwnerToContact + contactNode.ownerRepliesFromContactToOwner += facts.repliesFromContactToOwner + updateLastInteraction(contactNode, facts.lastInteractionTs) +} + +function getOrCreateEdge(edges: Map, sourceKey: string, targetKey: string): EdgeAccumulator { + const id = edgeId(sourceKey, targetKey) + const existing = edges.get(id) + if (existing) return existing + const created: EdgeAccumulator = { + sourceKey, + targetKey, + coOccurrenceCount: 0, + coOccurrenceRawScore: 0, + replyInteractionCount: 0, + repliesFromSourceToTarget: 0, + repliesFromTargetToSource: 0, + sourceSessionIds: new Set(), + panoramaEligible: false, + lastInteractionTs: null, + } + edges.set(id, created) + return created +} + +function toPeopleRelationshipsSessionMetaFacts( + meta: SessionMeta, + owner?: ContactMemberRef +): PeopleRelationshipsSessionMetaFacts { + return { + name: meta.name, + platform: meta.platform, + type: meta.type as ChatType.PRIVATE | ChatType.GROUP, + ownerId: meta.ownerId, + owner, + } +} + +function shouldScopeContactToSession(platform: ChatPlatform, contact: ContactMemberRef): boolean { + if (isNameMatchPlatform(platform)) return true + return platform.trim().toLowerCase() === 'qq' && contact.platformId.trim() === contact.name.trim() +} + +function buildContactKey(platform: ChatPlatform, platformId: string, sessionId?: string): string { + const normalizedPlatform = platform.trim() + const normalizedPlatformId = platformId.trim() + if (!normalizedPlatform) throw new Error('platform is required') + if (!normalizedPlatformId) throw new Error('platformId is required') + return sessionId?.trim() + ? `${normalizedPlatform}:${sessionId.trim()}:${normalizedPlatformId}` + : `${normalizedPlatform}:${normalizedPlatformId}` +} + +function buildOwnerKey(): string { + return OWNER_KEY_PREFIX +} + +function mergeContactIdentity(acc: NodeAccumulator, contact: ContactMemberRef): void { + if (contact.name) acc.aliases.add(contact.name) + acc.aliases.add(contact.platformId) + for (const alias of contact.aliases) acc.aliases.add(alias) + if ((!acc.displayName || acc.displayName === acc.platformId) && contact.name) acc.displayName = contact.name + if (!acc.avatar && contact.avatar) acc.avatar = contact.avatar +} + +function pickCommunityId(node: NodeAccumulator): string { + let bestId = node.isFriend ? PRIVATE_COMMUNITY_ID : '' + let bestWeight = node.isFriend ? 1 : 0 + for (const [id, weight] of node.communityWeights) { + if (weight > bestWeight || (weight === bestWeight && id < bestId)) { + bestId = id + bestWeight = weight + } + } + return bestId || PRIVATE_COMMUNITY_ID +} + +function computeEdgeWeight(edge: EdgeAccumulator): number { + return computeRawEdgeWeight(edge) +} + +function computeRawEdgeWeight(edge: { + coOccurrenceRawScore: number + replyInteractionCount: number + coOccurrenceCount: number +}): number { + return ( + edge.coOccurrenceRawScore + + edge.replyInteractionCount * REPLY_WEIGHT + + edge.coOccurrenceCount * CO_OCCURRENCE_COUNT_WEIGHT + ) +} + +// 默认全景优先级复用联系人页评分;关系边权只作为同分时的补充,避免大群活跃成员挤掉真正联系人。 +function computeContactPriorityScores(nodes: NodeAccumulator[]): Map { + const result = new Map() + for (const node of nodes) { + if (node.kind === 'owner') result.set(node, 1) + } + + const friendInputs = nodes + .filter((node) => node.kind !== 'owner' && node.isFriend) + .map((node) => ({ + node, + privateMessageCount: node.privateMessageCount, + activeMonths: [...node.activePrivateMonths], + commonGroupCount: node.commonGroupSessionIds.size, + })) + const nonFriendInputs = nodes + .filter((node) => node.kind !== 'owner' && !node.isFriend) + .map((node) => ({ + node, + coOccurrenceRawScore: node.ownerCoOccurrenceRawScore, + commonGroupCount: node.commonGroupSessionIds.size, + replyInteractionCount: node.ownerReplyInteractionCount, + coOccurrenceCount: node.ownerCoOccurrenceCount, + repliesFromOwnerToContact: node.ownerRepliesFromOwnerToContact, + repliesFromContactToOwner: node.ownerRepliesFromContactToOwner, + })) + + const friendScores = computeFriendScores(friendInputs) + const nonFriendScores = computeNonFriendScores(nonFriendInputs) + for (const input of friendInputs) { + result.set(input.node, friendScores.get(input)?.score ?? 0) + } + for (const input of nonFriendInputs) { + result.set(input.node, nonFriendScores.get(input)?.score ?? 0) + } + return result +} + +function compareRankedNodes( + a: { node: NodeAccumulator; score: number; relationshipActivityScore: number }, + b: { node: NodeAccumulator; score: number; relationshipActivityScore: number } +): number { + const poolPriorityDiff = getNodePoolPriority(a.node) - getNodePoolPriority(b.node) + if (poolPriorityDiff !== 0) return poolPriorityDiff + return ( + b.score - a.score || + b.relationshipActivityScore - a.relationshipActivityScore || + a.node.displayName.localeCompare(b.node.displayName) + ) +} + +function getNodePoolPriority(node: NodeAccumulator): number { + if (node.kind === 'owner') return 0 + return node.isFriend ? 1 : 2 +} + +function computeRelationshipActivityScore(node: NodeAccumulator): number { + return ( + (node.isFriend ? 60 : 0) + + node.privateMessageCount * 1.8 + + node.groupMessageCount * 0.35 + + node.commonGroupSessionIds.size * 8 + + node.edgeWeight * 6 + ) +} + +function normalizeLimits(limits: PeopleRelationshipsComputeLimits = {}): Required { + return { + coreNodeLimit: normalizePositiveLimit(limits.coreNodeLimit, 1500), + coreEdgeLimit: normalizePositiveLimit(limits.coreEdgeLimit, 6000), + perNodeEdgeLimit: normalizePositiveLimit(limits.perNodeEdgeLimit, 12), + neighborhoodNodeLimit: normalizePositiveLimit(limits.neighborhoodNodeLimit, 80), + neighborhoodEdgeLimit: normalizePositiveLimit(limits.neighborhoodEdgeLimit, 240), + searchResultLimit: normalizePositiveLimit(limits.searchResultLimit, 20), + } +} + +function normalizePositiveLimit(value: number | undefined, fallback: number): number { + if (!Number.isFinite(value)) return fallback + return Math.max(1, Math.trunc(value!)) +} + +function createEmptyDiagnostics(): PeopleRelationshipsDiagnostics { + return { + processedPrivateSessions: 0, + processedGroupSessions: 0, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedFailedSessions: 0, + totalNodes: 0, + totalEdges: 0, + panoramaIncludedGroupSessions: 0, + panoramaExcludedLowValueGroupSessions: 0, + panoramaIncludedGroupMembers: 0, + panoramaExcludedGroupMembers: 0, + panoramaCandidateNodes: 0, + panoramaGroupInclusionReasons: {}, + coreNodeCount: 0, + coreEdgeCount: 0, + warnings: [], + } +} + +function updateLastInteraction(acc: NodeAccumulator, ts: number | null): void { + acc.lastInteractionTs = maxNullableTs(acc.lastInteractionTs, ts) +} + +function maxNullableTs(current: number | null, next: number | null): number | null { + if (next === null) return current + return Math.max(current ?? 0, next) +} + +function edgeId(sourceKey: string, targetKey: string): string { + return `${sourceKey}__${targetKey}` +} + +function sortedEdgeId(aKey: string, bKey: string): string { + return aKey < bKey ? edgeId(aKey, bKey) : edgeId(bKey, aKey) +} + +function colorForCommunity(id: string): string { + return COMMUNITY_COLORS[stableHash(id) % COMMUNITY_COLORS.length] +} + +function stableHash(value: string): number { + let hash = 2166136261 + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return hash >>> 0 +} + +export function compareNodes(a: PeopleRelationshipGraphNode, b: PeopleRelationshipGraphNode): number { + return a.rank - b.rank || a.displayName.localeCompare(b.displayName) +} + +export function compareEdges(a: PeopleRelationshipGraphEdge, b: PeopleRelationshipGraphEdge): number { + return b.weight - a.weight || a.id.localeCompare(b.id) +} + +function sortEdgesForDisplay(edges: PeopleRelationshipGraphEdge[]): PeopleRelationshipGraphEdge[] { + const anchorTs = edges.reduce((max, edge) => Math.max(max, edge.lastInteractionTs ?? 0), 0) + return [...edges].sort((a, b) => compareEdgesForDisplay(a, b, anchorTs)) +} + +function compareEdgesForDisplay( + a: PeopleRelationshipGraphEdge, + b: PeopleRelationshipGraphEdge, + anchorTs: number +): number { + const bRecentWeight = getRecentEdgeWeight(b, anchorTs) + const aRecentWeight = getRecentEdgeWeight(a, anchorTs) + if (bRecentWeight !== aRecentWeight) return bRecentWeight - aRecentWeight + return compareEdges(a, b) +} + +function getRecentEdgeWeight(edge: PeopleRelationshipGraphEdge, anchorTs: number): number { + if (!edge.lastInteractionTs || anchorTs <= 0) return edge.weight + const ageSeconds = Math.max(0, anchorTs - edge.lastInteractionTs) + const recencyFactor = + EDGE_RECENCY_FLOOR + (1 - EDGE_RECENCY_FLOOR) * Math.pow(0.5, ageSeconds / EDGE_RECENCY_HALF_LIFE_SECONDS) + return edge.weight * recencyFactor +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function roundNum(value: number, digits = 4): number { + const factor = 10 ** digits + return Math.round(value * factor) / factor +} diff --git a/packages/node-runtime/src/services/people/relationships/facts-cache.test.ts b/packages/node-runtime/src/services/people/relationships/facts-cache.test.ts new file mode 100644 index 000000000..656e2e9ba --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/facts-cache.test.ts @@ -0,0 +1,75 @@ +/** + * Tests for People relationships session facts cache helpers. + * + * Run: pnpm test -- packages/node-runtime/src/services/people/relationships/facts-cache.test.ts + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { ChatType, type ContactsTimeRangeState } from '@openchatlab/shared-types' +import { + buildPeopleRelationshipsSessionFactsCacheKey, + readCachedPeopleRelationshipsSessionFacts, + writeCachedPeopleRelationshipsSessionFacts, +} from './facts-cache' + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir(), 'chatlab-rel-cache-')) +} + +const range1y: ContactsTimeRangeState = { + preset: '1y', + anchorTs: 1700000000, + startTs: 1668464000, +} + +const rangeAll: ContactsTimeRangeState = { + preset: 'all', + anchorTs: 1700000000, + startTs: null, +} + +test('stores versioned relationship facts by DB version and time range', () => { + const dir = makeTempDir() + try { + const key = buildPeopleRelationshipsSessionFactsCacheKey('people-relationships-v1', range1y) + const facts = { + kind: 'group' as const, + meta: { + name: 'Group', + platform: 'weixin', + type: ChatType.GROUP, + ownerId: 'owner', + }, + latestMessageTs: 1700000000, + facts: { + members: [], + edges: [], + ownerEdges: [], + ownerMessageCount: 0, + }, + } + + writeCachedPeopleRelationshipsSessionFacts('session-a', dir, key, 'db-v1', facts) + + assert.deepEqual(readCachedPeopleRelationshipsSessionFacts('session-a', dir, key, 'db-v1'), { + hit: true, + data: facts, + }) + assert.deepEqual(readCachedPeopleRelationshipsSessionFacts('session-a', dir, key, 'db-v2'), { hit: false }) + assert.deepEqual( + readCachedPeopleRelationshipsSessionFacts( + 'session-a', + dir, + buildPeopleRelationshipsSessionFactsCacheKey('people-relationships-v1', rangeAll), + 'db-v1' + ), + { hit: false } + ) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/node-runtime/src/services/people/relationships/facts-cache.ts b/packages/node-runtime/src/services/people/relationships/facts-cache.ts new file mode 100644 index 000000000..e1298413f --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/facts-cache.ts @@ -0,0 +1,284 @@ +import { ChatType, type ChatPlatform, type ContactsTimeRangeState } from '@openchatlab/shared-types' +import type { + ContactMemberRef, + GroupContactFacts, + RelationshipGraphEdgeFact, + RelationshipGraphMemberFact, +} from '@openchatlab/core' +import { deleteSessionCache, getCache, setCache } from '../../../cache/session-cache' + +export const PEOPLE_RELATIONSHIPS_FACTS_FORMAT_VERSION = 3 + +export interface PeopleRelationshipsFactsCacheStats { + latestHits: number + latestMisses: number + factsHits: number + factsMisses: number + writes: number +} + +export interface PeopleRelationshipsSessionMetaFacts { + name: string + platform: ChatPlatform + type: ChatType.PRIVATE | ChatType.GROUP + ownerId: string | null + owner?: ContactMemberRef +} + +export interface PeopleRelationshipsCachedPrivateFacts { + contact: ContactMemberRef + privateMessageCount: number + activeMonths: string[] + lastMessageTs: number | null +} + +export interface PeopleRelationshipsCachedGroupFacts { + members: RelationshipGraphMemberFact[] + edges: RelationshipGraphEdgeFact[] + ownerEdges: GroupContactFacts[] + ownerMessageCount: number +} + +export type PeopleRelationshipsSessionFacts = + | { kind: 'not_chat_db'; latestMessageTs: null } + | { kind: 'missing_meta'; latestMessageTs: number | null } + | { kind: 'unsupported_type'; latestMessageTs: number | null } + | { kind: 'missing_owner'; meta: PeopleRelationshipsSessionMetaFacts; latestMessageTs: number | null } + | { kind: 'unresolved_owner'; meta: PeopleRelationshipsSessionMetaFacts; latestMessageTs: number | null } + | { kind: 'private_missing'; meta: PeopleRelationshipsSessionMetaFacts; latestMessageTs: number | null } + | { kind: 'private_ambiguous'; meta: PeopleRelationshipsSessionMetaFacts; latestMessageTs: number | null } + | { + kind: 'private' + meta: PeopleRelationshipsSessionMetaFacts + latestMessageTs: number | null + facts: PeopleRelationshipsCachedPrivateFacts + } + | { + kind: 'group' + meta: PeopleRelationshipsSessionMetaFacts + latestMessageTs: number | null + facts: PeopleRelationshipsCachedGroupFacts + } + +export interface PeopleRelationshipsSessionLatestFacts { + latestMessageTs: number | null +} + +export type PeopleRelationshipsCacheReadResult = { hit: true; data: T } | { hit: false } + +interface VersionedPeopleRelationshipsCacheEntry { + v: string + data: T +} + +export function createEmptyPeopleRelationshipsFactsCacheStats(): PeopleRelationshipsFactsCacheStats { + return { + latestHits: 0, + latestMisses: 0, + factsHits: 0, + factsMisses: 0, + writes: 0, + } +} + +export function buildPeopleRelationshipsSessionLatestCacheKey(algorithmVersion: string): string { + return `people-relationships:latest:v${PEOPLE_RELATIONSHIPS_FACTS_FORMAT_VERSION}:${algorithmVersion}` +} + +export function buildPeopleRelationshipsSessionFactsCacheKey( + algorithmVersion: string, + timeRange: ContactsTimeRangeState +): string { + return [ + 'people-relationships:facts', + `v${PEOPLE_RELATIONSHIPS_FACTS_FORMAT_VERSION}`, + algorithmVersion, + `preset:${timeRange.preset}`, + `start:${timeRange.startTs ?? 'all'}`, + ].join(':') +} + +export function readCachedPeopleRelationshipsSessionLatest( + sessionId: string, + cacheDir: string, + key: string, + dbVersion: string +): PeopleRelationshipsCacheReadResult { + const cached = getCache>( + sessionId, + key, + cacheDir + ) + if (!cached || cached.v !== dbVersion || !isPeopleRelationshipsSessionLatestFacts(cached.data)) return { hit: false } + return { hit: true, data: cached.data } +} + +export function writeCachedPeopleRelationshipsSessionLatest( + sessionId: string, + cacheDir: string, + key: string, + dbVersion: string, + data: PeopleRelationshipsSessionLatestFacts +): void { + setCache>( + sessionId, + key, + { v: dbVersion, data }, + cacheDir + ) +} + +export function readCachedPeopleRelationshipsSessionFacts( + sessionId: string, + cacheDir: string, + key: string, + dbVersion: string +): PeopleRelationshipsCacheReadResult { + const cached = getCache>( + sessionId, + key, + cacheDir + ) + if (!cached || cached.v !== dbVersion || !isPeopleRelationshipsSessionFacts(cached.data)) return { hit: false } + return { hit: true, data: cached.data } +} + +export function writeCachedPeopleRelationshipsSessionFacts( + sessionId: string, + cacheDir: string, + key: string, + dbVersion: string, + data: PeopleRelationshipsSessionFacts +): void { + setCache>( + sessionId, + key, + { v: dbVersion, data }, + cacheDir + ) +} + +export function deletePeopleRelationshipsSessionFactsCache(sessionId: string, cacheDir: string): void { + deleteSessionCache(sessionId, cacheDir) +} + +function isPeopleRelationshipsSessionLatestFacts(value: unknown): value is PeopleRelationshipsSessionLatestFacts { + return isObject(value) && isNullableNumber(value.latestMessageTs) +} + +function isPeopleRelationshipsSessionFacts(value: unknown): value is PeopleRelationshipsSessionFacts { + if (!isObject(value) || typeof value.kind !== 'string' || !isNullableNumber(value.latestMessageTs)) return false + switch (value.kind) { + case 'not_chat_db': + case 'missing_meta': + case 'unsupported_type': + return true + case 'missing_owner': + case 'unresolved_owner': + case 'private_missing': + case 'private_ambiguous': + return isPeopleRelationshipsSessionMetaFacts(value.meta) + case 'private': + return isPeopleRelationshipsSessionMetaFacts(value.meta) && isPeopleRelationshipsCachedPrivateFacts(value.facts) + case 'group': + return isPeopleRelationshipsSessionMetaFacts(value.meta) && isPeopleRelationshipsCachedGroupFacts(value.facts) + default: + return false + } +} + +function isPeopleRelationshipsSessionMetaFacts(value: unknown): value is PeopleRelationshipsSessionMetaFacts { + return ( + isObject(value) && + typeof value.name === 'string' && + typeof value.platform === 'string' && + (value.type === ChatType.PRIVATE || value.type === ChatType.GROUP) && + (typeof value.ownerId === 'string' || value.ownerId === null) && + (value.owner === undefined || isContactMemberRef(value.owner)) + ) +} + +function isPeopleRelationshipsCachedPrivateFacts(value: unknown): value is PeopleRelationshipsCachedPrivateFacts { + return ( + isObject(value) && + isContactMemberRef(value.contact) && + isFiniteNumber(value.privateMessageCount) && + Array.isArray(value.activeMonths) && + value.activeMonths.every((month) => typeof month === 'string') && + isNullableNumber(value.lastMessageTs) + ) +} + +function isPeopleRelationshipsCachedGroupFacts(value: unknown): value is PeopleRelationshipsCachedGroupFacts { + return ( + isObject(value) && + Array.isArray(value.members) && + value.members.every(isRelationshipGraphMemberFact) && + Array.isArray(value.edges) && + value.edges.every(isRelationshipGraphEdgeFact) && + Array.isArray(value.ownerEdges) && + value.ownerEdges.every(isGroupContactFacts) && + isFiniteNumber(value.ownerMessageCount) + ) +} + +function isGroupContactFacts(value: unknown): value is GroupContactFacts { + return ( + isObject(value) && + isContactMemberRef(value.contact) && + isFiniteNumber(value.messageCount) && + isFiniteNumber(value.coOccurrenceCount) && + isFiniteNumber(value.coOccurrenceRawScore) && + isFiniteNumber(value.replyInteractionCount) && + isFiniteNumber(value.repliesFromOwnerToContact) && + isFiniteNumber(value.repliesFromContactToOwner) && + isNullableNumber(value.lastInteractionTs) + ) +} + +function isRelationshipGraphMemberFact(value: unknown): value is RelationshipGraphMemberFact { + return ( + isObject(value) && + isContactMemberRef(value.contact) && + isFiniteNumber(value.messageCount) && + isNullableNumber(value.lastMessageTs) + ) +} + +function isRelationshipGraphEdgeFact(value: unknown): value is RelationshipGraphEdgeFact { + return ( + isObject(value) && + isContactMemberRef(value.source) && + isContactMemberRef(value.target) && + isFiniteNumber(value.coOccurrenceCount) && + isFiniteNumber(value.coOccurrenceRawScore) && + isFiniteNumber(value.replyInteractionCount) && + isFiniteNumber(value.repliesFromSourceToTarget) && + isFiniteNumber(value.repliesFromTargetToSource) && + isNullableNumber(value.lastInteractionTs) + ) +} + +function isContactMemberRef(value: unknown): value is ContactMemberRef { + return ( + isObject(value) && + isFiniteNumber(value.id) && + typeof value.platformId === 'string' && + typeof value.name === 'string' && + Array.isArray(value.aliases) && + value.aliases.every((alias) => typeof alias === 'string') && + (typeof value.avatar === 'string' || value.avatar === null) + ) +} + +function isNullableNumber(value: unknown): value is number | null { + return value === null || isFiniteNumber(value) +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/packages/node-runtime/src/services/people/relationships/index.ts b/packages/node-runtime/src/services/people/relationships/index.ts new file mode 100644 index 000000000..cc72dfeb2 --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/index.ts @@ -0,0 +1,14 @@ +export { PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, computePeopleRelationshipsSnapshot } from './compute' +export type { + PeopleRelationshipsComputeLimits, + PeopleRelationshipsComputeProgress, + PeopleRelationshipsSnapshot, +} from './compute' +export { createPeopleRelationshipsService } from './service' +export type { + PeopleRelationshipsComputeRunner, + PeopleRelationshipsService, + PeopleRelationshipsServiceDeps, + PeopleRelationshipsServiceOptions, +} from './service' +export { getPeopleRelationshipsDir, getPeopleRelationshipsFactsCacheDir } from './paths' diff --git a/packages/node-runtime/src/services/people/relationships/paths.ts b/packages/node-runtime/src/services/people/relationships/paths.ts new file mode 100644 index 000000000..3ddb18dc3 --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/paths.ts @@ -0,0 +1,13 @@ +import path from 'node:path' + +export const PEOPLE_DIR_NAME = 'people' +export const RELATIONSHIPS_DIR_NAME = 'relationships' +export const RELATIONSHIPS_FACTS_DIR_NAME = 'facts' + +export function getPeopleRelationshipsDir(userDataDir: string): string { + return path.join(userDataDir, PEOPLE_DIR_NAME, RELATIONSHIPS_DIR_NAME) +} + +export function getPeopleRelationshipsFactsCacheDir(userDataDir: string): string { + return path.join(getPeopleRelationshipsDir(userDataDir), RELATIONSHIPS_FACTS_DIR_NAME) +} diff --git a/packages/node-runtime/src/services/people/relationships/service.test.ts b/packages/node-runtime/src/services/people/relationships/service.test.ts new file mode 100644 index 000000000..a7b6b9367 --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/service.test.ts @@ -0,0 +1,570 @@ +/** + * Tests for People relationships runtime service. + * + * Run: pnpm test -- packages/node-runtime/src/services/people/relationships/service.test.ts + */ + +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import type { PathProvider } from '@openchatlab/core' +import type { PeopleRelationshipGraphEdge, PeopleRelationshipGraphNode } from '@openchatlab/shared-types' +import type { SessionRuntimeAdapter } from '../../adapters' +import { getContactsDir } from '../../contacts/paths' +import { writeContactOverrides } from '../../contacts/overrides' +import { PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, type PeopleRelationshipsSnapshot } from './compute' +import { createPeopleRelationshipsService } from './service' + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir(), 'chatlab-rel-service-')) +} + +function makePathProvider(rootDir: string): PathProvider { + return { + getSystemDir: () => rootDir, + getUserDataDir: () => path.join(rootDir, 'data'), + getDatabaseDir: () => path.join(rootDir, 'data', 'databases'), + getVectorDir: () => path.join(rootDir, 'vector'), + getAiDataDir: () => path.join(rootDir, 'ai'), + getSettingsDir: () => path.join(rootDir, 'settings'), + getCacheDir: () => path.join(rootDir, 'cache'), + getTempDir: () => path.join(rootDir, 'temp'), + getLogsDir: () => path.join(rootDir, 'logs'), + getDownloadsDir: () => path.join(rootDir, 'downloads'), + } +} + +function makeNode(overrides: Partial & { key: string }): PeopleRelationshipGraphNode { + return { + key: overrides.key, + kind: overrides.kind, + platform: 'weixin', + platformId: overrides.platformId ?? overrides.key.split(':').at(-1) ?? overrides.key, + sessionScoped: false, + displayName: overrides.displayName ?? overrides.key, + aliases: overrides.aliases ?? [], + avatar: overrides.avatar ?? null, + pool: overrides.pool ?? 'non_friend', + score: overrides.score ?? 1, + rank: overrides.rank ?? 1, + communityId: overrides.communityId ?? 'group:g1', + x: overrides.x ?? 0, + y: overrides.y ?? 0, + size: overrides.size ?? 8, + color: overrides.color ?? '#7dd3fc', + labelVisibility: overrides.labelVisibility ?? 1, + lastInteractionTs: overrides.lastInteractionTs ?? null, + privateMessageCount: overrides.privateMessageCount ?? 0, + groupMessageCount: overrides.groupMessageCount ?? 1, + commonGroupCount: overrides.commonGroupCount ?? 1, + searchText: overrides.searchText ?? `${overrides.displayName ?? overrides.key} ${overrides.key}`.toLowerCase(), + ...(overrides.friendSource ? { friendSource: overrides.friendSource } : {}), + ...(overrides.sessionId ? { sessionId: overrides.sessionId } : {}), + } +} + +function makeSnapshot(signature: string): PeopleRelationshipsSnapshot { + const owner = makeNode({ + key: 'owner:weixin', + kind: 'owner', + platformId: 'owner', + displayName: 'Me', + pool: 'friend', + rank: 1, + score: 120, + searchText: 'me owner 我', + }) + const alice = makeNode({ + key: 'weixin:alice', + platformId: 'alice', + displayName: 'Alice', + pool: 'friend', + friendSource: 'private', + rank: 2, + score: 90, + }) + const bob = makeNode({ key: 'weixin:bob', platformId: 'bob', displayName: 'Bob', rank: 3, score: 70 }) + const carol = makeNode({ key: 'weixin:carol', platformId: 'carol', displayName: 'Carol', rank: 4, score: 10 }) + const edge = { + id: 'weixin:alice__weixin:bob', + sourceKey: alice.key, + targetKey: bob.key, + weight: 8, + coOccurrenceCount: 2, + coOccurrenceRawScore: 2, + replyInteractionCount: 1, + repliesFromSourceToTarget: 0, + repliesFromTargetToSource: 1, + sourceGroupCount: 1, + sourceSessionIds: ['group-a'], + lastInteractionTs: 1704103202, + visibility: 2 as const, + } + return { + nodes: [owner, alice, bob, carol], + edges: [edge], + communities: [{ id: 'group:g1', label: 'Group', size: 4, x: 0, y: 0, color: '#7dd3fc' }], + graph: { + nodes: [owner, alice, bob], + edges: [edge], + communities: [{ id: 'group:g1', label: 'Group', size: 4, x: 0, y: 0, color: '#7dd3fc' }], + }, + diagnostics: { + processedPrivateSessions: 1, + processedGroupSessions: 1, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedFailedSessions: 0, + totalNodes: 4, + totalEdges: 1, + panoramaIncludedGroupSessions: 0, + panoramaExcludedLowValueGroupSessions: 0, + panoramaIncludedGroupMembers: 0, + panoramaExcludedGroupMembers: 0, + panoramaCandidateNodes: 3, + panoramaGroupInclusionReasons: {}, + coreNodeCount: 3, + coreEdgeCount: 1, + warnings: [], + }, + algorithmVersion: PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, + signature, + timeRange: { preset: '1y', anchorTs: null, startTs: null }, + computedAt: 1800000000, + workerStats: { durationMs: 1, totalSessions: 2, processedSessions: 2, skippedFailedSessions: 0 }, + limits: { + coreNodeLimit: 3, + coreEdgeLimit: 1, + perNodeEdgeLimit: 1, + neighborhoodNodeLimit: 80, + neighborhoodEdgeLimit: 240, + searchResultLimit: 20, + }, + } +} + +function makeEdge(sourceKey: string, targetKey: string, weight: number): PeopleRelationshipGraphEdge { + return { + id: `${sourceKey}__${targetKey}`, + sourceKey, + targetKey, + weight, + coOccurrenceCount: Math.round(weight), + coOccurrenceRawScore: weight, + replyInteractionCount: 0, + repliesFromSourceToTarget: 0, + repliesFromTargetToSource: 0, + sourceGroupCount: 1, + sourceSessionIds: ['group-a'], + lastInteractionTs: 1704103202, + visibility: 1, + } +} + +function makeAdapter(signatureSeed = 'stable'): SessionRuntimeAdapter { + return { + listSessionIds: () => ['session-a'], + openReadonly: () => null, + openWritable: () => null, + closeSession: () => {}, + getDbPath: () => path.join('/tmp', `chatlab-${signatureSeed}.db`), + deleteSessionFile: () => false, + ensureReadonly: () => { + throw new Error('not used') + }, + ensureWritable: () => { + throw new Error('not used') + }, + } as SessionRuntimeAdapter +} + +function makeFreshSignature(): string { + return `algorithm:${PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION}|range:1y|session-a:missing` +} + +test('returns search results from all snapshot nodes, including nodes outside the core graph', () => { + const dir = makeTempDir() + try { + const service = createPeopleRelationshipsService({ + adapter: makeAdapter(), + systemDir: dir, + runner: async () => { + throw new Error('runner should not be called for fresh injected snapshot') + }, + }) + service.replaceSnapshotForTests?.(makeSnapshot(makeFreshSignature())) + + const response = service.getGraph({ acceptStale: true, query: 'carol' }) + + assert.equal( + response.graph.nodes.some((node) => node.key === 'weixin:carol'), + false + ) + assert.deepEqual( + response.searchResults.map((result) => [result.key, result.inCoreGraph]), + [['weixin:carol', false]] + ) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('returns the owner node from full snapshot search', () => { + const dir = makeTempDir() + try { + const service = createPeopleRelationshipsService({ + adapter: makeAdapter(), + systemDir: dir, + runner: async () => { + throw new Error('runner should not be called for fresh injected snapshot') + }, + }) + service.replaceSnapshotForTests?.(makeSnapshot(makeFreshSignature())) + + const response = service.getGraph({ acceptStale: true, query: '我' }) + + assert.deepEqual( + response.searchResults.map((result) => [result.key, result.kind, result.inCoreGraph]), + [['owner:weixin', 'owner', true]] + ) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('returns a close relationships graph with owner, all friends, and top scored groupmates', () => { + const dir = makeTempDir() + try { + const service = createPeopleRelationshipsService({ + adapter: makeAdapter(), + systemDir: dir, + runner: async () => { + throw new Error('runner should not be called for fresh injected snapshot') + }, + }) + const snapshot = makeSnapshot(makeFreshSignature()) + const offCoreFriend = makeNode({ + key: 'weixin:off-core-friend', + displayName: 'Off Core Friend', + pool: 'friend', + friendSource: 'manual', + rank: 80, + score: 45, + }) + const groupmates = Array.from({ length: 55 }, (_, index) => + makeNode({ + key: `weixin:groupmate-${index + 1}`, + displayName: `Groupmate ${index + 1}`, + pool: 'non_friend', + rank: 100 + index, + score: 100 - index, + }) + ) + snapshot.nodes = [...snapshot.nodes, offCoreFriend, ...groupmates] + snapshot.edges = [ + ...snapshot.edges, + makeEdge('owner:weixin', offCoreFriend.key, 9), + ...groupmates.map((node, index) => makeEdge('owner:weixin', node.key, 60 - index)), + ] + service.replaceSnapshotForTests?.(snapshot) + + const response = service.getGraph({ acceptStale: true, graphScope: 'close' }) + const keys = response.graph.nodes.map((node) => node.key) + const groupmateKeys = response.graph.nodes.filter((node) => node.pool === 'non_friend').map((node) => node.key) + + assert.ok(keys.includes('owner:weixin')) + assert.ok(keys.includes('weixin:alice')) + assert.ok(keys.includes(offCoreFriend.key)) + assert.equal(groupmateKeys.length, 50) + assert.ok(groupmateKeys.includes('weixin:groupmate-1')) + assert.ok(!groupmateKeys.includes('weixin:groupmate-55')) + assert.ok(response.graph.edges.every((edge) => keys.includes(edge.sourceKey) && keys.includes(edge.targetKey))) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('excludes silent non-friend group roster members from the close relationships graph', () => { + const dir = makeTempDir() + try { + const service = createPeopleRelationshipsService({ + adapter: makeAdapter(), + systemDir: dir, + runner: async () => { + throw new Error('runner should not be called for fresh injected snapshot') + }, + }) + const snapshot = makeSnapshot(makeFreshSignature()) + const connectedGroupmate = makeNode({ + key: 'weixin:connected-groupmate', + displayName: 'Connected Groupmate', + pool: 'non_friend', + rank: 70, + score: 20, + groupMessageCount: 0, + commonGroupCount: 1, + }) + const activeGroupmate = makeNode({ + key: 'weixin:active-groupmate', + displayName: 'Active Groupmate', + pool: 'non_friend', + rank: 71, + score: 15, + groupMessageCount: 3, + commonGroupCount: 1, + }) + const silentGroupmates = Array.from({ length: 55 }, (_, index) => + makeNode({ + key: `weixin:silent-groupmate-${index + 1}`, + displayName: `Silent Groupmate ${index + 1}`, + pool: 'non_friend', + rank: 10 + index, + score: 100 - index, + groupMessageCount: 0, + commonGroupCount: 1, + }) + ) + snapshot.nodes = [...snapshot.nodes, connectedGroupmate, activeGroupmate, ...silentGroupmates] + snapshot.edges = [...snapshot.edges, makeEdge('owner:weixin', connectedGroupmate.key, 8)] + service.replaceSnapshotForTests?.(snapshot) + + const response = service.getGraph({ acceptStale: true, graphScope: 'close' }) + const keys = response.graph.nodes.map((node) => node.key) + + assert.ok(keys.includes(connectedGroupmate.key)) + assert.ok(keys.includes(activeGroupmate.key)) + assert.ok(!keys.some((key) => key.startsWith('weixin:silent-groupmate-'))) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('returns a friends relationships graph without groupmate nodes while keeping full search results', () => { + const dir = makeTempDir() + try { + const service = createPeopleRelationshipsService({ + adapter: makeAdapter(), + systemDir: dir, + runner: async () => { + throw new Error('runner should not be called for fresh injected snapshot') + }, + }) + const snapshot = makeSnapshot(makeFreshSignature()) + const manualFriend = makeNode({ + key: 'weixin:manual-friend', + displayName: 'Manual Friend', + pool: 'friend', + friendSource: 'manual', + rank: 5, + score: 55, + }) + const groupmate = makeNode({ + key: 'weixin:groupmate', + displayName: 'Only Groupmate', + pool: 'non_friend', + rank: 6, + score: 50, + searchText: 'only groupmate', + }) + snapshot.nodes = [...snapshot.nodes, manualFriend, groupmate] + snapshot.edges = [ + ...snapshot.edges, + makeEdge('owner:weixin', manualFriend.key, 12), + makeEdge(manualFriend.key, groupmate.key, 10), + makeEdge('weixin:alice', manualFriend.key, 8), + ] + service.replaceSnapshotForTests?.(snapshot) + + const response = service.getGraph({ acceptStale: true, graphScope: 'friends', query: 'groupmate' }) + const keys = response.graph.nodes.map((node) => node.key) + + assert.ok(keys.includes('owner:weixin')) + assert.ok(keys.includes('weixin:alice')) + assert.ok(keys.includes(manualFriend.key)) + assert.equal( + response.graph.nodes.every((node) => node.kind === 'owner' || node.pool === 'friend'), + true + ) + assert.equal(keys.includes(groupmate.key), false) + assert.equal( + response.graph.edges.every((edge) => keys.includes(edge.sourceKey) && keys.includes(edge.targetKey)), + true + ) + assert.equal( + response.searchResults.some((result) => result.key === groupmate.key), + true + ) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('applies manual contact friend overrides to people relationships graph scopes', () => { + const dir = makeTempDir() + try { + const pathProvider = makePathProvider(dir) + const manualFriendKey = 'weixin:manual-groupmate' + writeContactOverrides(getContactsDir(pathProvider.getUserDataDir()), { + version: 1, + manualFriends: { + [manualFriendKey]: { + key: manualFriendKey, + createdAt: 1800000000, + updatedAt: 1800000000, + }, + }, + }) + const service = createPeopleRelationshipsService({ + adapter: makeAdapter(), + pathProvider, + runner: async () => { + throw new Error('runner should not be called for fresh injected snapshot') + }, + }) + const snapshot = makeSnapshot(makeFreshSignature()) + const manualGroupmate = makeNode({ + key: manualFriendKey, + displayName: 'Manual Groupmate', + pool: 'non_friend', + rank: 6, + score: 50, + searchText: 'manual groupmate', + }) + snapshot.nodes = [...snapshot.nodes, manualGroupmate] + snapshot.edges = [...snapshot.edges, makeEdge('owner:weixin', manualGroupmate.key, 7)] + service.replaceSnapshotForTests?.(snapshot) + + const response = service.getGraph({ acceptStale: true, graphScope: 'friends', query: 'manual' }) + const manualNode = response.graph.nodes.find((node) => node.key === manualFriendKey) + const manualSearchResult = response.searchResults.find((node) => node.key === manualFriendKey) + + assert.equal(manualNode?.pool, 'friend') + assert.equal(manualNode?.friendSource, 'manual') + assert.equal(manualSearchResult?.pool, 'friend') + assert.equal( + response.graph.edges.some((edge) => edge.sourceKey === manualFriendKey || edge.targetKey === manualFriendKey), + true + ) + + const neighborhood = service.getNeighborhood(manualFriendKey, { acceptStale: true }) + assert.equal(neighborhood.contact?.pool, 'friend') + assert.equal(neighborhood.contact?.friendSource, 'manual') + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('adds missing manual friends to the default panorama graph', () => { + const dir = makeTempDir() + try { + const pathProvider = makePathProvider(dir) + const manualFriendKey = 'weixin:manual-panorama' + writeContactOverrides(getContactsDir(pathProvider.getUserDataDir()), { + version: 1, + manualFriends: { + [manualFriendKey]: { + key: manualFriendKey, + createdAt: 1800000000, + updatedAt: 1800000000, + }, + }, + }) + const service = createPeopleRelationshipsService({ + adapter: makeAdapter(), + pathProvider, + runner: async () => { + throw new Error('runner should not be called for fresh injected snapshot') + }, + }) + const snapshot = makeSnapshot(makeFreshSignature()) + const manualGroupmate = makeNode({ + key: manualFriendKey, + displayName: 'Manual Panorama', + pool: 'non_friend', + rank: 6, + score: 50, + communityId: 'group:manual', + searchText: 'manual panorama', + }) + snapshot.nodes = [...snapshot.nodes, manualGroupmate] + snapshot.edges = [...snapshot.edges, makeEdge('weixin:alice', manualGroupmate.key, 7)] + snapshot.communities = [ + ...snapshot.communities, + { id: 'group:manual', label: 'Manual Group', size: 1, x: 0, y: 0, color: '#facc15' }, + ] + service.replaceSnapshotForTests?.(snapshot) + + const response = service.getGraph({ acceptStale: true }) + const manualNode = response.graph.nodes.find((node) => node.key === manualFriendKey) + + assert.equal(manualNode?.pool, 'friend') + assert.equal(manualNode?.friendSource, 'manual') + assert.equal( + response.graph.edges.some((edge) => edge.sourceKey === manualFriendKey || edge.targetKey === manualFriendKey), + true + ) + assert.equal( + response.graph.communities.some((community) => community.id === 'group:manual'), + true + ) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('returns neighborhood graph for a searched node outside the core graph', () => { + const dir = makeTempDir() + try { + const service = createPeopleRelationshipsService({ + adapter: makeAdapter(), + systemDir: dir, + runner: async () => { + throw new Error('runner should not be called for fresh injected snapshot') + }, + }) + service.replaceSnapshotForTests?.(makeSnapshot(makeFreshSignature())) + + const response = service.getNeighborhood('weixin:carol', { acceptStale: true }) + + assert.equal(response.contact?.key, 'weixin:carol') + assert.deepEqual( + response.graph.nodes.map((node) => node.key), + ['weixin:carol'] + ) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('starts a background task when snapshot is missing and returns task state immediately', async () => { + const dir = makeTempDir() + try { + let runnerCalls = 0 + let releaseRunner!: (snapshot: PeopleRelationshipsSnapshot) => void + const service = createPeopleRelationshipsService({ + adapter: makeAdapter(), + systemDir: dir, + runner: () => { + runnerCalls++ + return new Promise((resolve) => { + releaseRunner = resolve + }) + }, + now: () => 1800000000, + }) + + const response = service.getGraph({ acceptStale: true }) + + assert.equal(response.cache.status, 'missing') + assert.equal(response.task?.status, 'running') + assert.equal(runnerCalls, 1) + releaseRunner(makeSnapshot(makeFreshSignature())) + await new Promise((resolve) => setTimeout(resolve, 0)) + await service.close() + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/packages/node-runtime/src/services/people/relationships/service.ts b/packages/node-runtime/src/services/people/relationships/service.ts new file mode 100644 index 000000000..f4a7467ec --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/service.ts @@ -0,0 +1,615 @@ +import type { PathProvider } from '@openchatlab/core' +import type { + ContactsTimeRangePreset, + PeopleRelationshipCommunity, + PeopleRelationshipGraphNode, + PeopleRelationshipsGraphData, + PeopleRelationshipsCacheState, + PeopleRelationshipsDiagnostics, + PeopleRelationshipsGraphScope, + PeopleRelationshipsGraphResponse, + PeopleRelationshipsNeighborhoodResponse, + PeopleRelationshipsSearchResult, + PeopleRelationshipsTaskState, +} from '@openchatlab/shared-types' +import type { RuntimeIdentity } from '../../../data-dir-compat' +import { appLogger } from '../../../logging/app-logger' +import type { SessionRuntimeAdapter } from '../../adapters' +import { readContactOverrides } from '../../contacts/overrides' +import { getContactsDir } from '../../contacts/paths' +import { + buildPeopleRelationshipsNeighborhoodGraph, + compareEdges, + compareNodes, + PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, + type PeopleRelationshipsComputeProgress, + type PeopleRelationshipsSnapshot, +} from './compute' +import { getPeopleRelationshipsDir } from './paths' +import { buildPeopleRelationshipsSignature } from './signature' +import { + cleanupPeopleRelationshipsSnapshotTempFiles, + readPeopleRelationshipsSnapshot, + writePeopleRelationshipsSnapshot, +} from './snapshot' +import { normalizePeopleRelationshipsTimeRangePreset, resolvePeopleRelationshipsTimeRange } from './time-range' +import { createPeopleRelationshipsWorkerRunner } from './worker-runner' + +export interface PeopleRelationshipsServiceOptions { + forceRecompute?: boolean + acceptStale?: boolean + timeRangePreset?: ContactsTimeRangePreset + graphScope?: PeopleRelationshipsGraphScope + query?: string +} + +export interface PeopleRelationshipsRunnerOptions { + signature: string + timeRangePreset: ContactsTimeRangePreset + onProgress: (progress: PeopleRelationshipsComputeProgress) => void + signal: AbortSignal +} + +export type PeopleRelationshipsComputeRunner = ( + options: PeopleRelationshipsRunnerOptions +) => Promise + +export interface PeopleRelationshipsServiceDeps { + adapter: SessionRuntimeAdapter + systemDir?: string + pathProvider?: PathProvider + runtimeIdentity?: RuntimeIdentity + nativeBinding?: string + workerEntryUrl?: string | URL + runner?: PeopleRelationshipsComputeRunner + now?: () => number +} + +export interface PeopleRelationshipsService { + getGraph(options?: PeopleRelationshipsServiceOptions): PeopleRelationshipsGraphResponse + getNeighborhood(key: string, options?: PeopleRelationshipsServiceOptions): PeopleRelationshipsNeighborhoodResponse + startRecompute(options?: PeopleRelationshipsServiceOptions): PeopleRelationshipsGraphResponse + invalidateRelationshipsCache(): void + close(): Promise + replaceSnapshotForTests?(snapshot: PeopleRelationshipsSnapshot): void +} + +interface InFlightTask { + id: string + signature: string + promise: Promise + abortController: AbortController +} + +const CLOSE_GRAPH_NON_FRIEND_LIMIT = 50 + +export function createPeopleRelationshipsService(deps: PeopleRelationshipsServiceDeps): PeopleRelationshipsService { + return new DefaultPeopleRelationshipsService(deps) +} + +class DefaultPeopleRelationshipsService implements PeopleRelationshipsService { + private readonly snapshots = new Map() + private inFlight: InFlightTask | null = null + private task: PeopleRelationshipsTaskState = createIdleTaskState() + private readonly snapshotDir: string + private readonly runner: PeopleRelationshipsComputeRunner + + constructor(private readonly deps: PeopleRelationshipsServiceDeps) { + this.snapshotDir = resolvePeopleRelationshipsSnapshotDir(deps) + cleanupPeopleRelationshipsSnapshotTempFiles(this.snapshotDir) + this.runner = + deps.runner ?? + createPeopleRelationshipsWorkerRunner({ + pathProvider: requirePathProvider(deps), + runtimeIdentity: deps.runtimeIdentity, + nativeBinding: deps.nativeBinding, + workerEntryUrl: deps.workerEntryUrl, + }) + } + + getGraph(options: PeopleRelationshipsServiceOptions = {}): PeopleRelationshipsGraphResponse { + const timeRangePreset = normalizePeopleRelationshipsTimeRangePreset(options.timeRangePreset) + const signature = buildPeopleRelationshipsSignature(this.deps.adapter, timeRangePreset) + const cacheStatus = this.getCacheStatus(signature, timeRangePreset) + if (this.shouldStartTaskFromRead(options, cacheStatus)) this.ensureTaskStarted(signature, timeRangePreset) + return this.toGraphResponse(signature, { ...options, timeRangePreset }) + } + + getNeighborhood( + key: string, + options: PeopleRelationshipsServiceOptions = {} + ): PeopleRelationshipsNeighborhoodResponse { + const timeRangePreset = normalizePeopleRelationshipsTimeRangePreset(options.timeRangePreset) + const signature = buildPeopleRelationshipsSignature(this.deps.adapter, timeRangePreset) + const cacheStatus = this.getCacheStatus(signature, timeRangePreset) + if (this.shouldStartTaskFromRead(options, cacheStatus)) this.ensureTaskStarted(signature, timeRangePreset) + const snapshot = this.getSnapshotForResponse(timeRangePreset) + const status = this.getCacheStatus(signature, timeRangePreset) + const includeSnapshot = shouldIncludeSnapshot(status, options.acceptStale) + const graph = includeSnapshot && snapshot ? buildPeopleRelationshipsNeighborhoodGraph(snapshot, key) : emptyGraph() + const contact = includeSnapshot ? (snapshot?.nodes.find((node) => node.key === key) ?? null) : null + return { + contact, + graph, + diagnostics: includeSnapshot + ? sanitizePeopleRelationshipsDiagnostics(snapshot?.diagnostics ?? createEmptyDiagnostics()) + : createEmptyDiagnostics(), + algorithmVersion: includeSnapshot + ? (snapshot?.algorithmVersion ?? PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION) + : PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, + timeRange: snapshot?.timeRange ?? resolvePeopleRelationshipsTimeRange(timeRangePreset, null), + cache: this.toCacheState(status, snapshot), + task: this.task, + } + } + + startRecompute(options: PeopleRelationshipsServiceOptions = {}): PeopleRelationshipsGraphResponse { + const timeRangePreset = normalizePeopleRelationshipsTimeRangePreset(options.timeRangePreset) + const signature = buildPeopleRelationshipsSignature(this.deps.adapter, timeRangePreset) + this.ensureTaskStarted(signature, timeRangePreset) + return this.toGraphResponse(signature, { ...options, acceptStale: true, timeRangePreset }) + } + + invalidateRelationshipsCache(): void { + this.snapshots.clear() + } + + async close(): Promise { + const inFlight = this.inFlight + if (!inFlight) return + this.inFlight = null + inFlight.abortController.abort() + this.task = { + ...this.task, + status: 'failed', + finishedAt: this.now(), + lastError: 'people relationships task aborted', + } + } + + replaceSnapshotForTests(snapshot: PeopleRelationshipsSnapshot): void { + this.snapshots.set(snapshot.timeRange.preset, snapshot) + } + + private shouldStartTaskFromRead( + options: PeopleRelationshipsServiceOptions, + cacheStatus: PeopleRelationshipsCacheState['status'] + ): boolean { + if (options.forceRecompute) return true + if (cacheStatus === 'fresh') return false + return this.task.status !== 'failed' + } + + private ensureTaskStarted(signature: string, timeRangePreset: ContactsTimeRangePreset): void { + if (this.inFlight) return + + const taskId = `people_relationships_${this.now()}_${Math.random().toString(36).slice(2)}` + this.task = { + id: taskId, + status: 'running', + startedAt: this.now(), + finishedAt: null, + processedSessions: 0, + totalSessions: this.deps.adapter.listSessionIds().length, + timeRangePreset, + } + + const abortController = new AbortController() + const promise = this.runner({ + signature, + timeRangePreset, + signal: abortController.signal, + onProgress: (progress) => { + if (this.task.id !== taskId || this.task.status !== 'running') return + this.task = { + ...this.task, + processedSessions: progress.processedSessions, + totalSessions: progress.totalSessions, + currentSessionId: progress.currentSessionId, + } + }, + }) + this.inFlight = { id: taskId, signature, promise, abortController } + + promise + .then((snapshot) => this.handleTaskSuccess(taskId, signature, snapshot)) + .catch((error) => this.handleTaskFailure(taskId, error)) + } + + private handleTaskSuccess(taskId: string, inputSignature: string, snapshot: PeopleRelationshipsSnapshot): void { + if (this.inFlight?.id !== taskId) return + this.inFlight = null + const latestSignature = buildPeopleRelationshipsSignature(this.deps.adapter, snapshot.timeRange.preset) + const finishedAt = this.now() + + if (inputSignature !== latestSignature || snapshot.signature !== latestSignature) { + this.task = { + ...this.task, + status: 'superseded', + finishedAt, + } + appLogger.info('people-relationships', 'people relationships worker result discarded because signature changed', { + inputSignature, + latestSignature, + }) + return + } + + try { + writePeopleRelationshipsSnapshot(this.snapshotDir, snapshot) + this.snapshots.set(snapshot.timeRange.preset, snapshot) + this.task = { + ...this.task, + status: 'succeeded', + finishedAt, + processedSessions: snapshot.workerStats.processedSessions, + totalSessions: snapshot.workerStats.totalSessions, + currentSessionId: undefined, + } + appLogger.info('people-relationships', 'people relationships worker snapshot persisted', { + nodeCount: snapshot.nodes.length, + edgeCount: snapshot.edges.length, + durationMs: snapshot.workerStats.durationMs, + }) + } catch (error) { + this.handleTaskFailure(taskId, error) + } + } + + private handleTaskFailure(taskId: string, error: unknown): void { + if (this.inFlight?.id === taskId) this.inFlight = null + const message = error instanceof Error ? error.message : String(error) + this.task = { + ...this.task, + status: 'failed', + finishedAt: this.now(), + lastError: message, + } + appLogger.error('people-relationships', 'people relationships worker failed', error) + } + + private getCacheStatus( + signature: string, + timeRangePreset: ContactsTimeRangePreset + ): PeopleRelationshipsCacheState['status'] { + const snapshot = this.getSnapshot(timeRangePreset) + if (!snapshot) return 'missing' + return snapshot.signature === signature ? 'fresh' : 'stale' + } + + private toGraphResponse( + signature: string, + options: PeopleRelationshipsServiceOptions = {} + ): PeopleRelationshipsGraphResponse { + const timeRangePreset = normalizePeopleRelationshipsTimeRangePreset(options.timeRangePreset) + const graphScope = normalizePeopleRelationshipsGraphScope(options.graphScope) + const snapshot = this.getSnapshotForResponse(timeRangePreset) + const status = this.getCacheStatus(signature, timeRangePreset) + const includeSnapshot = shouldIncludeSnapshot(status, options.acceptStale) + const graph = includeSnapshot && snapshot ? buildGraphForScope(snapshot, graphScope) : emptyGraph() + return { + graph, + searchResults: includeSnapshot && snapshot ? buildSearchResults(snapshot, options.query, graph) : [], + diagnostics: includeSnapshot + ? sanitizePeopleRelationshipsDiagnostics(snapshot?.diagnostics ?? createEmptyDiagnostics()) + : createEmptyDiagnostics(), + algorithmVersion: includeSnapshot + ? (snapshot?.algorithmVersion ?? PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION) + : PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION, + timeRange: snapshot?.timeRange ?? resolvePeopleRelationshipsTimeRange(timeRangePreset, null), + cache: this.toCacheState(status, snapshot), + task: this.task, + } + } + + private toCacheState( + status: PeopleRelationshipsCacheState['status'], + snapshot: PeopleRelationshipsSnapshot | null + ): PeopleRelationshipsCacheState { + return { + status, + computedAt: snapshot?.computedAt ?? null, + signature: snapshot?.signature, + staleReason: status === 'stale' ? 'signature_changed' : undefined, + } + } + + private getSnapshot(timeRangePreset: ContactsTimeRangePreset): PeopleRelationshipsSnapshot | null { + if (!this.snapshots.has(timeRangePreset)) { + this.snapshots.set( + timeRangePreset, + readPeopleRelationshipsSnapshot(this.snapshotDir, timeRangePreset, { now: this.deps.now }) + ) + } + return this.snapshots.get(timeRangePreset) ?? null + } + + private getSnapshotForResponse(timeRangePreset: ContactsTimeRangePreset): PeopleRelationshipsSnapshot | null { + const snapshot = this.getSnapshot(timeRangePreset) + if (!snapshot || !this.deps.pathProvider) return snapshot + return applyManualFriendOverridesToSnapshot( + snapshot, + readContactOverrides(getContactsDir(this.deps.pathProvider.getUserDataDir())) + ) + } + + private now(): number { + return this.deps.now?.() ?? Date.now() + } +} + +function shouldIncludeSnapshot(status: PeopleRelationshipsCacheState['status'], acceptStale?: boolean): boolean { + return status === 'fresh' || (status === 'stale' && acceptStale === true) +} + +function normalizePeopleRelationshipsGraphScope( + scope: PeopleRelationshipsGraphScope | undefined +): PeopleRelationshipsGraphScope { + return scope === 'close' || scope === 'friends' ? scope : 'panorama' +} + +function buildGraphForScope( + snapshot: PeopleRelationshipsSnapshot, + scope: PeopleRelationshipsGraphScope +): PeopleRelationshipsGraphData { + if (scope === 'friends') return buildFriendsRelationshipsGraph(snapshot) + if (scope === 'close') return buildCloseRelationshipsGraph(snapshot) + return snapshot.graph +} + +function buildFriendsRelationshipsGraph(snapshot: PeopleRelationshipsSnapshot): PeopleRelationshipsGraphData { + const nodes = snapshot.nodes.filter((node) => node.kind === 'owner' || node.pool === 'friend').sort(compareNodes) + const selectedKeys = new Set(nodes.map((node) => node.key)) + const edges = snapshot.edges.filter((edge) => selectedKeys.has(edge.sourceKey) && selectedKeys.has(edge.targetKey)) + + return { + nodes, + edges, + communities: filterCommunitiesForNodes(snapshot.communities, nodes), + } +} + +function buildCloseRelationshipsGraph(snapshot: PeopleRelationshipsSnapshot): PeopleRelationshipsGraphData { + const selectedKeys = new Set() + for (const node of snapshot.nodes) { + if (node.kind === 'owner' || node.pool === 'friend') selectedKeys.add(node.key) + } + + const edgeConnectedKeys = buildEdgeConnectedNodeKeys(snapshot.edges) + const topGroupmates = snapshot.nodes + .filter( + (node) => + node.kind !== 'owner' && + node.pool !== 'friend' && + node.score > 0 && + hasCloseGraphNonFriendActivity(node, edgeConnectedKeys) + ) + .sort(compareCloseGroupmates) + .slice(0, CLOSE_GRAPH_NON_FRIEND_LIMIT) + for (const node of topGroupmates) selectedKeys.add(node.key) + + const nodes = snapshot.nodes.filter((node) => selectedKeys.has(node.key)).sort(compareNodes) + const edges = snapshot.edges.filter((edge) => selectedKeys.has(edge.sourceKey) && selectedKeys.has(edge.targetKey)) + + return { + nodes, + edges, + communities: filterCommunitiesForNodes(snapshot.communities, nodes), + } +} + +function buildEdgeConnectedNodeKeys(edges: PeopleRelationshipsSnapshot['edges']): Set { + const keys = new Set() + for (const edge of edges) { + keys.add(edge.sourceKey) + keys.add(edge.targetKey) + } + return keys +} + +function hasCloseGraphNonFriendActivity( + node: PeopleRelationshipsSnapshot['nodes'][number], + edgeConnectedKeys: Set +): boolean { + // close 图只展示有真实互动的非好友:共同群 roster 本身不算互动信号。 + return node.groupMessageCount > 0 || edgeConnectedKeys.has(node.key) +} + +function compareCloseGroupmates( + a: PeopleRelationshipsSnapshot['nodes'][number], + b: PeopleRelationshipsSnapshot['nodes'][number] +): number { + return b.score - a.score || a.rank - b.rank || a.key.localeCompare(b.key) +} + +function filterCommunitiesForNodes( + communities: PeopleRelationshipCommunity[], + nodes: PeopleRelationshipsGraphData['nodes'] +): PeopleRelationshipCommunity[] { + const communitySizes = new Map() + for (const node of nodes) communitySizes.set(node.communityId, (communitySizes.get(node.communityId) ?? 0) + 1) + return communities + .filter((community) => communitySizes.has(community.id)) + .map((community) => ({ + ...community, + size: communitySizes.get(community.id) ?? community.size, + })) +} + +function buildSearchResults( + snapshot: PeopleRelationshipsSnapshot, + queryInput: string | undefined, + graph: PeopleRelationshipsGraphData +): PeopleRelationshipsSearchResult[] { + const query = queryInput?.trim().toLowerCase() ?? '' + if (!query) return [] + const visibleKeys = new Set(graph.nodes.map((node) => node.key)) + return snapshot.nodes + .filter((node) => node.searchText.includes(query)) + .sort(compareNodes) + .slice(0, snapshot.limits.searchResultLimit) + .map((node) => ({ + key: node.key, + kind: node.kind, + displayName: node.displayName, + platform: node.platform, + platformId: node.platformId, + avatar: node.avatar, + pool: node.pool, + friendSource: node.friendSource, + score: node.score, + rank: node.rank, + communityId: node.communityId, + inCoreGraph: visibleKeys.has(node.key), + })) +} + +function applyManualFriendOverridesToSnapshot( + snapshot: PeopleRelationshipsSnapshot, + overrides: ReturnType +): PeopleRelationshipsSnapshot { + const manualFriendKeys = new Set(Object.keys(overrides.manualFriends)) + if (manualFriendKeys.size === 0) return snapshot + + // 手动好友是本机覆盖数据,不写入派生 snapshot;响应阶段克隆节点,确保各图谱 scope 即时生效。 + const applyOverride = (node: PeopleRelationshipGraphNode): PeopleRelationshipGraphNode => { + if (node.kind === 'owner' || node.pool === 'friend' || !manualFriendKeys.has(node.key)) return node + return { + ...node, + pool: 'friend', + friendSource: 'manual', + } + } + const nodes = snapshot.nodes.map(applyOverride) + const nodeByKey = new Map(nodes.map((node) => [node.key, node])) + const graphNodes = includeManualFriendsInPanoramaGraphNodes( + snapshot.graph.nodes.map((node) => nodeByKey.get(node.key) ?? applyOverride(node)), + nodes, + manualFriendKeys + ) + const graphNodeKeys = new Set(graphNodes.map((node) => node.key)) + const graphEdges = includeManualFriendPanoramaEdges( + snapshot.graph.edges, + snapshot.edges, + graphNodeKeys, + manualFriendKeys + ) + return { + ...snapshot, + nodes, + graph: { + ...snapshot.graph, + nodes: graphNodes, + edges: graphEdges, + communities: filterCommunitiesForNodes(snapshot.communities, graphNodes), + }, + } +} + +function includeManualFriendsInPanoramaGraphNodes( + graphNodes: PeopleRelationshipGraphNode[], + nodes: PeopleRelationshipGraphNode[], + manualFriendKeys: Set +): PeopleRelationshipGraphNode[] { + const graphNodeKeys = new Set(graphNodes.map((node) => node.key)) + const result = [...graphNodes] + for (const node of nodes) { + if (!manualFriendKeys.has(node.key) || graphNodeKeys.has(node.key) || node.kind === 'owner') continue + result.push(node) + graphNodeKeys.add(node.key) + } + return result.sort(compareNodes) +} + +function includeManualFriendPanoramaEdges( + graphEdges: PeopleRelationshipsGraphData['edges'], + edges: PeopleRelationshipsSnapshot['edges'], + graphNodeKeys: Set, + manualFriendKeys: Set +): PeopleRelationshipsGraphData['edges'] { + // 手动好友是用户显式修正,只补它和当前全景节点之间的边,避免把被裁剪的全量图重新展开。 + const edgeIds = new Set(graphEdges.map((edge) => edge.id)) + const result = [...graphEdges] + for (const edge of edges) { + if (edgeIds.has(edge.id)) continue + if (!graphNodeKeys.has(edge.sourceKey) || !graphNodeKeys.has(edge.targetKey)) continue + if (!manualFriendKeys.has(edge.sourceKey) && !manualFriendKeys.has(edge.targetKey)) continue + result.push(edge) + edgeIds.add(edge.id) + } + return result.sort(compareEdges) +} + +function emptyGraph(): PeopleRelationshipsGraphData { + return { nodes: [], edges: [], communities: [] } +} + +function createEmptyDiagnostics(): PeopleRelationshipsDiagnostics { + return { + processedPrivateSessions: 0, + processedGroupSessions: 0, + skippedMissingOwnerSessions: 0, + skippedUnresolvedOwnerSessions: 0, + skippedAmbiguousPrivateSessions: 0, + skippedFailedSessions: 0, + totalNodes: 0, + totalEdges: 0, + panoramaIncludedGroupSessions: 0, + panoramaExcludedLowValueGroupSessions: 0, + panoramaIncludedGroupMembers: 0, + panoramaExcludedGroupMembers: 0, + panoramaCandidateNodes: 0, + panoramaGroupInclusionReasons: {}, + coreNodeCount: 0, + coreEdgeCount: 0, + warnings: [], + } +} + +function sanitizePeopleRelationshipsDiagnostics( + diagnostics: PeopleRelationshipsDiagnostics +): PeopleRelationshipsDiagnostics { + return { + processedPrivateSessions: diagnostics.processedPrivateSessions, + processedGroupSessions: diagnostics.processedGroupSessions, + skippedMissingOwnerSessions: diagnostics.skippedMissingOwnerSessions, + skippedUnresolvedOwnerSessions: diagnostics.skippedUnresolvedOwnerSessions, + skippedAmbiguousPrivateSessions: diagnostics.skippedAmbiguousPrivateSessions, + skippedFailedSessions: diagnostics.skippedFailedSessions, + totalNodes: diagnostics.totalNodes, + totalEdges: diagnostics.totalEdges, + panoramaIncludedGroupSessions: diagnostics.panoramaIncludedGroupSessions ?? 0, + panoramaExcludedLowValueGroupSessions: diagnostics.panoramaExcludedLowValueGroupSessions ?? 0, + panoramaIncludedGroupMembers: diagnostics.panoramaIncludedGroupMembers ?? 0, + panoramaExcludedGroupMembers: diagnostics.panoramaExcludedGroupMembers ?? 0, + panoramaCandidateNodes: diagnostics.panoramaCandidateNodes ?? 0, + panoramaGroupInclusionReasons: diagnostics.panoramaGroupInclusionReasons ?? {}, + coreNodeCount: diagnostics.coreNodeCount, + coreEdgeCount: diagnostics.coreEdgeCount, + warnings: diagnostics.warnings, + } +} + +function createIdleTaskState(): PeopleRelationshipsTaskState { + return { + id: null, + status: 'idle', + startedAt: null, + finishedAt: null, + processedSessions: 0, + totalSessions: 0, + } +} + +function requirePathProvider(deps: PeopleRelationshipsServiceDeps): PathProvider { + if (!deps.pathProvider) { + throw new Error('people relationships worker runner requires pathProvider') + } + return deps.pathProvider +} + +function resolvePeopleRelationshipsSnapshotDir(deps: PeopleRelationshipsServiceDeps): string { + if (deps.pathProvider) return getPeopleRelationshipsDir(deps.pathProvider.getUserDataDir()) + if (deps.systemDir) return deps.systemDir + throw new Error('people relationships service requires systemDir or pathProvider') +} diff --git a/packages/node-runtime/src/services/people/relationships/signature.ts b/packages/node-runtime/src/services/people/relationships/signature.ts new file mode 100644 index 000000000..201157a6b --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/signature.ts @@ -0,0 +1,20 @@ +import type { ContactsTimeRangePreset } from '@openchatlab/shared-types' +import { getDbFileVersion } from '../../../cache/analytics-cache' +import type { SessionRuntimeAdapter } from '../../adapters' +import { PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION } from './compute' +import { normalizePeopleRelationshipsTimeRangePreset } from './time-range' + +export function buildPeopleRelationshipsSignature( + adapter: SessionRuntimeAdapter, + timeRangePreset?: ContactsTimeRangePreset +): string { + const parts = [ + `algorithm:${PEOPLE_RELATIONSHIPS_ALGORITHM_VERSION}`, + `range:${normalizePeopleRelationshipsTimeRangePreset(timeRangePreset)}`, + ] + for (const sessionId of [...adapter.listSessionIds()].sort()) { + const dbPath = adapter.getDbPath(sessionId) + parts.push(`${sessionId}:${getDbFileVersion(dbPath)}`) + } + return parts.join('|') +} diff --git a/packages/node-runtime/src/services/people/relationships/snapshot.ts b/packages/node-runtime/src/services/people/relationships/snapshot.ts new file mode 100644 index 000000000..18e420395 --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/snapshot.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { ContactsTimeRangePreset } from '@openchatlab/shared-types' +import { appLogger } from '../../../logging/app-logger' +import type { PeopleRelationshipsSnapshot } from './compute' +import { normalizePeopleRelationshipsTimeRangePreset } from './time-range' + +const PEOPLE_RELATIONSHIPS_SNAPSHOT_TMP_PREFIX = 'people-relationships-snapshot.tmp-' + +export interface ReadPeopleRelationshipsSnapshotOptions { + now?: () => number +} + +export function getPeopleRelationshipsSnapshotPath( + snapshotDir: string, + timeRangePreset?: ContactsTimeRangePreset +): string { + const preset = normalizePeopleRelationshipsTimeRangePreset(timeRangePreset) + return path.join(snapshotDir, `graph-snapshot-${preset}.json`) +} + +export function readPeopleRelationshipsSnapshot( + snapshotDir: string, + timeRangePreset?: ContactsTimeRangePreset, + options: ReadPeopleRelationshipsSnapshotOptions = {} +): PeopleRelationshipsSnapshot | null { + const snapshotPath = getPeopleRelationshipsSnapshotPath(snapshotDir, timeRangePreset) + if (!fs.existsSync(snapshotPath)) return null + + try { + return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as PeopleRelationshipsSnapshot + } catch (error) { + const ts = options.now?.() ?? Date.now() + const backupPath = path.join(snapshotDir, `graph-snapshot.corrupt-${ts}.json`) + try { + fs.renameSync(snapshotPath, backupPath) + } catch (renameError) { + appLogger.warn('people-relationships', 'failed to backup corrupt people relationships snapshot', renameError) + } + appLogger.warn('people-relationships', 'people relationships snapshot is corrupt', error) + return null + } +} + +export function writePeopleRelationshipsSnapshot(snapshotDir: string, snapshot: PeopleRelationshipsSnapshot): void { + if (!fs.existsSync(snapshotDir)) fs.mkdirSync(snapshotDir, { recursive: true }) + const tmpPath = path.join(snapshotDir, `${PEOPLE_RELATIONSHIPS_SNAPSHOT_TMP_PREFIX}${process.pid}-${Date.now()}`) + fs.writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2), 'utf-8') + fs.renameSync(tmpPath, getPeopleRelationshipsSnapshotPath(snapshotDir, snapshot.timeRange.preset)) +} + +export function cleanupPeopleRelationshipsSnapshotTempFiles(snapshotDir: string): void { + if (!fs.existsSync(snapshotDir)) return + for (const name of fs.readdirSync(snapshotDir)) { + if (!name.startsWith(PEOPLE_RELATIONSHIPS_SNAPSHOT_TMP_PREFIX)) continue + try { + fs.rmSync(path.join(snapshotDir, name), { force: true }) + } catch (error) { + appLogger.warn('people-relationships', 'failed to remove people relationships snapshot temp file', error) + } + } +} diff --git a/packages/node-runtime/src/services/people/relationships/time-range.ts b/packages/node-runtime/src/services/people/relationships/time-range.ts new file mode 100644 index 000000000..ad8b415d5 --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/time-range.ts @@ -0,0 +1,36 @@ +import { + CONTACTS_TIME_RANGE_PRESETS, + type ContactsTimeRangePreset, + type ContactsTimeRangeState, +} from '@openchatlab/shared-types' + +const SECONDS_PER_YEAR = 365 * 24 * 60 * 60 +const DEFAULT_PEOPLE_RELATIONSHIPS_TIME_RANGE_PRESET: ContactsTimeRangePreset = '1y' + +const YEARS_BY_PRESET: Partial> = { + '1y': 1, + '2y': 2, + '3y': 3, + '5y': 5, +} + +export function normalizePeopleRelationshipsTimeRangePreset(value: unknown): ContactsTimeRangePreset { + return CONTACTS_TIME_RANGE_PRESETS.includes(value as ContactsTimeRangePreset) + ? (value as ContactsTimeRangePreset) + : DEFAULT_PEOPLE_RELATIONSHIPS_TIME_RANGE_PRESET +} + +export function resolvePeopleRelationshipsTimeRange( + presetInput: unknown, + anchorTs: number | null | undefined +): ContactsTimeRangeState { + const preset = normalizePeopleRelationshipsTimeRangePreset(presetInput) + const normalizedAnchor = typeof anchorTs === 'number' ? anchorTs : null + const years = YEARS_BY_PRESET[preset] + + return { + preset, + anchorTs: normalizedAnchor, + startTs: normalizedAnchor !== null && years ? normalizedAnchor - years * SECONDS_PER_YEAR : null, + } +} diff --git a/packages/node-runtime/src/services/people/relationships/worker-entry.ts b/packages/node-runtime/src/services/people/relationships/worker-entry.ts new file mode 100644 index 000000000..cc6d98e01 --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/worker-entry.ts @@ -0,0 +1,47 @@ +import { parentPort, workerData } from 'node:worker_threads' +import type { ContactsTimeRangePreset } from '@openchatlab/shared-types' +import { DatabaseManager } from '../../../database-manager' +import type { RuntimeIdentity } from '../../../data-dir-compat' +import { initAppLogger } from '../../../logging/app-logger' +import { StaticPathProvider, type StaticPathProviderSnapshot } from '../../../semantic-index/static-path-provider' +import { createDatabaseManagerAdapter } from '../../adapters' +import { computePeopleRelationshipsSnapshot, type PeopleRelationshipsComputeProgress } from './compute' +import { getPeopleRelationshipsFactsCacheDir } from './paths' + +interface PeopleRelationshipsWorkerStartupOptions { + paths: StaticPathProviderSnapshot + runtimeIdentity?: RuntimeIdentity + nativeBinding?: string + signature: string + timeRangePreset?: ContactsTimeRangePreset +} + +async function main(): Promise { + if (!parentPort) throw new Error('people relationships worker requires parentPort') + const options = workerData as PeopleRelationshipsWorkerStartupOptions + initAppLogger(options.paths.logsDir) + const pathProvider = new StaticPathProvider(options.paths) + const dbManager = new DatabaseManager(pathProvider, { + nativeBinding: options.nativeBinding, + runtime: options.runtimeIdentity, + allowMissingRuntimeForTests: !options.runtimeIdentity, + }) + const adapter = createDatabaseManagerAdapter(dbManager) + const onProgress = (progress: PeopleRelationshipsComputeProgress) => + parentPort?.postMessage({ type: 'progress', progress }) + const snapshot = computePeopleRelationshipsSnapshot({ + adapter, + signature: options.signature, + timeRangePreset: options.timeRangePreset, + factsCacheDir: getPeopleRelationshipsFactsCacheDir(pathProvider.getUserDataDir()), + onProgress, + }) + parentPort.postMessage({ type: 'success', snapshot }) +} + +main().catch((error) => { + parentPort?.postMessage({ + type: 'error', + error: error instanceof Error ? error.message : String(error), + }) +}) diff --git a/packages/node-runtime/src/services/people/relationships/worker-runner.ts b/packages/node-runtime/src/services/people/relationships/worker-runner.ts new file mode 100644 index 000000000..f9f3ddd1d --- /dev/null +++ b/packages/node-runtime/src/services/people/relationships/worker-runner.ts @@ -0,0 +1,127 @@ +import { existsSync } from 'node:fs' +import { Worker } from 'node:worker_threads' +import type { WorkerOptions } from 'node:worker_threads' +import type { PathProvider } from '@openchatlab/core' +import type { RuntimeIdentity } from '../../../data-dir-compat' +import { snapshotPathProvider } from '../../../semantic-index/static-path-provider' +import type { PeopleRelationshipsComputeProgress, PeopleRelationshipsSnapshot } from './compute' +import type { PeopleRelationshipsComputeRunner } from './service' + +export interface PeopleRelationshipsWorkerRunnerOptions { + pathProvider: PathProvider + runtimeIdentity?: RuntimeIdentity + nativeBinding?: string + workerEntryUrl?: string | URL +} + +interface PeopleRelationshipsWorkerMessage { + type: 'progress' | 'success' | 'error' + progress?: PeopleRelationshipsComputeProgress + snapshot?: PeopleRelationshipsSnapshot + error?: string +} + +type ModuleWorkerOptions = WorkerOptions & { type: 'module' } +type EntryExists = (url: URL) => boolean + +export function resolveDefaultPeopleRelationshipsWorkerEntryUrl( + currentModuleUrl: string | URL = import.meta.url, + entryExists: EntryExists = (url) => existsSync(url) +): URL { + const moduleUrl = typeof currentModuleUrl === 'string' ? currentModuleUrl : currentModuleUrl.href + if (moduleUrl.endsWith('.ts')) return new URL('./worker-entry.ts', moduleUrl) + if (moduleUrl.endsWith('.mjs')) return new URL('./people-relationships-worker.mjs', moduleUrl) + + const siblingWorkerEntry = new URL('./worker-entry.js', moduleUrl) + return entryExists(siblingWorkerEntry) ? siblingWorkerEntry : new URL('./people-relationships-worker.js', moduleUrl) +} + +function normalizeWorkerEntryUrl(entryUrl?: string | URL): URL { + if (!entryUrl) return resolveDefaultPeopleRelationshipsWorkerEntryUrl() + return typeof entryUrl === 'string' ? new URL(entryUrl) : entryUrl +} + +function createWorker(workerData: unknown, entryUrlInput?: string | URL): Worker { + const entryUrl = normalizeWorkerEntryUrl(entryUrlInput) + if (!entryUrl.href.endsWith('.ts')) return new Worker(entryUrl, { workerData }) + + const bootstrap = ` + import { register } from 'tsx/esm/api'; + register(); + await import(${JSON.stringify(entryUrl.href)}); + ` + const options: ModuleWorkerOptions = { + eval: true, + type: 'module', + workerData, + execArgv: [], + } + return new Worker(bootstrap, options) +} + +export function createPeopleRelationshipsWorkerRunner( + options: PeopleRelationshipsWorkerRunnerOptions +): PeopleRelationshipsComputeRunner { + return ({ signature, timeRangePreset, signal, onProgress }) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(createAbortError()) + return + } + + const worker = createWorker( + { + paths: snapshotPathProvider(options.pathProvider), + runtimeIdentity: options.runtimeIdentity, + nativeBinding: options.nativeBinding, + signature, + timeRangePreset, + }, + options.workerEntryUrl + ) + let settled = false + const abort = () => { + if (settled) return + settled = true + void worker.terminate() + reject(createAbortError()) + } + signal.addEventListener('abort', abort, { once: true }) + + worker.on('message', (message: PeopleRelationshipsWorkerMessage) => { + if (message.type === 'progress' && message.progress) { + onProgress(message.progress) + return + } + if (message.type === 'success' && message.snapshot) { + settled = true + signal.removeEventListener('abort', abort) + resolve(message.snapshot) + void worker.terminate() + return + } + if (message.type === 'error') { + settled = true + signal.removeEventListener('abort', abort) + reject(new Error(message.error ?? 'people relationships worker failed')) + void worker.terminate() + } + }) + worker.on('error', (error) => { + if (settled) return + settled = true + signal.removeEventListener('abort', abort) + reject(error) + }) + worker.on('exit', (code) => { + if (settled || code === 0) return + settled = true + signal.removeEventListener('abort', abort) + reject(new Error(`people relationships worker exited with code ${code}`)) + }) + }) +} + +function createAbortError(): Error { + return new Error('people relationships worker aborted') +} diff --git a/packages/node-runtime/src/services/push-importer.test.ts b/packages/node-runtime/src/services/push-importer.test.ts new file mode 100644 index 000000000..fe03ca2a9 --- /dev/null +++ b/packages/node-runtime/src/services/push-importer.test.ts @@ -0,0 +1,511 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { DatabaseManager } from '../database-manager' +import type { PushImportPayload } from './push-importer' +import { pushImport } from './push-importer' + +const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node') + +function makeTempDir(): string { + const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir() + return fs.mkdtempSync(path.join(baseDir, 'chatlab-push-import-')) +} + +function createDatabaseManager(rootDir: string): DatabaseManager { + return new DatabaseManager( + { + getSystemDir: () => rootDir, + getUserDataDir: () => rootDir, + getDatabaseDir: () => path.join(rootDir, 'databases'), + getVectorDir: () => path.join(rootDir, 'vector'), + getAiDataDir: () => path.join(rootDir, 'ai'), + getSettingsDir: () => path.join(rootDir, 'settings'), + getCacheDir: () => path.join(rootDir, 'cache'), + getTempDir: () => path.join(rootDir, 'temp'), + getLogsDir: () => path.join(rootDir, 'logs'), + getDownloadsDir: () => rootDir, + }, + { nativeBinding, allowMissingRuntimeForTests: true } + ) +} + +test('deduplicates initial push batch when a platform-id message is repeated without platform id', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const manager = createDatabaseManager(tempDir) + const payload: PushImportPayload = { + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'Push Import Session', platform: 'wechat', type: 'private' }, + members: [{ platformId: 'wxid_alice', accountName: 'Alice' }], + messages: [ + { + platformMessageId: 'msg-1', + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'same content', + }, + { + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'same content', + }, + ], + } + + const outcome = await pushImport(manager, 'initial-dedup', payload) + assert.equal(outcome.ok, true) + if (!outcome.ok) return + assert.deepEqual(outcome.result.batch, { + receivedCount: 2, + writtenCount: 1, + duplicateCount: 1, + }) + + const db = manager.openRawSessionDatabase('initial-dedup', { readonly: true }) + try { + const row = db.prepare('SELECT COUNT(*) as count FROM message').get() as { count: number } + assert.equal(row.count, 1) + } finally { + db.close() + } +}) + +test('deduplicates initial push batch when a no-platform-id message is repeated with platform id', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const manager = createDatabaseManager(tempDir) + const payload: PushImportPayload = { + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'Push Import Session', platform: 'wechat', type: 'private' }, + members: [{ platformId: 'wxid_alice', accountName: 'Alice' }], + messages: [ + { + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'same content', + }, + { + platformMessageId: 'msg-1', + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'same content', + }, + ], + } + + const outcome = await pushImport(manager, 'initial-reverse-dedup', payload) + assert.equal(outcome.ok, true) + if (!outcome.ok) return + assert.deepEqual(outcome.result.batch, { + receivedCount: 2, + writtenCount: 1, + duplicateCount: 1, + }) + + const db = manager.openRawSessionDatabase('initial-reverse-dedup', { readonly: true }) + try { + const row = db.prepare('SELECT COUNT(*) as count FROM message').get() as { count: number } + assert.equal(row.count, 1) + } finally { + db.close() + } +}) + +test('deduplicates incremental push when a later platform-id message matches an existing content hash', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const manager = createDatabaseManager(tempDir) + const initialPayload: PushImportPayload = { + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'Push Import Session', platform: 'wechat', type: 'private' }, + members: [{ platformId: 'wxid_alice', accountName: 'Alice' }], + messages: [ + { + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'same content', + }, + ], + } + + const initialOutcome = await pushImport(manager, 'incremental-pmid-hash-dedup', initialPayload) + assert.equal(initialOutcome.ok, true) + + const duplicatePayload: PushImportPayload = { + messages: [ + { + platformMessageId: 'msg-1', + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'same content', + }, + ], + } + + const outcome = await pushImport(manager, 'incremental-pmid-hash-dedup', duplicatePayload) + assert.equal(outcome.ok, true) + if (!outcome.ok) return + assert.deepEqual(outcome.result.batch, { + receivedCount: 1, + writtenCount: 0, + duplicateCount: 1, + }) + + const db = manager.openRawSessionDatabase('incremental-pmid-hash-dedup', { readonly: true }) + try { + const row = db.prepare('SELECT COUNT(*) as count FROM message').get() as { count: number } + assert.equal(row.count, 1) + } finally { + db.close() + } +}) + +test('rejects non-array messages before applying incremental metadata updates', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const manager = createDatabaseManager(tempDir) + const initialPayload: PushImportPayload = { + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'Original Session', platform: 'wechat', type: 'private' }, + members: [{ platformId: 'wxid_alice', accountName: 'Alice' }], + messages: [ + { + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'hello', + }, + ], + } + + const initialOutcome = await pushImport(manager, 'invalid-messages', initialPayload) + assert.equal(initialOutcome.ok, true) + + const malformedPayload = { + meta: { name: 'Renamed Session', platform: 'wechat', type: 'private' }, + messages: {}, + } as unknown as PushImportPayload + + const outcome = await pushImport(manager, 'invalid-messages', malformedPayload) + assert.equal(outcome.ok, false) + if (outcome.ok) return + assert.equal(outcome.reason, 'invalid_payload') + + const db = manager.openRawSessionDatabase('invalid-messages', { readonly: true }) + try { + const row = db.prepare('SELECT name FROM meta').get() as { name: string } + assert.equal(row.name, 'Original Session') + } finally { + db.close() + } +}) + +test('rejects non-array members before applying incremental metadata updates', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const manager = createDatabaseManager(tempDir) + const initialPayload: PushImportPayload = { + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'Original Session', platform: 'wechat', type: 'private' }, + members: [{ platformId: 'wxid_alice', accountName: 'Alice' }], + messages: [ + { + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'hello', + }, + ], + } + + const initialOutcome = await pushImport(manager, 'invalid-members', initialPayload) + assert.equal(initialOutcome.ok, true) + + const malformedPayload = { + meta: { name: 'Renamed Session', platform: 'wechat', type: 'private' }, + members: {}, + messages: [ + { + sender: 'wxid_alice', + timestamp: 1780330833, + type: 0, + content: 'still valid', + }, + ], + } as unknown as PushImportPayload + + const outcome = await pushImport(manager, 'invalid-members', malformedPayload) + assert.equal(outcome.ok, false) + if (outcome.ok) return + assert.equal(outcome.reason, 'invalid_payload') + + const db = manager.openRawSessionDatabase('invalid-members', { readonly: true }) + try { + const row = db.prepare('SELECT name FROM meta').get() as { name: string } + assert.equal(row.name, 'Original Session') + } finally { + db.close() + } +}) + +test('rejects non-string member platform IDs before applying incremental metadata updates', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const manager = createDatabaseManager(tempDir) + const initialPayload: PushImportPayload = { + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'Original Session', platform: 'wechat', type: 'private' }, + members: [{ platformId: 'wxid_alice', accountName: 'Alice' }], + messages: [ + { + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'hello', + }, + ], + } + + const initialOutcome = await pushImport(manager, 'invalid-member-platform-id', initialPayload) + assert.equal(initialOutcome.ok, true) + + const malformedPayload = { + meta: { name: 'Renamed Session', platform: 'wechat', type: 'private' }, + members: [{ platformId: { id: 'wxid_alice' }, accountName: 'Alice' }], + messages: [ + { + sender: 'wxid_alice', + timestamp: 1780330833, + type: 0, + content: 'still valid', + }, + ], + } as unknown as PushImportPayload + + const outcome = await pushImport(manager, 'invalid-member-platform-id', malformedPayload) + assert.equal(outcome.ok, false) + if (outcome.ok) return + assert.equal(outcome.reason, 'invalid_payload') + + const db = manager.openRawSessionDatabase('invalid-member-platform-id', { readonly: true }) + try { + const row = db.prepare('SELECT name FROM meta').get() as { name: string } + assert.equal(row.name, 'Original Session') + } finally { + db.close() + } +}) + +test('rejects non-string message senders before applying incremental metadata updates', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const manager = createDatabaseManager(tempDir) + const initialPayload: PushImportPayload = { + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'Original Session', platform: 'wechat', type: 'private' }, + members: [{ platformId: 'wxid_alice', accountName: 'Alice' }], + messages: [ + { + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'hello', + }, + ], + } + + const initialOutcome = await pushImport(manager, 'invalid-sender', initialPayload) + assert.equal(initialOutcome.ok, true) + + const malformedPayload = { + meta: { name: 'Renamed Session', platform: 'wechat', type: 'private' }, + messages: [ + { + sender: { id: 'wxid_alice' }, + timestamp: 1780330833, + type: 0, + content: 'still valid', + }, + ], + } as unknown as PushImportPayload + + const outcome = await pushImport(manager, 'invalid-sender', malformedPayload) + assert.equal(outcome.ok, false) + if (outcome.ok) return + assert.equal(outcome.reason, 'invalid_payload') + + const db = manager.openRawSessionDatabase('invalid-sender', { readonly: true }) + try { + const row = db.prepare('SELECT name FROM meta').get() as { name: string } + assert.equal(row.name, 'Original Session') + } finally { + db.close() + } +}) + +test('rejects non-string reply targets before applying incremental metadata updates', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const manager = createDatabaseManager(tempDir) + const initialPayload: PushImportPayload = { + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'Original Session', platform: 'wechat', type: 'private' }, + members: [{ platformId: 'wxid_alice', accountName: 'Alice' }], + messages: [ + { + sender: 'wxid_alice', + accountName: 'Alice', + timestamp: 1780330832, + type: 0, + content: 'hello', + }, + ], + } + + const initialOutcome = await pushImport(manager, 'invalid-reply-target', initialPayload) + assert.equal(initialOutcome.ok, true) + + const malformedPayload = { + meta: { name: 'Renamed Session', platform: 'wechat', type: 'private' }, + messages: [ + { + sender: 'wxid_alice', + timestamp: 1780330833, + type: 0, + content: 'still valid', + replyToMessageId: { id: 'msg-1' }, + }, + ], + } as unknown as PushImportPayload + + const outcome = await pushImport(manager, 'invalid-reply-target', malformedPayload) + assert.equal(outcome.ok, false) + if (outcome.ok) return + assert.equal(outcome.reason, 'invalid_payload') + + const db = manager.openRawSessionDatabase('invalid-reply-target', { readonly: true }) + try { + const row = db.prepare('SELECT name FROM meta').get() as { name: string } + assert.equal(row.name, 'Original Session') + } finally { + db.close() + } +}) + +test('treats auto-created SYSTEM sender as a system member during initial import', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const manager = createDatabaseManager(tempDir) + const payload: PushImportPayload = { + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'System Session', platform: 'wechat', type: 'group' }, + messages: [ + { + sender: 'SYSTEM', + timestamp: 1780330832, + type: 80, + content: 'Alice joined the group', + }, + ], + } + + const outcome = await pushImport(manager, 'system-sender', payload) + assert.equal(outcome.ok, true) + if (!outcome.ok) return + assert.equal(outcome.result.session.memberCount, 0) + + const db = manager.openRawSessionDatabase('system-sender', { readonly: true }) + try { + const member = db.prepare('SELECT platform_id, account_name FROM member').get() as { + platform_id: string + account_name: string + } + assert.deepEqual(member, { + platform_id: 'SYSTEM', + account_name: '系统消息', + }) + } finally { + db.close() + } +}) + +test('keeps SYSTEM member canonical during incremental member upserts', async (t) => { + const tempDir = makeTempDir() + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })) + + const manager = createDatabaseManager(tempDir) + const initialPayload: PushImportPayload = { + chatlab: { version: '0.0.2', exportedAt: 1780330900 }, + meta: { name: 'System Session', platform: 'wechat', type: 'group' }, + messages: [ + { + sender: 'SYSTEM', + timestamp: 1780330832, + type: 80, + content: 'Alice joined the group', + }, + ], + } + + const initialOutcome = await pushImport(manager, 'system-member-upsert', initialPayload) + assert.equal(initialOutcome.ok, true) + + const incrementalPayload: PushImportPayload = { + members: [{ platformId: 'SYSTEM', accountName: 'Bot' }], + messages: [ + { + sender: 'SYSTEM', + timestamp: 1780330833, + type: 80, + content: 'Bob joined the group', + }, + ], + } + + const outcome = await pushImport(manager, 'system-member-upsert', incrementalPayload) + assert.equal(outcome.ok, true) + if (!outcome.ok) return + assert.equal(outcome.result.session.memberCount, 0) + + const db = manager.openRawSessionDatabase('system-member-upsert', { readonly: true }) + try { + const member = db.prepare('SELECT platform_id, account_name FROM member').get() as { + platform_id: string + account_name: string + } + assert.deepEqual(member, { + platform_id: 'SYSTEM', + account_name: '系统消息', + }) + } finally { + db.close() + } +}) diff --git a/packages/node-runtime/src/services/push-importer.ts b/packages/node-runtime/src/services/push-importer.ts new file mode 100644 index 000000000..c775ef359 --- /dev/null +++ b/packages/node-runtime/src/services/push-importer.ts @@ -0,0 +1,518 @@ +/** + * Push import service — handles POST /api/v1/imports/:sessionId + * + * Accepts a ChatLab Format JSON payload, creates or appends to a session. + * Dedup: platformMessageId (preferred) or content hash (fallback). + */ + +import * as fs from 'fs' +import { DataDirCompatibilityError } from '../data-dir-compat' +import { + CHAT_DB_SCHEMA, + generateMessageKey, + generateSessionIndex, + generateIncrementalSessionIndex, +} from '@openchatlab/core' +import type { DatabaseAdapter } from '@openchatlab/core' +import type { DatabaseManager } from '../database-manager' +import { buildFtsIndex, insertFtsEntries } from '../fts' +import { writeParseResultToDb } from '../import' + +// Per-session lock: concurrent imports to different sessions are fine (separate DB files). +const importInProgress = new Set() +const SYSTEM_SENDER_ID = 'SYSTEM' +const SYSTEM_MEMBER_NAME = '系统消息' + +export interface PushImportMessage { + sender: string + timestamp: number + type: number + accountName?: string + groupNickname?: string + content?: string | null + platformMessageId?: string + replyToMessageId?: string +} + +export interface PushImportMember { + platformId: string + accountName?: string + groupNickname?: string + avatar?: string + roles?: Array<{ id: string }> +} + +export interface PushImportMeta { + name: string + platform: string + type: string + groupId?: string + groupAvatar?: string + ownerId?: string +} + +export interface PushImportPayload { + chatlab?: { version: string; exportedAt: number; generator?: string } + meta?: PushImportMeta + members?: PushImportMember[] + messages?: PushImportMessage[] + options?: { + metaUpdateMode?: 'patch' | 'none' + memberUpdateMode?: 'upsert' | 'none' + } +} + +export interface PushImportResult { + sessionId: string + created: boolean + batch: { receivedCount: number; writtenCount: number; duplicateCount: number } + session: { totalCount: number; memberCount: number; firstTimestamp: number | null; lastTimestamp: number | null } + updates: { metaUpdated: boolean; membersAdded: number; membersUpdated: number } +} + +export type PushImportOutcome = + | { ok: true; result: PushImportResult } + | { ok: false; reason: 'import_in_progress' | 'invalid_payload' | 'import_failed'; message: string } + +function validatePayload(payload: PushImportPayload, isNew: boolean): string | null { + const messages = payload.messages + if (!Array.isArray(messages) || messages.length === 0) return 'messages is required and must be a non-empty array' + + if (isNew) { + if (!payload.chatlab) return 'chatlab is required for new sessions' + if (!payload.meta) return 'meta is required for new sessions' + const m = payload.meta + if (!m.name) return 'meta.name is required' + if (!m.platform) return 'meta.platform is required' + if (!m.type) return 'meta.type is required' + } + + const { metaUpdateMode, memberUpdateMode } = payload.options ?? {} + if (metaUpdateMode !== undefined && metaUpdateMode !== 'patch' && metaUpdateMode !== 'none') { + return `options.metaUpdateMode must be 'patch' or 'none'` + } + if (memberUpdateMode !== undefined && memberUpdateMode !== 'upsert' && memberUpdateMode !== 'none') { + return `options.memberUpdateMode must be 'upsert' or 'none'` + } + + if (payload.members !== undefined && !Array.isArray(payload.members)) return 'members must be an array' + if (payload.members) { + for (let i = 0; i < payload.members.length; i++) { + if (typeof payload.members[i].platformId !== 'string' || payload.members[i].platformId.length === 0) + return `members[${i}].platformId must be a string` + } + } + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + if (typeof msg.sender !== 'string' || msg.sender.length === 0) return `messages[${i}].sender must be a string` + if (typeof msg.timestamp !== 'number' || msg.timestamp <= 0) + return `messages[${i}].timestamp must be a positive number` + if (typeof msg.type !== 'number') return `messages[${i}].type must be a number` + if (msg.content !== undefined && msg.content !== null && typeof msg.content !== 'string') + return `messages[${i}].content must be a string or null` + if (msg.platformMessageId !== undefined && typeof msg.platformMessageId !== 'string') + return `messages[${i}].platformMessageId must be a string` + if (msg.replyToMessageId !== undefined && typeof msg.replyToMessageId !== 'string') + return `messages[${i}].replyToMessageId must be a string` + } + + return null +} + +function queryStats(db: DatabaseAdapter): PushImportResult['session'] { + const row = db.prepare('SELECT COUNT(*) as total, MIN(ts) as first, MAX(ts) as last FROM message').get() as { + total: number + first: number | null + last: number | null + } + const memberRow = db + .prepare(`SELECT COUNT(*) as cnt FROM member WHERE COALESCE(account_name, '') != ?`) + .get(SYSTEM_MEMBER_NAME) as { cnt: number } + return { totalCount: row.total, memberCount: memberRow.cnt, firstTimestamp: row.first, lastTimestamp: row.last } +} + +function normalizeSenderAccountName(sender: string, accountName: string | undefined): string | undefined { + return sender === SYSTEM_SENDER_ID ? SYSTEM_MEMBER_NAME : accountName +} + +function normalizeSenderGroupNickname(sender: string, groupNickname: string | undefined): string | undefined { + return sender === SYSTEM_SENDER_ID ? SYSTEM_MEMBER_NAME : groupNickname +} + +function writeMessages( + db: DatabaseAdapter, + messages: PushImportMessage[], + existingPmids: Set, + existingKeys: Set +): { + writtenCount: number + duplicateCount: number + minWrittenTs: number + ftsEntries: Array<{ id: number; content: string | null }> +} { + const insertMsg = db.prepare( + `INSERT INTO message (sender_id, sender_account_name, sender_group_nickname, ts, type, content, reply_to_message_id, platform_message_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ) + const getMemberId = db.prepare('SELECT id FROM member WHERE platform_id = ?') + const insertMinimalMember = db.prepare('INSERT OR IGNORE INTO member (platform_id, account_name) VALUES (?, ?)') + + const memberIdCache = new Map() + let writtenCount = 0 + let duplicateCount = 0 + let minWrittenTs = Infinity + const ftsEntries: Array<{ id: number; content: string | null }> = [] + + const sorted = [...messages].sort((a, b) => a.timestamp - b.timestamp) + + db.transaction(() => { + for (const msg of sorted) { + const key = generateMessageKey(msg.timestamp, msg.sender, msg.content ?? null) + if (msg.platformMessageId) { + if (existingPmids.has(msg.platformMessageId)) { + duplicateCount++ + continue + } + if (existingKeys.has(key)) { + duplicateCount++ + continue + } + existingPmids.add(msg.platformMessageId) + // Also register the content hash so a same-content no-pmid copy later + // in this batch is caught by the fallback dedup path. + existingKeys.add(key) + } else { + if (existingKeys.has(key)) { + duplicateCount++ + continue + } + existingKeys.add(key) + } + + let memberId = memberIdCache.get(msg.sender) + if (!memberId) { + insertMinimalMember.run(msg.sender, normalizeSenderAccountName(msg.sender, msg.accountName) || null) + const row = getMemberId.get(msg.sender) as { id: number } | undefined + if (row) { + memberId = row.id + memberIdCache.set(msg.sender, memberId) + } + } + if (!memberId) continue + + const result = insertMsg.run( + memberId, + normalizeSenderAccountName(msg.sender, msg.accountName) || null, + normalizeSenderGroupNickname(msg.sender, msg.groupNickname) || null, + msg.timestamp, + msg.type, + msg.content ?? null, + msg.replyToMessageId || null, + msg.platformMessageId || null + ) + ftsEntries.push({ id: Number(result.lastInsertRowid), content: msg.content ?? null }) + if (msg.timestamp < minWrittenTs) minWrittenTs = msg.timestamp + writtenCount++ + } + }) + + return { writtenCount, duplicateCount, minWrittenTs, ftsEntries } +} + +function fullImport( + db: DatabaseAdapter, + meta: PushImportMeta, + members: PushImportMember[], + messages: PushImportMessage[] +): { writtenCount: number; duplicateCount: number; membersAdded: number } { + db.exec(CHAT_DB_SCHEMA) + + // Auto-create minimal member entries for senders not listed in members, + // preserving the protocol promise that unknown senders are auto-created. + const knownIds = new Set(members.map((m) => m.platformId)) + const extraMembers: PushImportMember[] = [] + for (const msg of messages) { + if (!knownIds.has(msg.sender)) { + extraMembers.push({ + platformId: msg.sender, + accountName: normalizeSenderAccountName(msg.sender, msg.accountName), + groupNickname: normalizeSenderGroupNickname(msg.sender, msg.groupNickname), + }) + knownIds.add(msg.sender) + } + } + const allMembers = extraMembers.length > 0 ? [...members, ...extraMembers] : members + + // Deduplicate within the batch using the same pmid/hash logic as incremental imports. + const pmids = new Set() + const hashKeys = new Set() + let duplicateCount = 0 + const dedupedMessages: PushImportMessage[] = [] + for (const msg of messages) { + const key = generateMessageKey(msg.timestamp, msg.sender, msg.content ?? null) + if (msg.platformMessageId) { + if (pmids.has(msg.platformMessageId)) { + duplicateCount++ + continue + } + if (hashKeys.has(key)) { + duplicateCount++ + continue + } + pmids.add(msg.platformMessageId) + hashKeys.add(key) + } else { + if (hashKeys.has(key)) { + duplicateCount++ + continue + } + hashKeys.add(key) + } + dedupedMessages.push(msg) + } + + // Delegate to the shared writer so member_name_history is tracked correctly, + // matching the behaviour of file-based full imports. + const stats = writeParseResultToDb( + db, + meta, + allMembers.map((m) => ({ + ...m, + accountName: normalizeSenderAccountName(m.platformId, m.accountName) ?? m.platformId, + groupNickname: normalizeSenderGroupNickname(m.platformId, m.groupNickname), + })), + dedupedMessages.map((m) => ({ + senderPlatformId: m.sender, + senderAccountName: normalizeSenderAccountName(m.sender, m.accountName) ?? m.sender, + senderGroupNickname: normalizeSenderGroupNickname(m.sender, m.groupNickname), + timestamp: m.timestamp, + type: m.type, + content: m.content ?? null, + platformMessageId: m.platformMessageId, + replyToMessageId: m.replyToMessageId, + })) + ) + + buildFtsIndex(db) + + try { + generateSessionIndex(db) + } catch { + /* non-fatal */ + } + + return { writtenCount: stats.messageCount, duplicateCount, membersAdded: members.length } +} + +function incrementalImport( + db: DatabaseAdapter, + payload: PushImportPayload +): { + writtenCount: number + duplicateCount: number + metaUpdated: boolean + membersAdded: number + membersUpdated: number +} { + const metaUpdateMode = payload.options?.metaUpdateMode ?? 'patch' + const memberUpdateMode = payload.options?.memberUpdateMode ?? 'upsert' + + // Load dedup keys + const existingPmids = new Set() + const existingKeys = new Set() + ;( + db.prepare('SELECT platform_message_id FROM message WHERE platform_message_id IS NOT NULL').all() as Array<{ + platform_message_id: string + }> + ).forEach((r) => existingPmids.add(r.platform_message_id)) + // Load hashes for ALL existing messages (not just those without pmid) so that + // messages previously imported with a platformMessageId are still caught by + // content-hash dedup when the same content arrives without one. + ;( + db + .prepare(`SELECT msg.ts, m.platform_id, msg.content FROM message msg JOIN member m ON msg.sender_id = m.id`) + .all() as Array<{ ts: number; platform_id: string; content: string | null }> + ).forEach((r) => existingKeys.add(generateMessageKey(r.ts, r.platform_id, r.content))) + + let metaUpdated = false + let membersAdded = 0 + let membersUpdated = 0 + + if (payload.meta && metaUpdateMode === 'patch') { + const m = payload.meta + db.prepare( + `UPDATE meta SET name = COALESCE(NULLIF(?, ''), name), group_id = COALESCE(NULLIF(?, ''), group_id), group_avatar = COALESCE(NULLIF(?, ''), group_avatar), owner_id = COALESCE(NULLIF(?, ''), owner_id), imported_at = ?` + ).run(m.name || '', m.groupId || '', m.groupAvatar || '', m.ownerId || '', Math.floor(Date.now() / 1000)) + metaUpdated = true + } + + if (payload.members && memberUpdateMode === 'upsert') { + const upsertMember = db.prepare( + `INSERT INTO member (platform_id, account_name, group_nickname, avatar, roles) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(platform_id) DO UPDATE SET + account_name = COALESCE(NULLIF(excluded.account_name, ''), account_name), + group_nickname = COALESCE(NULLIF(excluded.group_nickname, ''), group_nickname), + avatar = COALESCE(NULLIF(excluded.avatar, ''), avatar), + roles = CASE WHEN excluded.roles != '[]' THEN excluded.roles ELSE roles END` + ) + const getMemberId = db.prepare('SELECT id FROM member WHERE platform_id = ?') + const existingMemberIds = new Set( + (db.prepare('SELECT platform_id FROM member').all() as Array<{ platform_id: string }>).map((r) => r.platform_id) + ) + + db.transaction(() => { + for (const m of payload.members!) { + const existed = existingMemberIds.has(m.platformId) + const accountName = normalizeSenderAccountName(m.platformId, m.accountName) + const groupNickname = normalizeSenderGroupNickname(m.platformId, m.groupNickname) + upsertMember.run( + m.platformId, + accountName || null, + groupNickname || null, + m.avatar || null, + m.roles ? JSON.stringify(m.roles) : '[]' + ) + if (!existed) { + membersAdded++ + const row = getMemberId.get(m.platformId) as { id: number } | undefined + if (row) existingMemberIds.add(m.platformId) + } else { + membersUpdated++ + } + } + }) + } + + // Capture existing max timestamp before writing to detect backfill batches. + const preWriteMaxTs = + (db.prepare('SELECT MAX(ts) as max_ts FROM message').get() as { max_ts: number | null })?.max_ts ?? 0 + + const { writtenCount, duplicateCount, minWrittenTs, ftsEntries } = writeMessages( + db, + payload.messages!, + existingPmids, + existingKeys + ) + + if (ftsEntries.length > 0) { + try { + insertFtsEntries(db, ftsEntries) + } catch { + /* non-fatal */ + } + } + + if (writtenCount > 0) { + try { + // Use minWrittenTs (not payload min) to avoid false-positive backfill detection + // when overlap duplicates in the batch have older timestamps than written rows. + if (minWrittenTs < preWriteMaxTs) { + generateSessionIndex(db) + } else { + generateIncrementalSessionIndex(db) + } + } catch { + /* non-fatal */ + } + } + + if (!metaUpdated) { + db.prepare('UPDATE meta SET imported_at = ?').run(Math.floor(Date.now() / 1000)) + } + + return { writtenCount, duplicateCount, metaUpdated, membersAdded, membersUpdated } +} + +// Only allow characters that are safe as a bare filename component. +// Rejects path separators (/ \), dots-only sequences (..), and control chars. +const SAFE_SESSION_ID_RE = /^[a-zA-Z0-9_@-][a-zA-Z0-9_@.-]*$/ + +export async function pushImport( + dbManager: DatabaseManager, + sessionId: string, + payload: PushImportPayload +): Promise { + if (!SAFE_SESSION_ID_RE.test(sessionId) || sessionId.includes('..')) { + return { ok: false, reason: 'invalid_payload', message: 'sessionId contains invalid characters' } + } + + if (importInProgress.has(sessionId)) { + return { + ok: false, + reason: 'import_in_progress', + message: 'Another import is already in progress for this session', + } + } + + const dbPath = dbManager.getDbPath(sessionId) + const isNew = !fs.existsSync(dbPath) + + const validationError = validatePayload(payload, isNew) + if (validationError) { + return { ok: false, reason: 'invalid_payload', message: validationError } + } + + importInProgress.add(sessionId) + try { + if (isNew) { + const db = dbManager.openRawSessionDatabase(sessionId, { create: true }) + try { + const { writtenCount, duplicateCount, membersAdded } = fullImport( + db, + payload.meta!, + payload.members ?? [], + payload.messages! + ) + const session = queryStats(db) + dbManager.raiseCurrentChatDbCompatibilityGate() + return { + ok: true, + result: { + sessionId, + created: true, + batch: { receivedCount: payload.messages!.length, writtenCount, duplicateCount }, + session, + updates: { metaUpdated: true, membersAdded, membersUpdated: 0 }, + }, + } + } finally { + db.close() + } + } + + const db = dbManager.openRawSessionDatabase(sessionId, { readonly: false }) + try { + const { writtenCount, duplicateCount, metaUpdated, membersAdded, membersUpdated } = incrementalImport(db, payload) + const session = queryStats(db) + dbManager.raiseCurrentChatDbCompatibilityGate() + return { + ok: true, + result: { + sessionId, + created: false, + batch: { receivedCount: payload.messages!.length, writtenCount, duplicateCount }, + session, + updates: { metaUpdated, membersAdded, membersUpdated }, + }, + } + } finally { + db.close() + } + } catch (err: unknown) { + // Let DataDirCompatibilityError propagate so the Fastify error handler + // maps it to 409 DATA_DIR_INCOMPATIBLE (consistent with other routes). + if (err instanceof DataDirCompatibilityError) throw err + + if (isNew) { + try { + dbManager.deleteSessionDatabaseFiles(sessionId) + } catch { + /* cleanup best-effort */ + } + } + return { ok: false, reason: 'import_failed', message: err instanceof Error ? err.message : String(err) } + } finally { + importInProgress.delete(sessionId) + } +} diff --git a/packages/node-runtime/src/services/session-index-service.ts b/packages/node-runtime/src/services/session-index-service.ts new file mode 100644 index 000000000..6b91b523f --- /dev/null +++ b/packages/node-runtime/src/services/session-index-service.ts @@ -0,0 +1,73 @@ +/** + * Shared session index service. + * + * Wraps core's session index operations with proper writable DB handling. + */ + +import { + generateSessionIndex as coreGenerateSessionIndex, + generateIncrementalSessionIndex as coreGenerateIncrementalSessionIndex, + clearSessionIndex as coreClearSessionIndex, + getSessionIndexStats, +} from '@openchatlab/core' +import { hasFtsTable, searchByFts, rebuildFtsIndex } from '../fts' +import type { SessionRuntimeAdapter } from './adapters' + +export function generateIndex(adapter: SessionRuntimeAdapter, sessionId: string, gapThreshold: number = 1800): number { + const db = adapter.ensureWritable(sessionId) + return coreGenerateSessionIndex(db, gapThreshold) +} + +export function generateIncrementalIndex( + adapter: SessionRuntimeAdapter, + sessionId: string, + gapThreshold: number = 1800 +): number { + const db = adapter.ensureWritable(sessionId) + return coreGenerateIncrementalSessionIndex(db, gapThreshold) +} + +export function clearIndex(adapter: SessionRuntimeAdapter, sessionId: string): void { + const db = adapter.ensureWritable(sessionId) + coreClearSessionIndex(db) +} + +export function getFtsStatus(adapter: SessionRuntimeAdapter, sessionId: string): boolean { + const db = adapter.ensureReadonly(sessionId) + return hasFtsTable(db) +} + +export function searchFts( + adapter: SessionRuntimeAdapter, + sessionId: string, + keywords: string[], + limit: number, + offset: number +) { + const db = adapter.ensureReadonly(sessionId) + return searchByFts(db, keywords, limit, offset) +} + +export function rebuildFts(adapter: SessionRuntimeAdapter, sessionId: string) { + const db = adapter.ensureWritable(sessionId) + return rebuildFtsIndex(db) +} + +export interface SessionIndexStatusItem { + sessionId: string + hasIndex: boolean + sessionCount: number +} + +export function getAllIndexStats(adapter: SessionRuntimeAdapter): SessionIndexStatusItem[] { + const ids = adapter.listSessionIds() + return ids.map((sessionId) => { + try { + const db = adapter.ensureReadonly(sessionId) + const stats = getSessionIndexStats(db) + return { sessionId, hasIndex: stats.hasIndex, sessionCount: stats.sessionCount } + } catch { + return { sessionId, hasIndex: false, sessionCount: 0 } + } + }) +} diff --git a/packages/node-runtime/src/services/session-service.ts b/packages/node-runtime/src/services/session-service.ts new file mode 100644 index 000000000..2647237e8 --- /dev/null +++ b/packages/node-runtime/src/services/session-service.ts @@ -0,0 +1,134 @@ +/** + * Shared session service layer. + * + * Provides unified session CRUD used by both CLI Web routes and Electron IPC/API. + * All business logic (sorting, data shape, validation) lives here — + * callers only handle protocol-specific concerns (HTTP params, IPC channels). + */ + +import { + getSessionInfo, + getSessionMeta, + getSummaryCount, + getPrivateChatMemberAvatar, + isChatSessionDb, + buildSessionInfo, + renameSession as coreRenameSession, + updateSessionOwnerId as coreUpdateSessionOwnerId, +} from '@openchatlab/core' +import type { CoreSessionInfo, DatabaseAdapter, SessionOverview } from '@openchatlab/core' +import type { SessionRuntimeAdapter } from './adapters' + +export interface AnalysisSessionDTO extends CoreSessionInfo { + id: string + dbPath: string + memberAvatar: string | null + aiConversationCount: number +} + +/** + * Optional hooks for platform-specific behavior. + * Electron can provide a cached overview resolver and post-list enrichment; + * CLI Web can omit these to use default (no-cache) behavior. + */ +export interface ListSessionsOptions { + /** + * Resolve overview stats for a session, optionally from cache. + * When provided, used instead of the default `getSessionOverview` SQL query. + * Electron worker uses this to read from JSON file cache (resolveOverview). + */ + resolveOverview?(db: DatabaseAdapter, sessionId: string): SessionOverview + + /** + * Enrich each session DTO after construction. + * Electron uses this to fill aiConversationCount, etc. + */ + enrichSession?(dto: AnalysisSessionDTO): AnalysisSessionDTO +} + +function buildSession( + db: DatabaseAdapter, + id: string, + dbPath: string, + options?: ListSessionsOptions +): AnalysisSessionDTO | null { + const meta = getSessionMeta(db) + if (!meta) return null + + const overview = options?.resolveOverview ? options.resolveOverview(db, id) : undefined + const info = overview ? buildSessionInfo(meta, overview, getSummaryCount(db)) : getSessionInfo(db) + if (!info) return null + + let memberAvatar: string | null = null + if (meta.type === 'private') { + memberAvatar = getPrivateChatMemberAvatar(db, meta.name, meta.ownerId) + } + + let dto: AnalysisSessionDTO = { ...info, id, dbPath, memberAvatar, aiConversationCount: 0 } + if (options?.enrichSession) { + dto = options.enrichSession(dto) + } + return dto +} + +/** + * List all valid sessions, sorted by importedAt descending. + * + * Accepts optional hooks so Electron can plug in cached overview resolution + * and session enrichment without duplicating the listing logic. + */ +export function listAnalysisSessions( + adapter: SessionRuntimeAdapter, + options?: ListSessionsOptions +): AnalysisSessionDTO[] { + const sessionIds = adapter.listSessionIds() + const sessions: AnalysisSessionDTO[] = [] + + for (const id of sessionIds) { + const db = adapter.openReadonly(id) + if (!db) continue + if (!isChatSessionDb(db)) continue + + const dto = buildSession(db, id, adapter.getDbPath(id), options) + if (dto) sessions.push(dto) + } + + return sessions.sort((a, b) => b.importedAt - a.importedAt) +} + +/** + * Get a single session by ID. + */ +export function getAnalysisSession( + adapter: SessionRuntimeAdapter, + sessionId: string, + options?: ListSessionsOptions +): AnalysisSessionDTO | null { + const db = adapter.openReadonly(sessionId) + if (!db) return null + return buildSession(db, sessionId, adapter.getDbPath(sessionId), options) +} + +/** + * Rename a session (updates meta.name). + */ +export function renameSession(adapter: SessionRuntimeAdapter, sessionId: string, name: string): void { + const db = adapter.ensureWritable(sessionId) + coreRenameSession(db, name) +} + +/** + * Update session owner_id. + */ +export function updateSessionOwnerId(adapter: SessionRuntimeAdapter, sessionId: string, ownerId: string | null): void { + const db = adapter.ensureWritable(sessionId) + coreUpdateSessionOwnerId(db, ownerId) +} + +/** + * Delete a session (close DB + remove files/cache). + * Returns true if deleted, false if file not found. + */ +export function deleteSession(adapter: SessionRuntimeAdapter, sessionId: string): boolean { + return adapter.deleteSessionFile(sessionId) +} diff --git a/packages/node-runtime/src/services/summary-service.ts b/packages/node-runtime/src/services/summary-service.ts new file mode 100644 index 000000000..38abea9e2 --- /dev/null +++ b/packages/node-runtime/src/services/summary-service.ts @@ -0,0 +1,125 @@ +/** + * Shared summary service. + * + * Encapsulates LLM config loading, SummaryDeps construction, + * and summary generation loop — shared across CLI Web and Electron. + */ + +import { getSegmentSummary, saveSegmentSummary, getChatSessionList, getSegmentMessages } from '@openchatlab/core' +import type { DatabaseAdapter } from '@openchatlab/core' +import { + generateSessionSummary, + checkSessionsCanGenerateSummary, + type SummaryDeps, + type SummaryOptions, + completeSimple, + type PiTextContent, +} from '../ai' +import type { SessionRuntimeAdapter } from './adapters' + +export interface LlmConfig { + apiKey: string +} + +export interface SummaryServiceDeps { + getLlmConfig(): LlmConfig | null + buildPiModel(config: LlmConfig): ReturnType +} + +function buildSummaryDeps(db: DatabaseAdapter, llmConfig: LlmConfig, deps: SummaryServiceDeps): SummaryDeps { + const piModel = deps.buildPiModel(llmConfig) + return { + loadMessages(segmentId, limit = 500) { + const data = getSegmentMessages(db, segmentId, limit) + if (!data) return null + return data.messages.map((m) => ({ senderName: m.senderName, content: m.content })) + }, + saveSummary(segmentId, summary) { + saveSegmentSummary(db, segmentId, summary) + }, + getSummary(segmentId) { + return getSegmentSummary(db, segmentId) + }, + async llmComplete(systemPrompt, userPrompt, options) { + const result = await completeSimple( + piModel, + { + systemPrompt, + messages: [{ role: 'user', content: [{ type: 'text', text: userPrompt }], timestamp: Date.now() }] as any, + }, + { apiKey: llmConfig.apiKey, maxTokens: options?.maxTokens, temperature: options?.temperature } + ) + return result.content + .filter((item): item is PiTextContent => item.type === 'text') + .map((item) => item.text) + .join('') + }, + t: (key: string) => key, + } +} + +export async function generateSummary( + adapter: SessionRuntimeAdapter, + sessionId: string, + segmentId: number, + serviceDeps: SummaryServiceDeps, + options?: SummaryOptions +) { + const llmConfig = serviceDeps.getLlmConfig() + if (!llmConfig) { + return { success: false as const, error: 'No LLM configuration available' } + } + + const db = adapter.ensureWritable(sessionId) + const deps = buildSummaryDeps(db, llmConfig, serviceDeps) + return generateSessionSummary(deps, segmentId, options) +} + +export async function generateAllSummaries( + adapter: SessionRuntimeAdapter, + sessionId: string, + serviceDeps: SummaryServiceDeps, + options?: SummaryOptions +) { + const llmConfig = serviceDeps.getLlmConfig() + if (!llmConfig) { + return { success: 0, failed: 0, total: 0, error: 'No LLM configuration available' } + } + + const db = adapter.ensureWritable(sessionId) + const chatSessions = getChatSessionList(db) + const deps = buildSummaryDeps(db, llmConfig, serviceDeps) + + let success = 0 + let failed = 0 + + for (const cs of chatSessions) { + const result = await generateSessionSummary(deps, cs.id, options) + if (result.success) success++ + else failed++ + } + + return { success, failed, total: chatSessions.length } +} + +export function checkCanGenerate( + adapter: SessionRuntimeAdapter, + sessionId: string, + segmentIds: number[] +): Record { + const db = adapter.ensureReadonly(sessionId) + const deps: Pick = { + loadMessages(segmentId, limit = 500) { + const data = getSegmentMessages(db, segmentId, limit) + if (!data) return null + return data.messages.map((m) => ({ senderName: m.senderName, content: m.content })) + }, + t: (key: string) => key, + } + const resultMap = checkSessionsCanGenerateSummary(deps, segmentIds) + const result: Record = {} + for (const [id, info] of resultMap) { + result[id] = info + } + return result +} diff --git a/packages/parser/package.json b/packages/parser/package.json new file mode 100644 index 000000000..8cf73896a --- /dev/null +++ b/packages/parser/package.json @@ -0,0 +1,16 @@ +{ + "name": "@openchatlab/parser", + "version": "0.0.0", + "private": true, + "description": "ChatLab 多格式聊天记录解析器(14+ 格式)", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "@openchatlab/shared-types": "workspace:*", + "stream-json": "^1.9.1" + }, + "devDependencies": { + "@types/stream-json": "^1.7.8" + } +} diff --git a/electron/main/parser/formats/chatlab-jsonl.ts b/packages/parser/src/formats/chatlab-jsonl.ts similarity index 92% rename from electron/main/parser/formats/chatlab-jsonl.ts rename to packages/parser/src/formats/chatlab-jsonl.ts index 04234b850..20c4c9b52 100644 --- a/electron/main/parser/formats/chatlab-jsonl.ts +++ b/packages/parser/src/formats/chatlab-jsonl.ts @@ -18,7 +18,7 @@ import * as fs from 'fs' import * as readline from 'readline' import * as path from 'path' -import { KNOWN_PLATFORMS, ChatType, MessageType, type ChatPlatform } from '../../../../src/types/base' +import { KNOWN_PLATFORMS, ChatType, MessageType } from '@openchatlab/shared-types' import type { FormatFeature, FormatModule, @@ -29,13 +29,10 @@ import type { ParsedMember, ParsedMessage, } from '../types' -import { getFileSize, createProgress, readFileHeadBytes } from '../utils' +import { getFileSize, createProgress } from '../utils' // ==================== JSONL 行类型定义 ==================== -/** JSONL 行类型 */ -type JsonlLineType = 'header' | 'member' | 'message' - /** Header 行结构 */ interface JsonlHeader { _type: 'header' @@ -114,7 +111,7 @@ export const feature: FormatFeature = { id: 'chatlab-jsonl', name: 'ChatLab JSONL', platform: KNOWN_PLATFORMS.UNKNOWN, - priority: 2, // 仅次于 ChatLab JSON + priority: 51, // 低优先级,让其他格式先匹配 extensions: ['.jsonl'], signatures: { // 第一行必须是 header 类型,包含 chatlab 信息 @@ -179,14 +176,16 @@ async function* parseChatLabJsonl(options: ParseOptions): AsyncGenerator() - let messageBatch: ParsedMessage[] = [] + const messageBatch: ParsedMessage[] = [] // 流式解析 await new Promise((resolve, reject) => { diff --git a/packages/parser/src/formats/fixture.test.ts b/packages/parser/src/formats/fixture.test.ts new file mode 100644 index 000000000..4df075b2c --- /dev/null +++ b/packages/parser/src/formats/fixture.test.ts @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, it } from 'node:test' +import { ChatType, KNOWN_PLATFORMS, MessageType } from '@openchatlab/shared-types' + +import { detectFormat, parseFileSync } from '../index' +import type { ParseResult } from '../types' + +interface ParserFixture { + filename: string + content: string + formatId: string + expected: { + meta: { + name: string + platform: string + type: ChatType + } + memberIds: string[] + messages: Array<{ + senderPlatformId: string + timestamp: number + type: MessageType + content: string | null + }> + } +} + +function localTs(isoLocal: string): number { + return Math.floor(new Date(isoLocal).getTime() / 1000) +} + +function json(value: unknown): string { + return JSON.stringify(value, null, 2) +} + +function jsonLine(value: unknown): string { + return JSON.stringify(value) +} + +async function parseFixture(fixture: ParserFixture): Promise { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-parser-fixture-')) + try { + const filePath = join(dir, fixture.filename) + writeFileSync(filePath, fixture.content, 'utf-8') + + assert.equal(detectFormat(filePath)?.id, fixture.formatId) + return await parseFileSync(filePath) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +const fixtures: ParserFixture[] = [ + { + filename: 'qq-group.txt', + content: [ + '消息记录(此消息记录为文本格式,不支持重新导入)', + '消息对象:测试 QQ 群', + '2024-01-02 03:04:05 Alice(10001)', + 'hello qq', + '2024-01-02 03:05:06 Bob(10002)', + '[图片]', + '', + ].join('\n'), + formatId: 'qq-native-txt', + expected: { + meta: { name: '测试 QQ 群', platform: KNOWN_PLATFORMS.QQ, type: ChatType.GROUP }, + memberIds: ['10001', '10002'], + messages: [ + { + senderPlatformId: '10001', + timestamp: localTs('2024-01-02T03:04:05'), + type: MessageType.TEXT, + content: 'hello qq', + }, + { + senderPlatformId: '10002', + timestamp: localTs('2024-01-02T03:05:06'), + type: MessageType.IMAGE, + content: '[图片]', + }, + ], + }, + }, + { + filename: 'weflow.json', + content: json({ + weflow: { version: '1.0.0', exportedAt: 1704164645 }, + session: { + wxid: 'room@chatroom', + nickname: '微信测试群', + remark: '', + displayName: '微信测试群', + type: '群聊', + lastTimestamp: 1704164706, + messageCount: 2, + }, + avatars: {}, + messages: [ + { + localId: 1, + createTime: 1704164645, + formattedTime: '2024-01-02 03:04:05', + type: '文本消息', + localType: 1, + content: 'hello wechat', + isSend: 0, + senderUsername: 'wxid_alice', + senderDisplayName: 'Alice', + senderAvatarKey: 'wxid_alice', + source: '', + }, + { + localId: 2, + createTime: 1704164706, + formattedTime: '2024-01-02 03:05:06', + type: '图片消息', + localType: 3, + content: '[图片]', + isSend: 1, + senderUsername: 'wxid_bob', + senderDisplayName: 'Bob', + senderAvatarKey: 'wxid_bob', + source: '', + }, + ], + }), + formatId: 'weflow', + expected: { + meta: { name: '微信测试群', platform: KNOWN_PLATFORMS.WECHAT, type: ChatType.GROUP }, + memberIds: ['wxid_alice', 'wxid_bob'], + messages: [ + { + senderPlatformId: 'wxid_alice', + timestamp: 1704164645, + type: MessageType.TEXT, + content: 'hello wechat', + }, + { + senderPlatformId: 'wxid_bob', + timestamp: 1704164706, + type: MessageType.IMAGE, + content: '[图片]', + }, + ], + }, + }, + { + filename: 'telegram.json', + content: json({ + name: 'Telegram Test Chat', + type: 'private_group', + id: 4242, + messages: [ + { + id: 1, + type: 'message', + date: '2024-01-02T03:04:05', + date_unixtime: '1704164645', + from: 'Alice', + from_id: 'user10001', + text: 'hello telegram', + text_entities: [{ type: 'plain', text: 'hello telegram' }], + }, + ], + }), + formatId: 'telegram-native-single', + expected: { + meta: { name: 'Telegram Test Chat', platform: KNOWN_PLATFORMS.TELEGRAM, type: ChatType.GROUP }, + memberIds: ['10001'], + messages: [ + { + senderPlatformId: '10001', + timestamp: 1704164645, + type: MessageType.TEXT, + content: 'hello telegram', + }, + ], + }, + }, + { + filename: '与Alice的 WhatsApp 聊天.txt', + content: [ + 'Messages and calls are end-to-end encrypted.', + '2024/01/02 03:04 - Alice: hello whatsapp', + '2024/01/02 03:05 - Bob: image omitted', + '', + ].join('\n'), + formatId: 'whatsapp-native-txt', + expected: { + meta: { name: 'Alice', platform: KNOWN_PLATFORMS.WHATSAPP, type: ChatType.PRIVATE }, + memberIds: ['Alice', 'Bob'], + messages: [ + { + senderPlatformId: 'Alice', + timestamp: localTs('2024-01-02T03:04:00'), + type: MessageType.TEXT, + content: 'hello whatsapp', + }, + { + senderPlatformId: 'Bob', + timestamp: localTs('2024-01-02T03:05:00'), + type: MessageType.IMAGE, + content: 'image omitted', + }, + ], + }, + }, + { + filename: 'chatlab.json', + content: json({ + chatlab: { version: '1.0.0', exportedAt: 1704164645 }, + meta: { name: 'ChatLab JSON 群', platform: 'qq', type: 'group' }, + members: [ + { platformId: 'u1', accountName: 'Alice', groupNickname: 'A' }, + { platformId: 'u2', accountName: 'Bob', groupNickname: 'B' }, + ], + messages: [ + { + sender: 'u1', + accountName: 'Alice', + groupNickname: 'A', + timestamp: 1704164645, + type: MessageType.TEXT, + content: 'hello chatlab json', + }, + ], + }), + formatId: 'chatlab', + expected: { + meta: { name: 'ChatLab JSON 群', platform: KNOWN_PLATFORMS.QQ, type: ChatType.GROUP }, + memberIds: ['u1', 'u2'], + messages: [ + { + senderPlatformId: 'u1', + timestamp: 1704164645, + type: MessageType.TEXT, + content: 'hello chatlab json', + }, + ], + }, + }, + { + filename: 'chatlab.jsonl', + content: [ + jsonLine({ + _type: 'header', + chatlab: { version: '1.0.0', exportedAt: 1704164645 }, + meta: { name: 'ChatLab JSONL 群', platform: 'telegram', type: 'group' }, + }), + jsonLine({ _type: 'member', platformId: 'tg1', accountName: 'Alice' }), + jsonLine({ + _type: 'message', + sender: 'tg1', + accountName: 'Alice', + timestamp: 1704164645, + type: MessageType.TEXT, + content: 'hello chatlab jsonl', + }), + '', + ].join('\n'), + formatId: 'chatlab-jsonl', + expected: { + meta: { name: 'ChatLab JSONL 群', platform: KNOWN_PLATFORMS.TELEGRAM, type: ChatType.GROUP }, + memberIds: ['tg1'], + messages: [ + { + senderPlatformId: 'tg1', + timestamp: 1704164645, + type: MessageType.TEXT, + content: 'hello chatlab jsonl', + }, + ], + }, + }, +] + +describe('parser representative format fixtures', () => { + for (const fixture of fixtures) { + it(`detects and parses ${fixture.formatId}`, async () => { + const result = await parseFixture(fixture) + + assert.deepEqual( + { + name: result.meta.name, + platform: result.meta.platform, + type: result.meta.type, + }, + fixture.expected.meta + ) + assert.deepEqual(result.members.map((member) => member.platformId).sort(), [...fixture.expected.memberIds].sort()) + assert.deepEqual( + result.messages.map((message) => ({ + senderPlatformId: message.senderPlatformId, + timestamp: message.timestamp, + type: message.type, + content: message.content, + })), + fixture.expected.messages + ) + }) + } +}) diff --git a/packages/parser/src/formats/google-chat-takeout.test.ts b/packages/parser/src/formats/google-chat-takeout.test.ts new file mode 100644 index 000000000..f1a7744f8 --- /dev/null +++ b/packages/parser/src/formats/google-chat-takeout.test.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { describe, it } from 'node:test' +import { ChatType, KNOWN_PLATFORMS, MessageType } from '@openchatlab/shared-types' + +import { detectFormat, parseFileSync } from '../index' + +interface FixtureMessage { + message_id: string + created_date?: string + updated_date?: string + creator: { + email?: string + name: string + user_type: string + } + text?: string + attached_files?: Array<{ + original_name?: string + export_name?: string + }> + quoted_message_metadata?: { + creator?: { + email?: string + name?: string + user_type?: string + } + text?: string + } +} + +async function parseGoogleChatFixture(options: { + chatType?: 'private' | 'group' + chatName?: string + date: string + messages?: FixtureMessage[] +}) { + const dir = mkdtempSync(join(tmpdir(), 'chatlab-google-chat-parser-')) + try { + const manifestPath = join(dir, 'google-chat-import.json') + writeFileSync( + manifestPath, + JSON.stringify({ + format: 'chatlab-google-chat-takeout', + version: 1, + chatId: options.chatType === 'group' ? 'Groups/Space sample' : 'Groups/DM sample', + chatType: options.chatType ?? 'private', + chatName: options.chatName, + userInfoFile: 'user_info.json', + groupInfoFile: 'group_info.json', + messagesFile: 'messages.json', + }), + 'utf8' + ) + writeFileSync( + join(dir, 'user_info.json'), + JSON.stringify({ + user: { + email: 'Owner@Example.com', + name: 'Owner', + user_type: 'Human', + }, + }), + 'utf8' + ) + writeFileSync( + join(dir, 'group_info.json'), + JSON.stringify({ + name: options.chatType === 'group' ? options.chatName : undefined, + members: [ + { email: 'Owner@Example.com', name: 'Owner', user_type: 'Human' }, + { email: 'Other@Example.com', name: 'Other User', user_type: 'Human' }, + ], + }), + 'utf8' + ) + writeFileSync( + join(dir, 'messages.json'), + JSON.stringify({ + messages: options.messages ?? [ + { + message_id: 'message-1', + created_date: options.date, + creator: { + email: 'Other@Example.com', + name: 'Other User', + user_type: 'Human', + }, + text: 'Hello', + }, + { + message_id: 'message-2', + created_date: options.date, + creator: { + email: 'Owner@Example.com', + name: 'Owner', + user_type: 'Human', + }, + attached_files: [{ original_name: 'voice_message.m4a', export_name: 'File-voice_message.m4a' }], + }, + ], + }), + 'utf8' + ) + + assert.equal(detectFormat(manifestPath)?.id, 'google-chat-takeout') + return await parseFileSync(manifestPath) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +} + +describe('google chat takeout parser', () => { + it('parses Chinese UTC dates and attachment-only messages', async () => { + const result = await parseGoogleChatFixture({ + date: '2026年5月29日星期五 UTC 03:00:29', + }) + + assert.deepEqual(result.meta, { + name: 'Other User', + platform: KNOWN_PLATFORMS.GOOGLE_CHAT, + type: ChatType.PRIVATE, + ownerId: 'owner@example.com', + }) + assert.deepEqual( + result.members.map((member) => member.platformId), + ['owner@example.com', 'other@example.com'] + ) + assert.equal(result.messages[0].timestamp, Date.UTC(2026, 4, 29, 3, 0, 29) / 1000) + assert.equal(result.messages[0].platformMessageId, 'message-1') + assert.equal(result.messages[1].type, MessageType.FILE) + assert.equal(result.messages[1].content, '[附件] voice_message.m4a') + }) + + it('parses English UTC dates with narrow no-break spaces', async () => { + const result = await parseGoogleChatFixture({ + date: 'Friday, May 29, 2026 at 3:00:29\u202fAM UTC', + }) + + assert.equal(result.messages[0].timestamp, Date.UTC(2026, 4, 29, 3, 0, 29) / 1000) + }) + + it('preserves quoted text and attachment names alongside message text', async () => { + const date = 'Friday, May 29, 2026 at 3:00:29 AM UTC' + const result = await parseGoogleChatFixture({ + date, + messages: [ + { + message_id: 'message-3', + created_date: date, + creator: { + email: 'Other@Example.com', + name: 'Other User', + user_type: 'Human', + }, + text: 'Current reply', + attached_files: [ + { original_name: 'photo.jpg', export_name: 'File-photo.jpg' }, + { export_name: 'File-document.pdf' }, + ], + quoted_message_metadata: { + creator: { name: 'Owner' }, + text: 'Original text', + }, + }, + ], + }) + + assert.equal(result.messages[0].type, MessageType.TEXT) + assert.equal( + result.messages[0].content, + ['> Owner: Original text', 'Current reply', '[附件] photo.jpg', '[附件] File-document.pdf'].join('\n') + ) + }) + + it('uses Space metadata and marks invalid dates for importer diagnostics', async () => { + const result = await parseGoogleChatFixture({ + chatType: 'group', + chatName: 'Project Space', + date: 'Friday, February 31, 2026 at 3:00:29 AM UTC', + }) + + assert.equal(result.meta.name, 'Project Space') + assert.equal(result.meta.type, ChatType.GROUP) + assert.equal(result.meta.groupId, 'Groups/Space sample') + assert.equal(Number.isNaN(result.messages[0].timestamp), true) + }) + + it('falls back to updated_date for edited messages without created_date', async () => { + const result = await parseGoogleChatFixture({ + date: 'unused', + messages: [ + { + message_id: 'edited-message', + updated_date: 'Friday, May 29, 2026 at 3:24:57\u202fAM UTC', + creator: { + email: 'Other@Example.com', + name: 'Other User', + user_type: 'Human', + }, + text: 'Edited text', + }, + ], + }) + + assert.equal(result.messages[0].timestamp, Date.UTC(2026, 4, 29, 3, 24, 57) / 1000) + }) +}) diff --git a/packages/parser/src/formats/google-chat-takeout.ts b/packages/parser/src/formats/google-chat-takeout.ts new file mode 100644 index 000000000..f0b5c9aa7 --- /dev/null +++ b/packages/parser/src/formats/google-chat-takeout.ts @@ -0,0 +1,373 @@ +/** + * Google Chat Takeout 内部导入格式解析器。 + * + * ZIP 读取与会话选择由 node-runtime 负责;本解析器只读取其生成的固定 manifest + * 和同目录 JSON 文件,从而复用现有 streaming importer。 + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' +import streamChain from 'stream-chain' +import streamJson from 'stream-json' +import pickModule from 'stream-json/filters/Pick.js' +import streamValuesModule from 'stream-json/streamers/StreamValues.js' +import { ChatType, KNOWN_PLATFORMS, MessageType } from '@openchatlab/shared-types' +import type { + FormatFeature, + FormatModule, + ParseEvent, + ParseOptions, + ParsedMember, + ParsedMessage, + Parser, +} from '../types' +import { createProgress, getFileSize } from '../utils' + +const { chain } = streamChain +const { parser } = streamJson +const { pick } = pickModule +const { streamValues } = streamValuesModule + +interface GoogleChatManifest { + format: string + version: number + chatId: string + chatType: 'private' | 'group' + chatName?: string + userInfoFile: string + groupInfoFile: string + messagesFile: string +} + +interface GoogleChatUser { + email?: string + name?: string + user_type?: string +} + +interface GoogleChatUserInfo { + user?: GoogleChatUser +} + +interface GoogleChatGroupInfo { + name?: string + members?: GoogleChatUser[] +} + +interface GoogleChatAttachment { + original_name?: string + export_name?: string +} + +interface GoogleChatQuotedMessage { + creator?: GoogleChatUser + text?: string +} + +interface GoogleChatMessage { + message_id?: string + created_date?: string + updated_date?: string + creator?: GoogleChatUser + text?: string + attached_files?: GoogleChatAttachment[] + quoted_message_metadata?: GoogleChatQuotedMessage +} + +const ENGLISH_MONTHS = new Map([ + ['january', 0], + ['february', 1], + ['march', 2], + ['april', 3], + ['may', 4], + ['june', 5], + ['july', 6], + ['august', 7], + ['september', 8], + ['october', 9], + ['november', 10], + ['december', 11], +]) + +export const feature: FormatFeature = { + id: 'google-chat-takeout', + name: 'Google Chat Takeout', + platform: KNOWN_PLATFORMS.GOOGLE_CHAT, + priority: 24, + extensions: ['.json'], + signatures: { + head: [/"format"\s*:\s*"chatlab-google-chat-takeout"/], + requiredFields: ['format', 'version', 'messagesFile'], + }, +} + +function normalizeSpaces(value: string): string { + return value + .replace(/[\u00a0\u202f]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function createUtcTimestamp( + year: number, + monthIndex: number, + day: number, + hour: number, + minute: number, + second: number +): number | null { + const timestampMs = Date.UTC(year, monthIndex, day, hour, minute, second) + const date = new Date(timestampMs) + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== monthIndex || + date.getUTCDate() !== day || + date.getUTCHours() !== hour || + date.getUTCMinutes() !== minute || + date.getUTCSeconds() !== second + ) { + return null + } + return Math.floor(timestampMs / 1000) +} + +function parseChineseUtcDate(value: string): number | null { + const match = normalizeSpaces(value).match( + /^(\d{4})年(\d{1,2})月(\d{1,2})日(?:星期[一二三四五六日天])?\s+UTC\s+(\d{1,2}):(\d{2}):(\d{2})$/ + ) + if (!match) return null + return createUtcTimestamp( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6]) + ) +} + +function parseEnglishUtcDate(value: string): number | null { + const match = normalizeSpaces(value).match( + /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday),\s+([A-Za-z]+)\s+(\d{1,2}),\s+(\d{4})\s+at\s+(\d{1,2}):(\d{2}):(\d{2})\s+(AM|PM)\s+UTC$/i + ) + if (!match) return null + + const monthIndex = ENGLISH_MONTHS.get(match[1].toLowerCase()) + if (monthIndex === undefined) return null + + let hour = Number(match[4]) + if (hour < 1 || hour > 12) return null + if (match[7].toUpperCase() === 'AM') { + if (hour === 12) hour = 0 + } else if (hour !== 12) { + hour += 12 + } + + return createUtcTimestamp(Number(match[3]), monthIndex, Number(match[2]), hour, Number(match[5]), Number(match[6])) +} + +/** + * Google Takeout 会根据导出语言生成本地化日期文本。这里只接受已验证的 + * 中文和英文 UTC 形式,避免宿主机 locale 或 Date.parse 产生不稳定结果。 + */ +export function parseGoogleChatDate(value: string): number | null { + return parseChineseUtcDate(value) ?? parseEnglishUtcDate(value) +} + +function normalizeIdentity(user: GoogleChatUser | undefined): { platformId: string; name: string } { + const email = user?.email?.trim().toLowerCase() + const name = user?.name?.trim() || email || 'Unknown' + return { + platformId: email || name.toLowerCase(), + name, + } +} + +function readJson(filePath: string): T { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T +} + +function resolveManifestFile(manifestDir: string, relativePath: string): string { + const resolved = path.resolve(manifestDir, relativePath) + const relative = path.relative(manifestDir, resolved) + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Google Chat manifest path escapes its directory: ${relativePath}`) + } + return resolved +} + +function readManifest(filePath: string): { + manifest: GoogleChatManifest + userInfoPath: string + groupInfoPath: string + messagesPath: string +} { + const manifest = readJson(filePath) + if (manifest.format !== 'chatlab-google-chat-takeout' || manifest.version !== 1) { + throw new Error('Invalid Google Chat import manifest') + } + if (manifest.chatType !== 'private' && manifest.chatType !== 'group') { + throw new Error('Invalid Google Chat chat type') + } + + const manifestDir = path.dirname(filePath) + return { + manifest, + userInfoPath: resolveManifestFile(manifestDir, manifest.userInfoFile), + groupInfoPath: resolveManifestFile(manifestDir, manifest.groupInfoFile), + messagesPath: resolveManifestFile(manifestDir, manifest.messagesFile), + } +} + +function getAttachmentName(attachment: GoogleChatAttachment): string | null { + return attachment.original_name?.trim() || attachment.export_name?.trim() || null +} + +function buildQuotePrefix(message: GoogleChatMessage): string | null { + const quote = message.quoted_message_metadata + const quoteText = quote?.text?.trim() + if (!quoteText) return null + const creator = quote?.creator?.name?.trim() || quote?.creator?.email?.trim() + return creator ? `> ${creator}: ${quoteText}` : `> ${quoteText}` +} + +function buildMessageContent(message: GoogleChatMessage): string { + const parts: string[] = [] + const quote = buildQuotePrefix(message) + if (quote) parts.push(quote) + + const text = message.text?.trim() + if (text) parts.push(text) + + for (const attachment of message.attached_files ?? []) { + const name = getAttachmentName(attachment) + if (name) parts.push(`[附件] ${name}`) + } + + return parts.length > 0 ? parts.join('\n') : '[不支持的 Google Chat 消息]' +} + +function detectMessageType(message: GoogleChatMessage): MessageType { + if (message.text?.trim()) return MessageType.TEXT + if ((message.attached_files ?? []).some((attachment) => getAttachmentName(attachment))) { + return MessageType.FILE + } + return MessageType.OTHER +} + +function deriveChatName( + manifest: GoogleChatManifest, + groupInfo: GoogleChatGroupInfo, + ownerId: string | undefined +): string { + if (manifest.chatName?.trim()) return manifest.chatName.trim() + if (manifest.chatType === 'group' && groupInfo.name?.trim()) return groupInfo.name.trim() + + const otherMember = (groupInfo.members ?? []) + .map(normalizeIdentity) + .find((member) => !ownerId || member.platformId !== ownerId) + return ( + otherMember?.name || + groupInfo.members?.map((member) => normalizeIdentity(member).name).join(', ') || + manifest.chatId + ) +} + +async function* parseGoogleChat(options: ParseOptions): AsyncGenerator { + const { filePath, batchSize = 5000, onProgress, onLog } = options + const { manifest, userInfoPath, groupInfoPath, messagesPath } = readManifest(filePath) + const userInfo = readJson(userInfoPath) + const groupInfo = readJson(groupInfoPath) + const owner = userInfo.user ? normalizeIdentity(userInfo.user) : null + const totalBytes = getFileSize(messagesPath) + + const initialProgress = createProgress('parsing', 0, totalBytes, 0, '') + yield { type: 'progress', data: initialProgress } + onProgress?.(initialProgress) + + yield { + type: 'meta', + data: { + name: deriveChatName(manifest, groupInfo, owner?.platformId), + platform: KNOWN_PLATFORMS.GOOGLE_CHAT, + type: manifest.chatType === 'group' ? ChatType.GROUP : ChatType.PRIVATE, + ...(manifest.chatType === 'group' ? { groupId: manifest.chatId } : {}), + ownerId: owner?.platformId, + }, + } + + const memberMap = new Map() + for (const member of groupInfo.members ?? []) { + const identity = normalizeIdentity(member) + memberMap.set(identity.platformId, { + platformId: identity.platformId, + accountName: identity.name, + }) + } + if (owner && !memberMap.has(owner.platformId)) { + memberMap.set(owner.platformId, { + platformId: owner.platformId, + accountName: owner.name, + }) + } + yield { type: 'members', data: Array.from(memberMap.values()) } + + const readStream = fs.createReadStream(messagesPath, { encoding: 'utf8' }) + let bytesRead = 0 + let messagesProcessed = 0 + readStream.on('data', (chunk: string | Buffer) => { + bytesRead += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length + }) + + const pipeline = chain([readStream, parser(), pick({ filter: /^messages\.\d+$/ }), streamValues()]) + const messageBatch: ParsedMessage[] = [] + + try { + for await (const item of pipeline as AsyncIterable<{ value: GoogleChatMessage }>) { + const message = item.value + const sender = normalizeIdentity(message.creator) + messageBatch.push({ + platformMessageId: message.message_id ? String(message.message_id) : undefined, + senderPlatformId: sender.platformId, + senderAccountName: sender.name, + timestamp: parseGoogleChatDate(message.created_date ?? message.updated_date ?? '') ?? Number.NaN, + type: detectMessageType(message), + content: buildMessageContent(message), + }) + messagesProcessed++ + + if (messageBatch.length >= batchSize) { + yield { type: 'messages', data: messageBatch.splice(0) } + const progress = createProgress('parsing', bytesRead, totalBytes, messagesProcessed, '') + yield { type: 'progress', data: progress } + onProgress?.(progress) + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + onLog?.('error', `Failed to parse Google Chat messages: ${message}`) + yield { type: 'error', data: new Error(`Failed to parse Google Chat messages: ${message}`) } + return + } + + if (messageBatch.length > 0) { + yield { type: 'messages', data: messageBatch } + } + + const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '') + yield { type: 'progress', data: doneProgress } + onProgress?.(doneProgress) + yield { type: 'done', data: { messageCount: messagesProcessed, memberCount: memberMap.size } } +} + +export const parser_: Parser = { + feature, + parse: parseGoogleChat, +} + +const module_: FormatModule = { + feature, + parser: parser_, +} + +export default module_ diff --git a/packages/parser/src/formats/index.ts b/packages/parser/src/formats/index.ts new file mode 100644 index 000000000..e87d7f13a --- /dev/null +++ b/packages/parser/src/formats/index.ts @@ -0,0 +1,61 @@ +/** + * 格式模块注册 + * 导出所有支持的格式 + */ + +import type { FormatModule } from '../types' + +// 导入所有格式模块 +import chatlab from './chatlab' +import chatlabJsonl from './chatlab-jsonl' +import shuakamiQqExporter from './shuakami-qq-exporter' +import shuakamiQqExporterChunked from './shuakami-qq-exporter-chunked' +import weflow from './weflow' +import yccccccyEchotrace from './ycccccccy-echotrace' +import tyrrrzDiscordExporter from './tyrrrz-discord-exporter' +import telegramNative from './telegram-native' +import telegramNativeSingle from './telegram-native-single' +import whatsappNativeTxt from './whatsapp-native-txt' +import qqNativeTxt from './qq-native-txt' +import instagramNative from './instagram-native' +import googleChatTakeout from './google-chat-takeout' +import lineNativeTxt from './line-native-txt' + +/** + * 所有支持的格式模块(按优先级排序) + * 注意:注册时会自动按 priority 字段排序 + */ +export const formats: FormatModule[] = [ + shuakamiQqExporterChunked, // 优先级 5 - shuakami/qq-chat-exporter chunked-jsonl + shuakamiQqExporter, // 优先级 10 - shuakami/qq-chat-exporter + weflow, // 优先级 15 - WeFlow 微信导出 + yccccccyEchotrace, // 优先级 16 - ycccccccy/echotrace 微信导出 + tyrrrzDiscordExporter, // 优先级 20 - Tyrrrz/DiscordChatExporter + telegramNative, // 优先级 22 - Telegram 官方全量导出 JSON + telegramNativeSingle, // 优先级 23 - Telegram 单聊天导出 JSON + googleChatTakeout, // 优先级 24 - Google Chat Takeout 内部 manifest + instagramNative, // 优先级 25 - Instagram 官方导出 + whatsappNativeTxt, // 优先级 26 - WhatsApp 官方导出 TXT + qqNativeTxt, // 优先级 30 - QQ 官方导出 TXT + lineNativeTxt, // 优先级 35 - LINE 官方导出 TXT + chatlab, // 优先级 50 - ChatLab JSON + chatlabJsonl, // 优先级 51 - ChatLab JSONL(流式格式,支持超大文件) +] + +// 按名称导出,方便单独使用 +export { + chatlab, + chatlabJsonl, + shuakamiQqExporter, + shuakamiQqExporterChunked, + weflow, + yccccccyEchotrace, + tyrrrzDiscordExporter, + telegramNative, + telegramNativeSingle, + googleChatTakeout, + instagramNative, + whatsappNativeTxt, + qqNativeTxt, + lineNativeTxt, +} diff --git a/packages/parser/src/formats/instagram-native.ts b/packages/parser/src/formats/instagram-native.ts new file mode 100644 index 000000000..f5f19fd8a --- /dev/null +++ b/packages/parser/src/formats/instagram-native.ts @@ -0,0 +1,363 @@ +/** + * Instagram 官方导出格式解析器 + * 适配:Instagram 账号数据下载功能导出的 JSON 文件 + * + * 文件结构: + * - participants: 参与者数组 [{ name: string }] + * - messages: 消息数组(逆序,最新在前) + * - title: 对话标题(群名或对方用户名) + * - thread_path: 线程路径,如 "inbox/xxx_123456" + * - joinable_mode: 仅群聊有,包含入群链接 + * + * 特殊处理: + * - 编码问题:Instagram 将 UTF-8 字节按 Latin-1 编码后存储,需要解码 + * - 消息逆序:需要反转为正序 + * - 无用户 ID:使用用户名作为 platformId + */ + +import * as fs from 'fs' +import * as path from 'path' +import { KNOWN_PLATFORMS, ChatType, MessageType } from '@openchatlab/shared-types' +import type { + FormatFeature, + FormatModule, + Parser, + ParseOptions, + ParseEvent, + ParsedMeta, + ParsedMember, + ParsedMessage, +} from '../types' +import { getFileSize, createProgress } from '../utils' + +// ==================== 特征定义 ==================== + +export const feature: FormatFeature = { + id: 'instagram-native', + name: 'Instagram 官方导出', + platform: KNOWN_PLATFORMS.INSTAGRAM, + priority: 25, + extensions: ['.json'], + signatures: { + // 使用 Instagram 特有的字段作为签名(在文件头部就能匹配) + // is_geoblocked_for_viewer 是 Instagram 消息特有的字段 + requiredFields: ['participants', 'messages'], + head: [/"is_geoblocked_for_viewer"\s*:/], + }, +} + +// ==================== 类型定义 ==================== + +interface InstagramParticipant { + name: string +} + +interface InstagramPhoto { + uri: string + creation_timestamp?: number +} + +interface InstagramVideo { + uri: string + creation_timestamp?: number +} + +interface InstagramAudio { + uri: string + creation_timestamp?: number +} + +interface InstagramShare { + link?: string + share_text?: string + original_content_owner?: string +} + +interface InstagramReaction { + reaction: string + actor: string +} + +interface InstagramMessage { + sender_name: string + timestamp_ms: number + content?: string + photos?: InstagramPhoto[] + videos?: InstagramVideo[] + audio_files?: InstagramAudio[] + share?: InstagramShare + reactions?: InstagramReaction[] + is_geoblocked_for_viewer?: boolean + is_unsent_image_by_messenger_kid_parent?: boolean +} + +interface InstagramData { + participants: InstagramParticipant[] + messages: InstagramMessage[] + title: string + is_still_participant?: boolean + thread_path: string + magic_words?: unknown[] + joinable_mode?: { + mode: number + link: string + } +} + +// ==================== 辅助函数 ==================== + +/** + * 解码 Instagram 特殊编码的文本 + * Instagram 将 UTF-8 字节按 Latin-1 编码后存储 + */ +function decodeInstagramText(text: string): string { + try { + // 将每个字符的 charCode 收集为字节数组 + const bytes = new Uint8Array(text.length) + for (let i = 0; i < text.length; i++) { + bytes[i] = text.charCodeAt(i) + } + // 用 UTF-8 解码 + return new TextDecoder('utf-8').decode(bytes) + } catch { + return text // 解码失败则返回原文 + } +} + +/** + * 从文件名提取名称(备用) + */ +function extractNameFromFilePath(filePath: string): string { + const basename = path.basename(filePath) + return basename.replace(/\.json$/i, '') || '未知对话' +} + +/** + * 判断是否为系统消息 + */ +function isSystemMessage(content: string): boolean { + const systemPatterns = [ + 'You created the group', + 'created the group', + 'added', + 'to the group', + 'left the group', + 'removed', + 'named the group', + 'changed the group photo', + 'Reacted', + 'sent an attachment', + 'liked a message', + 'changed the theme', + 'set the nickname', + ] + return systemPatterns.some((p) => content.includes(p)) +} + +/** + * 判断消息类型 + */ +function detectMessageType(msg: InstagramMessage): MessageType { + const content = msg.content || '' + + // 1. 系统消息判断 + if (content && isSystemMessage(content)) { + return MessageType.SYSTEM + } + + // 2. 媒体消息 + if (msg.photos?.length) return MessageType.IMAGE + if (msg.videos?.length) return MessageType.VIDEO + if (msg.audio_files?.length) return MessageType.VOICE + + // 3. 分享消息 + if (msg.share) { + const link = msg.share.link || '' + if (link.includes('giphy.com')) return MessageType.EMOJI + return MessageType.LINK + } + + // 4. 文本消息 + if (content) return MessageType.TEXT + + // 5. 空消息(位置分享、通话等已删除的消息) + return MessageType.OTHER +} + +/** + * 获取消息内容 + */ +function getMessageContent(msg: InstagramMessage): string | null { + // 文本内容 + if (msg.content) { + return decodeInstagramText(msg.content) + } + + // 图片 + if (msg.photos?.length) { + return `[图片] ${msg.photos[0].uri}` + } + + // 视频 + if (msg.videos?.length) { + return `[视频] ${msg.videos[0].uri}` + } + + // 语音 + if (msg.audio_files?.length) { + return `[语音] ${msg.audio_files[0].uri}` + } + + // 分享 + if (msg.share) { + const link = msg.share.link || '' + if (link.includes('giphy.com')) { + return `[GIF] ${link}` + } + return `[链接] ${link}` + } + + // 空消息 + return '[未知消息]' +} + +// ==================== 解析器实现 ==================== + +async function* parseInstagram(options: ParseOptions): AsyncGenerator { + const { filePath, batchSize = 5000, onProgress, onLog } = options + + const totalBytes = getFileSize(filePath) + let messagesProcessed = 0 + + // 发送初始进度 + const initialProgress = createProgress('parsing', 0, totalBytes, 0, '正在解析 Instagram 聊天记录...') + yield { type: 'progress', data: initialProgress } + onProgress?.(initialProgress) + + onLog?.('info', `开始解析 Instagram 聊天记录,大小: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`) + + // 读取并解析 JSON 文件 + let data: InstagramData + try { + const content = fs.readFileSync(filePath, 'utf-8') + data = JSON.parse(content) + } catch (error) { + const err = new Error(`无法解析 Instagram JSON 文件: ${error}`) + yield { type: 'error', data: err } + return + } + + // 判断聊天类型 + const isGroup = data.participants.length > 2 || !!data.joinable_mode + const chatType = isGroup ? ChatType.GROUP : ChatType.PRIVATE + + // 判断 Owner + let ownerId: string | undefined + if (chatType === ChatType.PRIVATE) { + // 私聊:title 是对方名字,另一个参与者是 owner + const owner = data.participants.find((p) => decodeInstagramText(p.name) !== decodeInstagramText(data.title)) + ownerId = owner ? decodeInstagramText(owner.name) : undefined + } else { + // 群聊:找 "You created the group." 消息的发送者 + const createMsg = data.messages.find((m) => m.content === 'You created the group.') + ownerId = createMsg ? decodeInstagramText(createMsg.sender_name) : undefined + } + + // 发送 meta + const meta: ParsedMeta = { + name: decodeInstagramText(data.title) || extractNameFromFilePath(filePath), + platform: KNOWN_PLATFORMS.INSTAGRAM, + type: chatType, + ownerId, + } + yield { type: 'meta', data: meta } + + // 收集成员信息 + const memberMap = new Map() + for (const participant of data.participants) { + const name = decodeInstagramText(participant.name) + memberMap.set(name, { + platformId: name, + accountName: name, + }) + } + + // 发送成员 + const members = Array.from(memberMap.values()) + yield { type: 'members', data: members } + + // 处理消息(Instagram 消息是逆序的,需要反转) + const reversedMessages = [...data.messages].reverse() + const messageBatch: ParsedMessage[] = [] + + for (const msg of reversedMessages) { + const senderName = decodeInstagramText(msg.sender_name) + const timestamp = Math.floor(msg.timestamp_ms / 1000) // 毫秒转秒 + const type = detectMessageType(msg) + const content = getMessageContent(msg) + + // 确保成员存在(处理消息中出现但不在 participants 中的情况) + if (!memberMap.has(senderName)) { + memberMap.set(senderName, { + platformId: senderName, + accountName: senderName, + }) + } + + messageBatch.push({ + senderPlatformId: senderName, + senderAccountName: senderName, + timestamp, + type, + content, + }) + + messagesProcessed++ + + // 分批输出消息 + if (messageBatch.length >= batchSize) { + yield { type: 'messages', data: [...messageBatch] } + messageBatch.length = 0 + + const progress = createProgress( + 'parsing', + Math.floor((messagesProcessed / reversedMessages.length) * totalBytes), + totalBytes, + messagesProcessed, + `已处理 ${messagesProcessed} 条消息...` + ) + onProgress?.(progress) + } + } + + // 发送剩余消息 + if (messageBatch.length > 0) { + yield { type: 'messages', data: messageBatch } + } + + // 完成 + const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '解析完成') + yield { type: 'progress', data: doneProgress } + onProgress?.(doneProgress) + + onLog?.('info', `解析完成: ${messagesProcessed} 条消息, ${memberMap.size} 个成员`) + + yield { + type: 'done', + data: { messageCount: messagesProcessed, memberCount: memberMap.size }, + } +} + +// ==================== 导出 ==================== + +export const parser_: Parser = { + feature, + parse: parseInstagram, +} + +const module_: FormatModule = { + feature, + parser: parser_, +} + +export default module_ diff --git a/packages/parser/src/formats/line-native-txt.ts b/packages/parser/src/formats/line-native-txt.ts new file mode 100644 index 000000000..1c41fd895 --- /dev/null +++ b/packages/parser/src/formats/line-native-txt.ts @@ -0,0 +1,573 @@ +/** + * LINE 官方导出 TXT 格式解析器 + * 支持私聊和群聊,支持多语言导出(EN / ZH-CN / ZH-TW / JA) + * + * 格式特征: + * - 头部格式(私聊和群聊相同): + * Line 1: [LINE] {name}的聊天记录 / Chat history with/in {name} / ... + * Line 2: 保存日期: YYYY/MM/DD HH:MM / Saved on: ... + * Line 3: (空行) + * - 日期行:YYYY/MM/DD(星期)或 Day, MM/DD/YYYY + * - 消息格式:TIME\t{sender}\t{content}(Tab 分隔) + * - 系统消息:TIME\t\t{content}(双 Tab,无发送者) + * - 时间格式:HH:MM / 上午|下午HH:MM / 午前|午後HH:MM / HH:MMam|pm + * - 多行消息:用双引号包裹 + * + * 私聊 vs 群聊区分: + * - EN: "Chat history with {name}" (私聊) vs "Chat history in {name}" (群聊) + * - JA: "{name}とのトーク履歴" (私聊) vs "{name}のトーク履歴" (群聊) + * - ZH-CN: "与{name}的聊天记录" (私聊) vs "{name}的聊天记录" (群聊) + * - ZH-TW: "與{name}的聊天記錄" (私聊) vs "{name}的聊天記錄" (群聊) + */ + +import * as fs from 'fs' +import * as path from 'path' +import { KNOWN_PLATFORMS, ChatType, MessageType } from '@openchatlab/shared-types' +import type { + FormatFeature, + FormatModule, + Parser, + ParseOptions, + ParseEvent, + ParsedMeta, + ParsedMember, + ParsedMessage, +} from '../types' +import { getFileSize, createProgress } from '../utils' + +// ==================== 特征定义 ==================== + +export const feature: FormatFeature = { + id: 'line-native-txt', + name: 'LINE 官方导出 TXT', + platform: KNOWN_PLATFORMS.LINE, + priority: 35, + extensions: ['.txt'], + signatures: { + head: [ + // 头部标识(多语言) + /^\[LINE\] /m, + /^(?:\[LINE\] )?Chat history (?:with|in) /m, + // Tab 分隔的消息格式(支持多种时间格式) + /^((?:上午|下午|午前|午後)?\d{1,2}:\d{2}(?:[AaPp][Mm])?)\t[^\t\n]+\t/m, + // 空格分隔的消息格式(部分 LINE 导出) + /^((?:上午|下午|午前|午後)?\d{1,2}:\d{2}(?:[AaPp][Mm])?) [^\s]+ /m, + // LINE 独有的日期行格式:YYYY.MM.DD DayOfWeek(英文星期全称) + /^\d{4}\.\d{2}\.\d{2}\s+(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)/m, + // LINE 日文/中文日期行格式:YYYY/M/D(曜日) + /^\d{4}\/\d{1,2}\/\d{1,2}[((][月火水木金土日]/m, + // LINE 中文日期行格式:YYYY/M/D周X + /^\d{4}\/\d{1,2}\/\d{1,2}周/m, + ], + // 文件名特征:[LINE] 出现在文件名中 + filename: [/\[LINE\]/i], + }, +} + +// ==================== 辅助函数 ==================== + +/** + * 从文件名提取聊天名称 + */ +function extractNameFromFilePath(filePath: string): string { + const basename = path.basename(filePath, '.txt') + // 移除 [LINE] 前缀 + const name = basename.replace(/^\[LINE\]\s*/i, '').trim() + return name || '未知聊天' +} + +/** + * 从头部提取聊天名称和类型 + * 支持:英文、日文、简体中文、繁体中文 + */ +function extractNameFromHeader(header: string): { name: string; isGroup: boolean } | null { + // ===== 英文 ===== + // 私聊:Chat history with {name} + const enPrivateMatch = header.match(/^(?:\[LINE\] )?Chat history with (.+)$/m) + if (enPrivateMatch) return { name: enPrivateMatch[1].trim(), isGroup: false } + // 群聊:Chat history in {name} + const enGroupMatch = header.match(/^(?:\[LINE\] )?Chat history in (.+)$/m) + if (enGroupMatch) return { name: enGroupMatch[1].trim(), isGroup: true } + + // ===== 日文 ===== + // 私聊:{name}とのトーク履歴 + const jaPrivateMatch = header.match(/^\[LINE\] (.+)とのトーク履歴/) + if (jaPrivateMatch) return { name: jaPrivateMatch[1].trim(), isGroup: false } + // 群聊:{name}のトーク履歴 + const jaGroupMatch = header.match(/^\[LINE\] (.+)のトーク履歴/) + if (jaGroupMatch) return { name: jaGroupMatch[1].trim(), isGroup: true } + + // ===== 简体中文 ===== + // 私聊:与{name}的聊天记录 + const zhCnPrivateMatch = header.match(/^\[LINE\] 与(.+)的聊天记录/) + if (zhCnPrivateMatch) return { name: zhCnPrivateMatch[1].trim(), isGroup: false } + // 群聊:{name}的聊天记录 + const zhCnGroupMatch = header.match(/^\[LINE\] (.+)的聊天记录/) + if (zhCnGroupMatch) return { name: zhCnGroupMatch[1].trim(), isGroup: true } + + // ===== 繁体中文 ===== + // 私聊:與{name}的聊天記錄 + const zhTwPrivateMatch = header.match(/^\[LINE\] 與(.+)的聊天記錄/) + if (zhTwPrivateMatch) return { name: zhTwPrivateMatch[1].trim(), isGroup: false } + // 群聊:{name}的聊天記錄 + const zhTwGroupMatch = header.match(/^\[LINE\] (.+)的聊天記錄/) + if (zhTwGroupMatch) return { name: zhTwGroupMatch[1].trim(), isGroup: true } + + return null +} + +/** + * 日期行正则模式 + */ +const DATE_PATTERNS = [ + // 2025.12.10 Wednesday + /^(\d{4})\.(\d{2})\.(\d{2})\s+(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)?/, + // 2026/1/30周五 or 2026/1/30(金) + /^(\d{4})\/(\d{1,2})\/(\d{1,2})/, + // Fri, 1/30/2026 + /^[A-Za-z]+,\s*(\d{1,2})\/(\d{1,2})\/(\d{4})/, +] + +/** + * 尝试解析日期行 + */ +function parseDateLine(line: string): Date | null { + for (const pattern of DATE_PATTERNS) { + const match = line.match(pattern) + if (match) { + // 根据不同格式提取年月日 + if (pattern === DATE_PATTERNS[0] || pattern === DATE_PATTERNS[1]) { + // YYYY.MM.DD or YYYY/M/D + const year = parseInt(match[1]) + const month = parseInt(match[2]) - 1 + const day = parseInt(match[3]) + return new Date(year, month, day) + } else if (pattern === DATE_PATTERNS[2]) { + // M/D/YYYY + const month = parseInt(match[1]) - 1 + const day = parseInt(match[2]) + const year = parseInt(match[3]) + return new Date(year, month, day) + } + } + } + return null +} + +/** + * 消息行正则模式 + * 时间格式:HH:MM / HH:MMam|pm / 上午|下午|午前|午後HH:MM + */ +// 私聊/群聊(有发送者):TIME\t{name}\t{content} +const PRIVATE_MSG_PATTERN = /^((?:上午|下午|午前|午後)?\d{1,2}:\d{2}(?:[AaPp][Mm])?)\t([^\t]+)\t(.*)$/ +// 群聊:HH:MM {name} {content} (已废弃,实际都用 Tab 分隔) +const GROUP_MSG_PATTERN = /^((?:上午|下午|午前|午後)?\d{1,2}:\d{2}(?:[AaPp][Mm])?) ([^\s]+) (.*)$/ +// 系统消息:双 Tab(无发送者),如「下午07:04\t\tXXX已加入群組」 +const SYSTEM_MSG_PATTERN = /^((?:上午|下午|午前|午後)?\d{1,2}:\d{2}(?:[AaPp][Mm])?)\t\t(.+)$/ + +/** + * 特殊消息类型映射(多语言:EN / ZH-CN / ZH-TW / JA) + */ +const SPECIAL_MESSAGE_TYPES: Record = { + // 图片 / Photo + '[Photo]': MessageType.IMAGE, // EN + '[照片]': MessageType.IMAGE, // ZH-CN / ZH-TW + '[写真]': MessageType.IMAGE, // JA + Photos: MessageType.IMAGE, // EN (fallback) + + // 语音 / Voice + '[Voice message]': MessageType.VOICE, // EN + '[语音信息]': MessageType.VOICE, // ZH-CN + '[語音訊息]': MessageType.VOICE, // ZH-TW + '[ボイスメッセージ]': MessageType.VOICE, // JA + Audio: MessageType.VOICE, // EN (fallback) + + // 视频 / Video + '[Video]': MessageType.VIDEO, // EN + '[视频]': MessageType.VIDEO, // ZH-CN + '[影片]': MessageType.VIDEO, // ZH-TW + '[動画]': MessageType.VIDEO, // JA + Videos: MessageType.VIDEO, // EN (fallback) + + // 文件 / File + '[File]': MessageType.FILE, // EN + '[文件]': MessageType.FILE, // ZH-CN + '[檔案]': MessageType.FILE, // ZH-TW + '[ファイル]': MessageType.FILE, // JA + + // 贴纸 / Sticker + '[Sticker]': MessageType.EMOJI, // EN + '[贴图]': MessageType.EMOJI, // ZH-CN + '[貼圖]': MessageType.EMOJI, // ZH-TW + '[スタンプ]': MessageType.EMOJI, // JA + Stickers: MessageType.EMOJI, // EN (fallback) + + // 位置 / Location + '[Location]': MessageType.LOCATION, // EN + '[位置]': MessageType.LOCATION, // ZH-CN / ZH-TW + '[位置情報]': MessageType.LOCATION, // JA + + // 记事本 / Notes + '[Notes]': MessageType.TEXT, // EN + '[记事本]': MessageType.TEXT, // ZH-CN + '[記事本]': MessageType.TEXT, // ZH-TW + '[ノート]': MessageType.TEXT, // JA +} + +/** + * 检测消息类型 + */ +function detectMessageType(content: string): MessageType { + // 检查特殊消息类型 + for (const [pattern, type] of Object.entries(SPECIAL_MESSAGE_TYPES)) { + if (content === pattern || content.startsWith(pattern)) { + return type + } + } + + // 检查 [null] 开头的位置消息 + if (content.startsWith('[null]') && content.includes('maps.google.com')) { + return MessageType.LOCATION + } + + // 检查系统消息(多语言:EN / ZH-CN / ZH-TW / JA) + if ( + // --- 加入群组 / Join group --- + content.includes(' joined the group') || // EN + content.includes('已加入该群') || // ZH-CN + content.includes('已加入群組') || // ZH-TW + content.includes('がグループに参加しました') || // JA + // --- 拉人进群 / Added to group --- + content.includes(' added ') || // EN + content.includes(' to the group') || // EN + content.includes('已将') || // ZH-CN + content.includes('添加至群') || // ZH-CN + content.includes('添加到群') || // ZH-CN (另一格式) + content.includes('已新增') || // ZH-TW + content.includes('至群組') || // ZH-TW + content.includes('をグループに追加しました') || // JA + // --- 退出群组 / Left group --- + content.includes(' left the group') || // EN + content.includes('已退群') || // ZH-CN + content.includes('已離開群組') || // ZH-TW + content.includes('がグループを退会しました') || // JA + // --- 设定公告 / Announcement --- + content.includes('made an announcement') || // EN + content.includes('发布了通告') || // ZH-CN + content.includes('已設定公告') || // ZH-TW + content.includes('がアナウンスしました') || // JA + // --- 收回讯息 / Unsent message --- + content.includes('unsent a message') || // EN + content === 'Message unsent.' || // EN + content.includes('撤回了一条消息') || // ZH-CN + content.includes('已收回訊息') || // ZH-TW + content.includes('送信を取り消しました') || // JA + // --- 其他 / Others --- + content.startsWith('Auto-reply') // EN 自动回复 + ) { + return MessageType.SYSTEM + } + + // 检查链接 + if (content.match(/^https?:\/\//)) { + return MessageType.LINK + } + + return MessageType.TEXT +} + +// ==================== 解析器实现 ==================== + +async function* parseLINE(options: ParseOptions): AsyncGenerator { + const { filePath, batchSize = 5000, onProgress, onLog } = options + + const totalBytes = getFileSize(filePath) + let messagesProcessed = 0 + + // 发送初始进度 + const initialProgress = createProgress('parsing', 0, totalBytes, 0, '') + yield { type: 'progress', data: initialProgress } + onProgress?.(initialProgress) + + onLog?.('info', `开始解析 LINE 导出文件,大小: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`) + + // 读取整个文件(LINE 导出通常不大) + const content = fs.readFileSync(filePath, 'utf-8') + // 处理 Windows 换行符 (\r\n) + const lines = content.split('\n').map((line) => line.replace(/\r$/, '')) + + // 解析状态 + let currentDate: Date | null = null + let chatName = extractNameFromFilePath(filePath) + let isPrivateChat = false + let useTabSeparator = false + const memberMap = new Map() + const messages: ParsedMessage[] = [] + let lastMessage: ParsedMessage | null = null + let lineIndex = 0 + + // 检测是否有头部 + if (lines.length > 0) { + const firstLine = lines[0].trim() + onLog?.('debug', `LINE 第一行: "${firstLine}"`) + const headerResult = extractNameFromHeader(firstLine) + if (headerResult) { + chatName = headerResult.name + isPrivateChat = !headerResult.isGroup + useTabSeparator = true // 两种头部格式都使用 Tab 分隔 + lineIndex = 3 // 跳过头部(标题、保存时间、空行) + onLog?.('debug', `LINE 检测到头部,名称: ${headerResult.name}, 群聊: ${headerResult.isGroup}`) + } + } + + // 如果没有检测到头部,检查第一条消息的格式 + if (!isPrivateChat && lines.length > 0) { + for (const line of lines) { + if (PRIVATE_MSG_PATTERN.test(line)) { + useTabSeparator = true + onLog?.('debug', `LINE 检测到 Tab 分隔格式`) + break + } + if (GROUP_MSG_PATTERN.test(line)) { + useTabSeparator = false + onLog?.('debug', `LINE 检测到空格分隔格式`) + break + } + } + } + + onLog?.('debug', `LINE 解析配置: useTabSeparator=${useTabSeparator}, lineIndex=${lineIndex}`) + + // 解析消息 + let debugLogCount = 0 + for (let i = lineIndex; i < lines.length; i++) { + const line = lines[i] + + // 尝试解析日期行 + const dateResult = parseDateLine(line) + if (dateResult) { + currentDate = dateResult + if (debugLogCount < 5) { + onLog?.('debug', `LINE 日期行[${i}]: ${line} -> ${dateResult.toISOString()}`) + } + continue + } + + // 尝试解析消息行 + const msgPattern = useTabSeparator ? PRIVATE_MSG_PATTERN : GROUP_MSG_PATTERN + const msgMatch = line.match(msgPattern) + + // 调试前几行 + if (debugLogCount < 5 && line.trim()) { + onLog?.('debug', `LINE 行[${i}]: "${line.substring(0, 50)}..." match=${!!msgMatch}`) + debugLogCount++ + } + + if (msgMatch) { + const [, timeStr, sender, contentRaw] = msgMatch + let content = contentRaw.trim() + + // 处理 LINE 导出的多行消息格式(用双引号包裹) + let isQuotedMultiline = false + if (content.startsWith('"')) { + content = content.substring(1) // 移除开头的引号 + isQuotedMultiline = !content.endsWith('"') // 单行带引号则直接处理 + if (content.endsWith('"')) { + content = content.substring(0, content.length - 1) // 移除结尾引号 + } + } + + // 解析时间(支持中文上午/下午、日文午前/午後、英文am/pm) + let hours = 0 + let minutes = 0 + + const prefix = timeStr.match(/^(上午|下午|午前|午後)/)?.[1] + const cleanTime = timeStr.replace(/^(上午|下午|午前|午後)/, '') + const partsMatch = cleanTime.match(/^(\d{1,2}):(\d{2})([AaPp][Mm])?$/i) + + if (partsMatch) { + hours = parseInt(partsMatch[1]) + minutes = parseInt(partsMatch[2]) + const suffix = partsMatch[3]?.toLowerCase() + + // 英文后缀 + if (suffix === 'pm' && hours < 12) hours += 12 + if (suffix === 'am' && hours === 12) hours = 0 + + // 中文/日文前缀 (下午/午後 = PM, 上午/午前 = AM) + if ((prefix === '下午' || prefix === '午後') && hours < 12) hours += 12 + if ((prefix === '上午' || prefix === '午前') && hours === 12) hours = 0 + } else { + // Fallback + const parts = timeStr.split(':').map(Number) + hours = parts[0] + minutes = parts[1] + } + + let timestamp: number + + if (currentDate) { + const msgDate = new Date(currentDate) + msgDate.setHours(hours, minutes, 0, 0) + timestamp = Math.floor(msgDate.getTime() / 1000) + } else { + // 如果没有日期,使用当前日期 + const now = new Date() + now.setHours(hours, minutes, 0, 0) + timestamp = Math.floor(now.getTime() / 1000) + } + + // 检测消息类型 + const msgType = detectMessageType(content) + + // 更新成员信息 + if (!memberMap.has(sender)) { + memberMap.set(sender, { + platformId: sender, + accountName: sender, + }) + } + + // 创建消息 + lastMessage = { + senderPlatformId: sender, + senderAccountName: sender, + timestamp, + type: msgType, + content: content || null, + _isQuotedMultiline: isQuotedMultiline, // 临时标记,用于追加多行内容时处理结尾引号 + } as ParsedMessage & { _isQuotedMultiline?: boolean } + messages.push(lastMessage) + messagesProcessed++ + + // 更新进度 + if (messagesProcessed % 1000 === 0) { + const progress = createProgress( + 'parsing', + i, + lines.length, + messagesProcessed, + `已处理 ${messagesProcessed} 条消息...` + ) + onProgress?.(progress) + } + } else { + // 尝试解析系统消息(双 Tab) + const systemMatch = line.match(SYSTEM_MSG_PATTERN) + if (systemMatch) { + const [, timeStr, contentRaw] = systemMatch + const content = contentRaw.trim() + + // 解析时间(支持中文、日文、英文) + let hours = 0 + let minutes = 0 + + const prefix = timeStr.match(/^(上午|下午|午前|午後)/)?.[1] + const cleanTime = timeStr.replace(/^(上午|下午|午前|午後)/, '') + const partsMatch = cleanTime.match(/^(\d{1,2}):(\d{2})([AaPp][Mm])?$/i) + + if (partsMatch) { + hours = parseInt(partsMatch[1]) + minutes = parseInt(partsMatch[2]) + const suffix = partsMatch[3]?.toLowerCase() + + if (suffix === 'pm' && hours < 12) hours += 12 + if (suffix === 'am' && hours === 12) hours = 0 + + if ((prefix === '下午' || prefix === '午後') && hours < 12) hours += 12 + if ((prefix === '上午' || prefix === '午前') && hours === 12) hours = 0 + } else { + const parts = timeStr.split(':').map(Number) + hours = parts[0] + minutes = parts[1] + } + + let timestamp: number + if (currentDate) { + const msgDate = new Date(currentDate) + msgDate.setHours(hours, minutes, 0, 0) + timestamp = Math.floor(msgDate.getTime() / 1000) + } else { + const now = new Date() + now.setHours(hours, minutes, 0, 0) + timestamp = Math.floor(now.getTime() / 1000) + } + + // 创建系统消息 + lastMessage = { + senderPlatformId: 'system', + senderAccountName: '系統', + timestamp, + type: MessageType.SYSTEM, + content: content || null, + } + messages.push(lastMessage) + messagesProcessed++ + } else if (line.trim() && lastMessage) { + // 非消息行,追加到上一条消息(多行内容) + let appendLine = line + const quotedMsg = lastMessage as ParsedMessage & { _isQuotedMultiline?: boolean } + + // 检查是否为带引号多行消息的最后一行(以 " 结尾) + if (quotedMsg._isQuotedMultiline && appendLine.endsWith('"')) { + appendLine = appendLine.substring(0, appendLine.length - 1) // 移除结尾引号 + delete quotedMsg._isQuotedMultiline // 清除临时标记 + } + + if (lastMessage.content) { + lastMessage.content += '\n' + appendLine + } else { + lastMessage.content = appendLine + } + } + } + } + + // 根据成员数判断聊天类型 + const memberCount = memberMap.size + const chatType = memberCount <= 2 ? ChatType.PRIVATE : ChatType.GROUP + + // 发送 meta + const meta: ParsedMeta = { + name: chatName, + platform: KNOWN_PLATFORMS.LINE, + type: chatType, + } + yield { type: 'meta', data: meta } + + // 发送成员 + const members = Array.from(memberMap.values()) + yield { type: 'members', data: members } + + // 分批发送消息 + for (let i = 0; i < messages.length; i += batchSize) { + const batch = messages.slice(i, i + batchSize) + yield { type: 'messages', data: batch } + } + + // 完成 + const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '') + yield { type: 'progress', data: doneProgress } + onProgress?.(doneProgress) + + onLog?.('info', `解析完成: ${messagesProcessed} 条消息, ${memberCount} 个成员`) + + yield { + type: 'done', + data: { messageCount: messagesProcessed, memberCount }, + } +} + +// ==================== 导出 ==================== + +export const parser_: Parser = { + feature, + parse: parseLINE, +} + +const module_: FormatModule = { + feature, + parser: parser_, +} + +export default module_ diff --git a/electron/main/parser/formats/qq-native-txt.ts b/packages/parser/src/formats/qq-native-txt.ts similarity index 98% rename from electron/main/parser/formats/qq-native-txt.ts rename to packages/parser/src/formats/qq-native-txt.ts index d792046b9..3b6e63b7b 100644 --- a/electron/main/parser/formats/qq-native-txt.ts +++ b/packages/parser/src/formats/qq-native-txt.ts @@ -17,7 +17,7 @@ import * as fs from 'fs' import * as path from 'path' import * as readline from 'readline' -import { KNOWN_PLATFORMS, ChatType, MessageType } from '../../../../src/types/base' +import { KNOWN_PLATFORMS, ChatType, MessageType } from '@openchatlab/shared-types' import type { FormatFeature, FormatModule, @@ -225,7 +225,7 @@ async function* parseTxt(options: ParseOptions): AsyncGenerator,如果没有则使用昵称(讨论组格式) - let platformId = headerMatch[3] || headerMatch[4] || nickname + const platformId = headerMatch[3] || headerMatch[4] || nickname // 如果昵称和 ID 相同,可能是系统故障,使用之前记录的昵称 if (nickname === platformId && headerMatch[3]) { diff --git a/packages/parser/src/formats/shuakami-qq-exporter-chunked.ts b/packages/parser/src/formats/shuakami-qq-exporter-chunked.ts new file mode 100644 index 000000000..0c2e4c43b --- /dev/null +++ b/packages/parser/src/formats/shuakami-qq-exporter-chunked.ts @@ -0,0 +1,566 @@ +/** + * shuakami/qq-chat-exporter chunked-jsonl 格式解析器 + * 适配项目: https://github.com/shuakami/qq-chat-exporter + * 版本: 5.x(chunked-jsonl 分块格式) + * + * 文件结构: + * - manifest.json: 元数据和分块信息 + * - metadata: 导出元数据 + * - chatInfo: 聊天信息 + * - chunked.chunks[]: 分块文件列表 + * - avatars?: 头像文件信息(V5.5+) + * - chunks/: 分块目录 + * - chunk_0001.jsonl: 每行一条消息 + * - chunk_0002.jsonl: ... + * - avatars.json: 头像数据(V5.5+,可选) + * + * 消息格式(sender 字段): + * - uid: 用户 UID + * - uin: QQ 号 + * - name: 展示名(优先群昵称,否则QQ昵称) + * - nickname: QQ 昵称 + * - groupCard: 群昵称(群聊时存在) + * - remark: 好友备注 + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as readline from 'readline' +import { KNOWN_PLATFORMS, ChatType, MessageType } from '@openchatlab/shared-types' +import type { + FormatFeature, + FormatModule, + Parser, + ParseOptions, + ParseEvent, + ParsedMeta, + ParsedMember, + ParsedMessage, +} from '../types' +import { createProgress, parseTimestamp, isValidYear } from '../utils' + +// ==================== 特征定义 ==================== + +export const feature: FormatFeature = { + id: 'shuakami-qq-exporter-chunked', + name: 'shuakami/qq-chat-exporter (chunked)', + platform: KNOWN_PLATFORMS.QQ, + priority: 5, // 比单文件版本优先级更高 + extensions: ['.json'], + signatures: { + // 使用 head 签名 "format": "chunked-jsonl" 来区分。 + head: [/"format"\s*:\s*"chunked-jsonl"/], + requiredFields: ['metadata', 'chatInfo'], + }, +} + +// ==================== 类型定义 ==================== + +interface ChunkInfo { + // V5.0 格式 + file?: string + messages?: number + bytes?: number + // V5.5+ 格式 + index?: number + fileName?: string + relativePath?: string + count?: number + start?: string + end?: string +} + +interface Manifest { + metadata: { + name?: string + copyright?: string + exportTime: string + version: string + format: string + } + chatInfo: { + name: string + type: string + selfUid?: string + selfUin?: string + selfName?: string + } + statistics: { + totalMessages: number + chunkCount?: number + timeRange?: { + start: string + end: string + durationDays: number + } + messageTypes?: Record + senders?: Array<{ + uid: string + name: string + messageCount: number + percentage: number + }> + } + chunked: { + format: string + chunksDir: string + chunkFileExt: string + maxMessagesPerChunk: number + maxBytesPerChunk: number + chunks: ChunkInfo[] + } + avatars?: { + file: string + count: number + } +} + +interface ChunkedMessage { + id?: string + seq?: string + timestamp: number + time?: string + sender: { + uid?: string + uin?: string + name: string + nickname?: string // QQ 昵称 + groupCard?: string // 群昵称 + remark?: string // 好友备注 + } + type: string + content: { + text: string + html?: string + elements?: Array<{ type: string; data?: Record }> + resources?: Array<{ type: string }> + mentions?: Array<{ uid: string; name: string }> + } + recalled?: boolean + system?: boolean +} + +interface MemberInfo { + platformId: string + accountName: string + groupNickname: string | undefined + avatar: string | undefined +} + +// ==================== 消息类型转换 ==================== + +function convertMessageType(msgType: string, content: ChunkedMessage['content'], isRecalled?: boolean): MessageType { + if (isRecalled) return MessageType.RECALL + + // 系统消息 + if (msgType === 'system') return MessageType.SYSTEM + + // 检查资源类型 + if (content.resources?.length) { + const resourceType = content.resources[0].type + switch (resourceType) { + case 'image': + return MessageType.IMAGE + case 'video': + return MessageType.VIDEO + case 'voice': + case 'audio': + return MessageType.VOICE + case 'file': + return MessageType.FILE + case 'location': + return MessageType.LOCATION + } + } + + // 检查表情 + if (content.elements?.some((e) => e.type === 'face' || e.type === 'market_face' || e.type === 'marketFace')) { + return MessageType.EMOJI + } + + // 根据文本内容判断 + const text = content.text?.trim() || '' + if (text.includes('QQ红包') || text.includes('发出了红包') || text === '[红包]') return MessageType.RED_PACKET + if (text.includes('转账') || text === '[转账]') return MessageType.TRANSFER + if (text.includes('拍了拍') || text.includes('戳了戳') || text === '[拍一拍]') return MessageType.POKE + if (text.includes('语音通话') || text.includes('视频通话') || text.includes('通话时长')) return MessageType.CALL + if (text === '[分享]' || text === '[音乐]' || text === '[小程序]') return MessageType.SHARE + if (text === '[链接]' || text === '[卡片消息]') return MessageType.LINK + if (text === '[位置]' || text === '[地理位置]') return MessageType.LOCATION + if (text === '[转发]' || text === '[聊天记录]') return MessageType.FORWARD + + return MessageType.TEXT +} + +// ==================== 辅助函数 ==================== + +/** + * 读取并解析 manifest.json + */ +function readManifest(manifestPath: string): Manifest { + const content = fs.readFileSync(manifestPath, 'utf-8') + return JSON.parse(content) as Manifest +} + +/** + * 获取 chunk 文件的相对路径(兼容新旧格式) + */ +function getChunkRelativePath(chunk: ChunkInfo): string { + // V5.5+ 使用 relativePath + if (chunk.relativePath) return chunk.relativePath + // V5.0 使用 file + if (chunk.file) return chunk.file + // 后备:使用 fileName 拼接 + if (chunk.fileName) return `chunks/${chunk.fileName}` + throw new Error('无法获取 chunk 文件路径') +} + +/** + * 获取 chunk 的消息数量(兼容新旧格式) + */ +function getChunkMessageCount(chunk: ChunkInfo): number { + // V5.5+ 使用 count + if (chunk.count !== undefined) return chunk.count + // V5.0 使用 messages + if (chunk.messages !== undefined) return chunk.messages + return 0 +} + +/** + * 计算所有 chunk 文件的总字节数 + */ +function calculateTotalBytes(manifest: Manifest, baseDir: string): number { + let total = 0 + for (const chunk of manifest.chunked.chunks) { + const relativePath = getChunkRelativePath(chunk) + const chunkPath = path.join(baseDir, relativePath) + if (fs.existsSync(chunkPath)) { + total += fs.statSync(chunkPath).size + } + } + return total +} + +/** + * 读取 avatars.json 文件 + */ +function readAvatars(baseDir: string, avatarsInfo?: { file: string; count: number }): Map { + const avatarsMap = new Map() + if (!avatarsInfo?.file) return avatarsMap + + const avatarsPath = path.join(baseDir, avatarsInfo.file) + if (!fs.existsSync(avatarsPath)) return avatarsMap + + try { + const content = fs.readFileSync(avatarsPath, 'utf-8') + const avatars = JSON.parse(content) as Record + for (const [uin, avatar] of Object.entries(avatars)) { + if (avatar && typeof avatar === 'string' && avatar.startsWith('data:image/')) { + avatarsMap.set(uin, avatar) + } + } + } catch { + // 头像读取失败,继续不带头像 + } + + return avatarsMap +} + +/** + * 流式读取 JSONL 文件(带详细错误处理) + */ +async function* readJsonlFile( + filePath: string, + onParseError?: (lineNumber: number, error: string) => void +): AsyncGenerator { + let fileStream: fs.ReadStream | null = null + let rl: readline.Interface | null = null + let lineNumber = 0 + let parseErrorCount = 0 + const MAX_PARSE_ERRORS_TO_LOG = 10 // 最多记录前 10 个解析错误 + + try { + fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }) + rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }) + + for await (const line of rl) { + lineNumber++ + const trimmed = line.trim() + if (!trimmed) continue + + try { + yield JSON.parse(trimmed) as ChunkedMessage + } catch (error) { + parseErrorCount++ + // 只记录前几个解析错误,避免日志爆炸 + if (parseErrorCount <= MAX_PARSE_ERRORS_TO_LOG && onParseError) { + const errorMsg = error instanceof Error ? error.message : String(error) + onParseError(lineNumber, errorMsg) + } + // 继续处理下一行 + } + } + } finally { + // 确保资源被正确清理 + if (rl) { + rl.close() + } + if (fileStream) { + fileStream.destroy() + } + } +} + +// ==================== 解析器实现 ==================== + +async function* parseChunkedJsonl(options: ParseOptions): AsyncGenerator { + const { filePath, batchSize = 5000, onProgress, onLog } = options + + // 确定 manifest.json 路径和基础目录 + const manifestPath = filePath + const baseDir = path.dirname(manifestPath) + + onLog?.('info', `开始解析 manifest: ${manifestPath}`) + onLog?.('info', `基础目录: ${baseDir}`) + + // 读取 manifest + let manifest: Manifest + try { + manifest = readManifest(manifestPath) + onLog?.('info', `manifest 读取成功`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + onLog?.('error', `无法读取 manifest.json: ${errorMsg}`) + yield { type: 'error', data: new Error(`无法读取 manifest.json: ${errorMsg}`) } + return + } + + // 验证格式 + if (manifest.metadata.format !== 'chunked-jsonl') { + onLog?.('error', `不支持的格式: ${manifest.metadata.format}`) + yield { type: 'error', data: new Error(`不支持的格式: ${manifest.metadata.format}`) } + return + } + + const totalBytes = calculateTotalBytes(manifest, baseDir) + let bytesRead = 0 + let messagesProcessed = 0 + let skippedMessages = 0 + let parseErrors = 0 + + // 发送初始进度 + const initialProgress = createProgress('parsing', 0, totalBytes, 0, '') + yield { type: 'progress', data: initialProgress } + onProgress?.(initialProgress) + + onLog?.( + 'info', + `开始解析 chunked-jsonl 格式 (V${manifest.metadata.version}),共 ${manifest.chunked.chunks.length} 个分块,预计 ${manifest.statistics.totalMessages} 条消息,总大小约 ${Math.round(totalBytes / 1024 / 1024)}MB` + ) + + // 读取头像文件(如果存在) + let avatarsMap: Map + try { + avatarsMap = readAvatars(baseDir, manifest.avatars) + if (avatarsMap.size > 0) { + onLog?.('info', `已加载 ${avatarsMap.size} 个用户头像`) + } + } catch (error) { + onLog?.('error', `读取头像文件失败: ${error instanceof Error ? error.message : String(error)}`) + avatarsMap = new Map() + } + + // 发送 meta + const chatType = manifest.chatInfo.type === 'group' ? ChatType.GROUP : ChatType.PRIVATE + const meta: ParsedMeta = { + name: manifest.chatInfo.name || '未知群聊', + platform: KNOWN_PLATFORMS.QQ, + type: chatType, + ownerId: manifest.chatInfo.selfUin || manifest.chatInfo.selfUid, + } + yield { type: 'meta', data: meta } + onLog?.('info', `Meta 信息: name=${meta.name}, type=${meta.type}, ownerId=${meta.ownerId}`) + + // 收集成员信息(不再收集所有消息到内存) + const memberMap = new Map() + + // 当前消息批次(真正的流式处理:边读取边发送) + let currentBatch: ParsedMessage[] = [] + + // 遍历所有 chunk 文件 + for (let chunkIndex = 0; chunkIndex < manifest.chunked.chunks.length; chunkIndex++) { + const chunkInfo = manifest.chunked.chunks[chunkIndex] + const relativePath = getChunkRelativePath(chunkInfo) + const chunkPath = path.join(baseDir, relativePath) + const chunkMessageCount = getChunkMessageCount(chunkInfo) + + if (!fs.existsSync(chunkPath)) { + onLog?.('error', `分块文件不存在: ${chunkPath}`) + continue + } + + const chunkSize = fs.statSync(chunkPath).size + let chunkMessagesRead = 0 + let chunkParseErrors = 0 + + onLog?.( + 'info', + `正在解析分块 [${chunkIndex + 1}/${manifest.chunked.chunks.length}]: ${relativePath} (预计 ${chunkMessageCount} 条消息, ${Math.round(chunkSize / 1024)}KB)` + ) + + // 流式读取 JSONL 文件(带错误处理) + try { + const parseErrorCallback = (lineNumber: number, errorMsg: string) => { + onLog?.('error', `分块 ${relativePath} 第 ${lineNumber} 行 JSON 解析失败: ${errorMsg}`) + } + + for await (const msg of readJsonlFile(chunkPath, parseErrorCallback)) { + chunkMessagesRead++ + + // 获取 platformId + const platformId = msg.sender?.uin || msg.sender?.uid + if (!platformId || platformId === '0' || platformId === '未知') { + skippedMessages++ + continue + } + + // 获取名字信息 + // nickname: QQ 昵称(原始昵称) + // groupCard: 群昵称 + // name: 展示名(一般是 groupCard || nickname) + const accountName = msg.sender?.nickname || msg.sender?.name || platformId + const groupNickname = msg.sender?.groupCard || undefined + + // 更新成员信息 + const existingMember = memberMap.get(platformId) + if (!existingMember) { + memberMap.set(platformId, { + platformId, + accountName, + groupNickname, + avatar: avatarsMap.get(platformId), + }) + } else { + existingMember.accountName = accountName + if (groupNickname) existingMember.groupNickname = groupNickname + if (!existingMember.avatar) existingMember.avatar = avatarsMap.get(platformId) + } + + // 解析时间戳(chunked 格式的时间戳是毫秒) + const timestamp = + typeof msg.timestamp === 'number' ? Math.floor(msg.timestamp / 1000) : parseTimestamp(msg.timestamp) + + if (timestamp === null || !isValidYear(timestamp)) { + skippedMessages++ + continue + } + + // 消息类型 + const type = msg.system ? MessageType.SYSTEM : convertMessageType(msg.type, msg.content, msg.recalled) + + // 文本内容 + let textContent = msg.content?.text || '' + if (msg.recalled) textContent = '[已撤回] ' + textContent + + // 添加到当前批次 + currentBatch.push({ + platformMessageId: msg.id, + senderPlatformId: platformId, + senderAccountName: accountName, + senderGroupNickname: groupNickname, + timestamp, + type, + content: textContent || null, + }) + + messagesProcessed++ + + // 达到批次大小时立即发送(真正的流式处理) + if (currentBatch.length >= batchSize) { + yield { type: 'messages', data: currentBatch } + currentBatch = [] // 清空批次,释放内存 + + // 发送进度 + const chunkProgress = chunkMessageCount > 0 ? chunkMessagesRead / chunkMessageCount : 0 + const chunkBytesRead = Math.floor(chunkProgress * chunkSize) + const currentBytesRead = bytesRead + chunkBytesRead + const progress = createProgress( + 'parsing', + currentBytesRead, + totalBytes, + messagesProcessed, + `已处理 ${messagesProcessed} 条消息...` + ) + yield { type: 'progress', data: progress } + onProgress?.(progress) + } + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + onLog?.('error', `解析分块 ${relativePath} 时发生错误: ${errorMsg}`) + chunkParseErrors++ + parseErrors++ + // 继续处理下一个分块,不中断整个解析 + } + + // 更新总字节读取 + bytesRead += chunkSize + + // 记录分块处理结果 + onLog?.( + 'info', + `分块 ${relativePath} 解析完成: 读取 ${chunkMessagesRead} 条, 有效 ${messagesProcessed} 条, 跳过 ${skippedMessages} 条${chunkParseErrors > 0 ? `, 错误 ${chunkParseErrors} 个` : ''}` + ) + } + + // 发送剩余的消息批次 + if (currentBatch.length > 0) { + yield { type: 'messages', data: currentBatch } + onLog?.('info', `发送最后一批消息: ${currentBatch.length} 条`) + } + + // 发送成员(包含头像) + const members: ParsedMember[] = Array.from(memberMap.values()).map((m) => ({ + platformId: m.platformId, + accountName: m.accountName, + groupNickname: m.groupNickname, + avatar: m.avatar, + })) + yield { type: 'members', data: members } + onLog?.('info', `发送成员列表: ${members.length} 个成员`) + + // 完成 + const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '') + yield { type: 'progress', data: doneProgress } + onProgress?.(doneProgress) + + onLog?.('info', `解析完成: ${messagesProcessed} 条消息, ${memberMap.size} 个成员`) + if (skippedMessages > 0) { + onLog?.('info', `跳过 ${skippedMessages} 条无效消息(缺少发送者ID或时间戳无效)`) + } + if (parseErrors > 0) { + onLog?.('error', `解析过程中发生 ${parseErrors} 个错误`) + } + + yield { + type: 'done', + data: { messageCount: messagesProcessed, memberCount: memberMap.size }, + } +} + +// ==================== 导出 ==================== + +export const parser_: Parser = { + feature, + parse: parseChunkedJsonl, +} + +const module_: FormatModule = { + feature, + parser: parser_, +} + +export default module_ diff --git a/electron/main/parser/formats/shuakami-qq-exporter.ts b/packages/parser/src/formats/shuakami-qq-exporter.ts similarity index 96% rename from electron/main/parser/formats/shuakami-qq-exporter.ts rename to packages/parser/src/formats/shuakami-qq-exporter.ts index de7263f86..6e8ce1839 100644 --- a/electron/main/parser/formats/shuakami-qq-exporter.ts +++ b/packages/parser/src/formats/shuakami-qq-exporter.ts @@ -19,11 +19,11 @@ import * as fs from 'fs' import * as path from 'path' -import { parser } from 'stream-json' -import { pick } from 'stream-json/filters/Pick' -import { streamValues } from 'stream-json/streamers/StreamValues' -import { chain } from 'stream-chain' -import { KNOWN_PLATFORMS, ChatType, MessageType } from '../../../../src/types/base' +import streamJson from 'stream-json' +import pickModule from 'stream-json/filters/Pick.js' +import streamValuesModule from 'stream-json/streamers/StreamValues.js' +import streamChain from 'stream-chain' +import { KNOWN_PLATFORMS, ChatType, MessageType } from '@openchatlab/shared-types' import type { FormatFeature, FormatModule, @@ -36,6 +36,11 @@ import type { } from '../types' import { getFileSize, createProgress, readFileHeadBytes, parseTimestamp, isValidYear } from '../utils' +const { parser } = streamJson +const { pick } = pickModule +const { streamValues } = streamValuesModule +const { chain } = streamChain + // ==================== 特征定义 ==================== export const feature: FormatFeature = { diff --git a/electron/main/parser/formats/shuakami-qq-preprocessor.ts b/packages/parser/src/formats/shuakami-qq-preprocessor.ts similarity index 96% rename from electron/main/parser/formats/shuakami-qq-preprocessor.ts rename to packages/parser/src/formats/shuakami-qq-preprocessor.ts index 796eae0f6..394ec229c 100644 --- a/electron/main/parser/formats/shuakami-qq-preprocessor.ts +++ b/packages/parser/src/formats/shuakami-qq-preprocessor.ts @@ -9,13 +9,18 @@ import * as fs from 'fs' import * as path from 'path' import * as os from 'os' -import { parser } from 'stream-json' -import { pick } from 'stream-json/filters/Pick' -import { streamValues } from 'stream-json/streamers/StreamValues' -import { chain } from 'stream-chain' +import streamJson from 'stream-json' +import pickModule from 'stream-json/filters/Pick.js' +import streamValuesModule from 'stream-json/streamers/StreamValues.js' +import streamChain from 'stream-chain' import type { ParseProgress, Preprocessor } from '../types' import { getFileSize, createProgress } from '../utils' +const { parser } = streamJson +const { pick } = pickModule +const { streamValues } = streamValuesModule +const { chain } = streamChain + /** 预处理阈值:50MB */ const PREPROCESS_THRESHOLD = 50 * 1024 * 1024 @@ -362,7 +367,7 @@ function cleanupTempFile(filePath: string): void { * QQ Chat Exporter 预处理器 */ export const qqPreprocessor: Preprocessor = { - needsPreprocess(filePath: string, fileSize: number): boolean { + needsPreprocess(_filePath: string, fileSize: number): boolean { return fileSize > PREPROCESS_THRESHOLD }, diff --git a/packages/parser/src/formats/telegram-native-single.ts b/packages/parser/src/formats/telegram-native-single.ts new file mode 100644 index 000000000..5ac9a9fe7 --- /dev/null +++ b/packages/parser/src/formats/telegram-native-single.ts @@ -0,0 +1,214 @@ +/** + * Telegram 单聊天导出 JSON 格式解析器 + * 适配 Telegram Desktop (Windows) 的「导出聊天记录」→ 单个聊天导出 + * + * 格式特征: + * - 单个 JSON 文件只包含一个聊天 + * - 顶层直接是聊天对象:{ name, type, id, messages } + * - 没有 about / personal_information / chats 等外层包装 + * - 消息结构与全量导出一致(date_unixtime, from_id, text_entities 等) + * - 支持 personal_chat / bot_chat / private_group / public_channel 等类型 + * + * 导入流程(直接导入,无需聊天选择器): + * 1. 用户选择单聊天 JSON 文件 → 格式识别 + * 2. parser 直接读取并解析该文件 + */ + +import * as fs from 'fs' +import streamChain from 'stream-chain' +import streamJson from 'stream-json' +import streamValuesModule from 'stream-json/streamers/StreamValues.js' + +import { KNOWN_PLATFORMS, ChatType } from '@openchatlab/shared-types' +import type { + FormatFeature, + FormatModule, + Parser, + ParseOptions, + ParseEvent, + ParsedMeta, + ParsedMember, + ParsedMessage, +} from '../types' +import { getFileSize, createProgress } from '../utils' +import { mapChatType, extractPlatformId, detectMessageType, buildContent } from './utils/telegram-utils' +import type { TelegramChat } from './utils/telegram-utils' + +const { chain } = streamChain +const { parser } = streamJson +const { streamValues } = streamValuesModule + +// ==================== 特征定义 ==================== + +export const feature: FormatFeature = { + id: 'telegram-native-single', + name: 'Telegram 单聊天导出 (JSON)', + platform: KNOWN_PLATFORMS.TELEGRAM, + priority: 23, + extensions: ['.json'], + signatures: { + // Telegram 单聊天导出特征:文件以 "name" 作为第一个 JSON 键 + // 这与全量导出(以 "about" 开头)有效区分,避免嵌套字段误匹配 + head: [/^\s*\{\s*\r?\n\s*"name"\s*:/], + requiredFields: ['messages'], + fieldPatterns: { + // Telegram 特有的聊天类型值,精确区分 + telegramChatType: + /"type"\s*:\s*"(personal_chat|bot_chat|private_group|private_supergroup|public_group|public_supergroup|public_channel|private_channel|saved_messages)"/, + }, + }, +} + +// ==================== 解析器实现 ==================== + +async function* parseTelegramSingle(options: ParseOptions): AsyncGenerator { + const { filePath, batchSize = 5000, onProgress, onLog } = options + + const totalBytes = getFileSize(filePath) + let messagesProcessed = 0 + + // 发送初始进度 + const initialProgress = createProgress('parsing', 0, totalBytes, 0, '') + yield { type: 'progress', data: initialProgress } + onProgress?.(initialProgress) + + onLog?.('info', `开始解析 Telegram 单聊天 JSON 文件,大小: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`) + + // 流式读取整个文件(顶层即为聊天对象) + const chatData = await new Promise((resolve, reject) => { + const readStream = fs.createReadStream(filePath, { encoding: 'utf-8' }) + + const pipeline = chain([readStream, parser(), streamValues()]) + + let found = false + + pipeline.on('data', ({ value }: { value: TelegramChat }) => { + if (!found) { + found = true + resolve(value) + } + }) + + pipeline.on('end', () => { + if (!found) resolve(null) + }) + + pipeline.on('error', reject) + }) + + if (!chatData) { + onLog?.('error', '无法解析 Telegram 单聊天文件') + yield { type: 'error', data: new Error('无法解析 Telegram 单聊天文件') } + return + } + + onLog?.('info', `聊天: "${chatData.name}", 类型: ${chatData.type}, 消息数: ${chatData.messages?.length || 0}`) + + // 确定聊天类型 + const chatType = mapChatType(chatData.type) + + // 发送 meta + const meta: ParsedMeta = { + name: chatData.name || `Telegram Chat ${chatData.id}`, + platform: KNOWN_PLATFORMS.TELEGRAM, + type: chatType, + groupId: chatType === ChatType.GROUP ? String(chatData.id) : undefined, + } + yield { type: 'meta', data: meta } + + // 收集成员和消息 + const memberMap = new Map() + const messageBatch: ParsedMessage[] = [] + const messages = chatData.messages || [] + + for (const msg of messages) { + // 提取发送者信息 + let senderPlatformId: string + let senderName: string + + if (msg.type === 'service') { + // Service 消息使用 actor 信息 + senderPlatformId = extractPlatformId(msg.actor_id) + senderName = msg.actor || '系统' + } else { + senderPlatformId = extractPlatformId(msg.from_id) + senderName = msg.from || senderPlatformId + } + + // 更新成员 + if (!memberMap.has(senderPlatformId) && senderPlatformId !== 'unknown') { + memberMap.set(senderPlatformId, { + platformId: senderPlatformId, + accountName: senderName, + }) + } + + // 解析时间戳 + const timestamp = parseInt(msg.date_unixtime, 10) + if (isNaN(timestamp)) continue + + // 构建消息 + const parsedMsg: ParsedMessage = { + platformMessageId: String(msg.id), + senderPlatformId, + senderAccountName: senderName, + timestamp, + type: detectMessageType(msg), + content: buildContent(msg), + replyToMessageId: msg.reply_to_message_id ? String(msg.reply_to_message_id) : undefined, + } + + messageBatch.push(parsedMsg) + messagesProcessed++ + + // 分批 yield 消息 + if (messageBatch.length >= batchSize) { + yield { type: 'messages', data: [...messageBatch] } + messageBatch.length = 0 + + const progress = createProgress( + 'parsing', + 0, + totalBytes, + messagesProcessed, + `已处理 ${messagesProcessed} 条消息...` + ) + yield { type: 'progress', data: progress } + onProgress?.(progress) + } + } + + // 发送成员 + yield { type: 'members', data: Array.from(memberMap.values()) } + + // 发送剩余消息 + if (messageBatch.length > 0) { + yield { type: 'messages', data: messageBatch } + } + + // 完成 + const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '') + yield { type: 'progress', data: doneProgress } + onProgress?.(doneProgress) + + onLog?.('info', `解析完成: ${messagesProcessed} 条消息, ${memberMap.size} 个成员, 类型: ${chatType}`) + + yield { + type: 'done', + data: { messageCount: messagesProcessed, memberCount: memberMap.size }, + } +} + +// ==================== 导出 ==================== + +export const parser_: Parser = { + feature, + parse: parseTelegramSingle, +} + +const module_: FormatModule = { + feature, + parser: parser_, +} + +export default module_ diff --git a/packages/parser/src/formats/telegram-native.ts b/packages/parser/src/formats/telegram-native.ts new file mode 100644 index 000000000..327d7ada8 --- /dev/null +++ b/packages/parser/src/formats/telegram-native.ts @@ -0,0 +1,285 @@ +/** + * Telegram 官方全量导出 JSON 格式解析器 + * 适配 Telegram Desktop (macOS) 的「导出聊天记录」→ 全部导出 + * + * 格式特征: + * - 单个 JSON 文件包含用户所有聊天(contacts, chats 等) + * - 聊天数据在 chats.list[] 下,每个聊天有独立的 messages 数组 + * - 消息的 text 字段可以是纯字符串或 mixed 数组(含富文本实体) + * - 支持 personal_chat / private_group / private_supergroup / saved_messages 等类型 + * + * 导入流程(多聊天选择器): + * 1. 用户选择 telegram.json → 格式识别 + * 2. scanChats() 快速扫描提取聊天列表 + * 3. 用户选择要导入的聊天 + * 4. parser 使用 formatOptions.chatIndex 定位并流式解析选定聊天 + */ + +import * as fs from 'fs' +import streamChain from 'stream-chain' +import streamJson from 'stream-json' +import pickModule from 'stream-json/filters/Pick.js' +import streamValuesModule from 'stream-json/streamers/StreamValues.js' + +import { KNOWN_PLATFORMS, ChatType } from '@openchatlab/shared-types' +import type { + FormatFeature, + FormatModule, + Parser, + ParseOptions, + ParseEvent, + ParsedMeta, + ParsedMember, + ParsedMessage, +} from '../types' +import { getFileSize, createProgress } from '../utils' +import { mapChatType, extractPlatformId, detectMessageType, buildContent } from './utils/telegram-utils' +import type { TelegramChat } from './utils/telegram-utils' + +const { chain } = streamChain +const { parser } = streamJson +const { pick } = pickModule +const { streamValues } = streamValuesModule + +// ==================== 类型定义 ==================== + +/** Telegram 聊天信息(扫描结果) */ +export interface TelegramChatInfo { + /** 在 chats.list[] 中的索引 */ + index: number + /** 聊天名称 */ + name: string + /** Telegram 聊天类型 */ + type: string + /** Telegram 聊天 ID */ + id: number + /** 消息数量 */ + messageCount: number +} + +// ==================== 特征定义 ==================== + +export const feature: FormatFeature = { + id: 'telegram-native', + name: 'Telegram 官方导出 (JSON)', + platform: KNOWN_PLATFORMS.TELEGRAM, + priority: 22, + extensions: ['.json'], + signatures: { + // Telegram 导出 JSON 的特征(语言无关:品牌名在所有语言导出中都存在) + head: [/Telegram/i], + // 注意:personal_information 在某些导出配置中是可选的,不能作为必需字段 + requiredFields: ['chats'], + }, + multiChat: true, +} + +// ==================== 扫描函数 ==================== + +/** + * 快速扫描 Telegram 导出 JSON,提取聊天列表 + * 使用 stream-json 流式处理,避免全量加载大文件到内存 + * + * @param filePath 文件路径 + * @returns 聊天列表信息 + */ +export async function scanChats(filePath: string): Promise { + const chats: TelegramChatInfo[] = [] + + return new Promise((resolve, reject) => { + const readStream = fs.createReadStream(filePath, { encoding: 'utf-8' }) + + // 使用 stream-json 解析 chats.list 数组中的每个聊天对象 + // ignore 过滤掉 messages 的实际内容以加速扫描 + const pipeline = chain([readStream, parser(), pick({ filter: /^chats\.list\.\d+$/ }), streamValues()]) + + pipeline.on('data', ({ value }: { value: TelegramChat }) => { + const chat = value + chats.push({ + index: chats.length, + name: chat.name || `Chat ${chat.id}`, + type: chat.type, + id: chat.id, + messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, + }) + }) + + pipeline.on('end', () => { + resolve(chats) + }) + + pipeline.on('error', (err: Error) => { + reject(new Error(`扫描 Telegram 文件失败: ${err.message}`)) + }) + }) +} + +// ==================== 解析器实现 ==================== + +async function* parseTelegram(options: ParseOptions): AsyncGenerator { + const { filePath, batchSize = 5000, formatOptions, onProgress, onLog } = options + + // 获取目标聊天索引 + const chatIndex = (formatOptions?.chatIndex as number) ?? 0 + + const totalBytes = getFileSize(filePath) + let bytesRead = 0 + let messagesProcessed = 0 + + // 发送初始进度 + const initialProgress = createProgress('parsing', 0, totalBytes, 0, '') + yield { type: 'progress', data: initialProgress } + onProgress?.(initialProgress) + + onLog?.('info', `开始解析 Telegram JSON 文件,大小: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`) + onLog?.('info', `目标聊天索引: ${chatIndex}`) + + // 使用 stream-json 流式解析目标聊天 + // 定位到 chats.list[chatIndex] 对象 + const chatPathFilter = `chats.list.${chatIndex}` + + const chatData = await new Promise((resolve, reject) => { + const readStream = fs.createReadStream(filePath, { encoding: 'utf-8' }) + + readStream.on('data', (chunk: string | Buffer) => { + bytesRead += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length + }) + + const pipeline = chain([ + readStream, + parser(), + pick({ filter: new RegExp(`^${chatPathFilter.replace('.', '\\.')}$`) }), + streamValues(), + ]) + + let found = false + + pipeline.on('data', ({ value }: { value: TelegramChat }) => { + found = true + resolve(value) + }) + + pipeline.on('end', () => { + if (!found) resolve(null) + }) + + pipeline.on('error', reject) + }) + + if (!chatData) { + onLog?.('error', `未找到索引 ${chatIndex} 对应的聊天`) + yield { type: 'error', data: new Error(`未找到索引 ${chatIndex} 对应的聊天`) } + return + } + + onLog?.('info', `找到聊天: "${chatData.name}", 类型: ${chatData.type}, 消息数: ${chatData.messages?.length || 0}`) + + // 确定聊天类型 + const chatType = mapChatType(chatData.type) + + // 发送 meta + const meta: ParsedMeta = { + name: chatData.name || `Telegram Chat ${chatData.id}`, + platform: KNOWN_PLATFORMS.TELEGRAM, + type: chatType, + groupId: chatType === ChatType.GROUP ? String(chatData.id) : undefined, + } + yield { type: 'meta', data: meta } + + // 收集成员和消息 + const memberMap = new Map() + const messageBatch: ParsedMessage[] = [] + const messages = chatData.messages || [] + + for (const msg of messages) { + // 提取发送者信息 + let senderPlatformId: string + let senderName: string + + if (msg.type === 'service') { + // Service 消息使用 actor 信息 + senderPlatformId = extractPlatformId(msg.actor_id) + senderName = msg.actor || '系统' + } else { + senderPlatformId = extractPlatformId(msg.from_id) + senderName = msg.from || senderPlatformId + } + + // 更新成员 + if (!memberMap.has(senderPlatformId) && senderPlatformId !== 'unknown') { + memberMap.set(senderPlatformId, { + platformId: senderPlatformId, + accountName: senderName, + }) + } + + // 解析时间戳 + const timestamp = parseInt(msg.date_unixtime, 10) + if (isNaN(timestamp)) continue + + // 构建消息 + const parsedMsg: ParsedMessage = { + platformMessageId: String(msg.id), + senderPlatformId, + senderAccountName: senderName, + timestamp, + type: detectMessageType(msg), + content: buildContent(msg), + replyToMessageId: msg.reply_to_message_id ? String(msg.reply_to_message_id) : undefined, + } + + messageBatch.push(parsedMsg) + messagesProcessed++ + + // 分批 yield 消息 + if (messageBatch.length >= batchSize) { + yield { type: 'messages', data: [...messageBatch] } + messageBatch.length = 0 + + const progress = createProgress( + 'parsing', + bytesRead, + totalBytes, + messagesProcessed, + `已处理 ${messagesProcessed} 条消息...` + ) + yield { type: 'progress', data: progress } + onProgress?.(progress) + } + } + + // 发送成员 + yield { type: 'members', data: Array.from(memberMap.values()) } + + // 发送剩余消息 + if (messageBatch.length > 0) { + yield { type: 'messages', data: messageBatch } + } + + // 完成 + const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '') + yield { type: 'progress', data: doneProgress } + onProgress?.(doneProgress) + + onLog?.('info', `解析完成: ${messagesProcessed} 条消息, ${memberMap.size} 个成员, 类型: ${chatType}`) + + yield { + type: 'done', + data: { messageCount: messagesProcessed, memberCount: memberMap.size }, + } +} + +// ==================== 导出 ==================== + +export const parser_: Parser = { + feature, + parse: parseTelegram, +} + +const module_: FormatModule = { + feature, + parser: parser_, + scanChats, +} + +export default module_ diff --git a/electron/main/parser/formats/tyrrrz-discord-exporter.ts b/packages/parser/src/formats/tyrrrz-discord-exporter.ts similarity index 95% rename from electron/main/parser/formats/tyrrrz-discord-exporter.ts rename to packages/parser/src/formats/tyrrrz-discord-exporter.ts index a0c537e0b..a0fee082b 100644 --- a/electron/main/parser/formats/tyrrrz-discord-exporter.ts +++ b/packages/parser/src/formats/tyrrrz-discord-exporter.ts @@ -9,11 +9,11 @@ */ import * as fs from 'fs' -import { parser } from 'stream-json' -import { pick } from 'stream-json/filters/Pick' -import { streamValues } from 'stream-json/streamers/StreamValues' -import { chain } from 'stream-chain' -import { KNOWN_PLATFORMS, ChatType, MessageType, type MemberRole } from '../../../../src/types/base' +import streamJson from 'stream-json' +import pickModule from 'stream-json/filters/Pick.js' +import streamValuesModule from 'stream-json/streamers/StreamValues.js' +import streamChain from 'stream-chain' +import { KNOWN_PLATFORMS, ChatType, MessageType, type MemberRole } from '@openchatlab/shared-types' import type { FormatFeature, FormatModule, @@ -26,6 +26,11 @@ import type { } from '../types' import { getFileSize, createProgress, readFileHeadBytes } from '../utils' +const { parser } = streamJson +const { pick } = pickModule +const { streamValues } = streamValuesModule +const { chain } = streamChain + // ==================== Discord 数据结构定义 ==================== interface DiscordGuild { @@ -309,7 +314,7 @@ async function* parseDiscordExporter(options: ParseOptions): AsyncGenerator() - let messageBatch: ParsedMessage[] = [] + const messageBatch: ParsedMessage[] = [] // 流式解析消息 await new Promise((resolve, reject) => { @@ -333,7 +338,7 @@ async function* parseDiscordExporter(options: ParseOptions): AsyncGenerator + text_entities?: Array<{ type: string; text: string }> + reply_to_message_id?: number + forwarded_from?: string | null + forwarded_from_id?: string + photo?: string + file?: string + file_name?: string + media_type?: string + sticker_emoji?: string + mime_type?: string + members?: string[] +} + +/** Telegram 聊天结构 */ +export interface TelegramChat { + name: string + type: string + id: number + messages: TelegramMessage[] +} + +// ==================== 共享辅助函数 ==================== + +/** + * 将 Telegram 聊天类型映射到 ChatLab 聊天类型 + */ +export function mapChatType(telegramType: string): ChatType { + switch (telegramType) { + case 'personal_chat': + case 'bot_chat': + case 'saved_messages': + return ChatType.PRIVATE + case 'private_group': + case 'public_group': + case 'private_supergroup': + case 'public_supergroup': + case 'private_channel': + case 'public_channel': + return ChatType.GROUP + default: + return ChatType.GROUP + } +} + +/** + * 提取 Telegram text 字段为纯文本 + * text 可以是字符串或 mixed 数组 + */ +export function extractText(text: string | Array): string { + if (typeof text === 'string') return text + if (!Array.isArray(text)) return '' + + return text + .map((part) => { + if (typeof part === 'string') return part + if (part && typeof part === 'object' && 'text' in part) return part.text + return '' + }) + .join('') +} + +/** + * 从 from_id 提取平台 ID + * 格式:user123456 → 123456 + */ +export function extractPlatformId(fromId: string | undefined): string { + if (!fromId) return 'unknown' + // 移除 "user" / "channel" 等前缀,保留数字部分 + return fromId.replace(/^(user|channel)/, '') +} + +/** + * 检测消息类型 + */ +export function detectMessageType(msg: TelegramMessage): MessageType { + // Service 消息 → 系统消息 + if (msg.type === 'service') return MessageType.SYSTEM + + // 贴纸 + if (msg.media_type === 'sticker') return MessageType.IMAGE + // 图片 + if (msg.photo) return MessageType.IMAGE + // 动画 (GIF) + if (msg.media_type === 'animation') return MessageType.IMAGE + // 视频 + if (msg.media_type === 'video_file') return MessageType.VIDEO + // 语音 + if (msg.media_type === 'voice_message') return MessageType.VOICE + // 视频留言 + if (msg.media_type === 'video_message') return MessageType.VIDEO + // 文件 + if (msg.file && !msg.media_type) return MessageType.FILE + + // 默认文本 + return MessageType.TEXT +} + +/** + * 构建消息内容 + */ +export function buildContent(msg: TelegramMessage): string | null { + const text = extractText(msg.text) + + // Service 消息 + if (msg.type === 'service') { + const action = msg.action || '' + const members = msg.members?.join(', ') || '' + if (members) return `[${action}] ${members}` + // action 为空时回退到文本,避免常量表达式触发 lint 规则。 + if (action) return `[${action}]` + return text || null + } + + // 贴纸:使用 emoji 表示 + if (msg.media_type === 'sticker' && msg.sticker_emoji) { + return text ? `[sticker ${msg.sticker_emoji}] ${text}` : `[sticker ${msg.sticker_emoji}]` + } + + // 媒体消息带文字说明 + if (msg.photo || msg.file || msg.media_type) { + const mediaLabel = msg.media_type || (msg.photo ? 'photo' : 'file') + if (text) return `[${mediaLabel}] ${text}` + return `[${mediaLabel}]` + } + + return text || null +} diff --git a/electron/main/parser/formats/ycccccccy-echotrace-preprocessor.ts b/packages/parser/src/formats/weflow-preprocessor.ts similarity index 87% rename from electron/main/parser/formats/ycccccccy-echotrace-preprocessor.ts rename to packages/parser/src/formats/weflow-preprocessor.ts index 2172a7cf0..5c371fb58 100644 --- a/electron/main/parser/formats/ycccccccy-echotrace-preprocessor.ts +++ b/packages/parser/src/formats/weflow-preprocessor.ts @@ -1,8 +1,8 @@ /** - * echotrace 格式预处理器 + * WeFlow 格式预处理器 * 用于大文件预处理,移除冗余字段 * - * 当前为预留实现,echotrace 格式的字段结构较为简洁, + * 当前为预留实现,WeFlow 格式的字段结构较为简洁, * 暂不需要复杂的预处理逻辑。 * * 如果未来发现性能问题,可在此添加: @@ -14,10 +14,10 @@ import type { Preprocessor, ParseProgress } from '../types' /** - * echotrace 预处理器 + * WeFlow 预处理器 * 当前为预留实现,返回不需要预处理 */ -export const echotracePreprocessor: Preprocessor = { +export const weflowPreprocessor: Preprocessor = { /** * 判断是否需要预处理 * 当前策略:暂不需要预处理 @@ -54,5 +54,4 @@ export const echotracePreprocessor: Preprocessor = { }, } -export default echotracePreprocessor - +export default weflowPreprocessor diff --git a/electron/main/parser/formats/ycccccccy-echotrace.ts b/packages/parser/src/formats/weflow.ts similarity index 68% rename from electron/main/parser/formats/ycccccccy-echotrace.ts rename to packages/parser/src/formats/weflow.ts index f81414fa3..ef863f0db 100644 --- a/electron/main/parser/formats/ycccccccy-echotrace.ts +++ b/packages/parser/src/formats/weflow.ts @@ -1,12 +1,14 @@ /** - * ycccccccy/echotrace 导出格式解析器 - * 适配项目: https://github.com/ycccccccy/echotrace + * WeFlow 导出格式解析器 + * 适配项目: WeFlow 聊天记录导出工具 * * 特征: - * - 顶层包含 session 和 messages 字段 + * - 顶层包含 weflow、session 和 messages 字段 + * - weflow 对象包含版本信息和导出时间 * - session.wxid: ID(群聊以 @chatroom 结尾) * - session.type: "群聊" 或 "私聊" - * - messages[].type: 中文消息类型字符串 + * - session.avatar: 群/用户头像(base64 Data URL) + * - messages[].isSend: 1=发送者本人, 0=接收, null=系统 * - messages[].senderUsername: 发送者ID * - messages[].senderDisplayName: 发送者显示名 * @@ -15,11 +17,11 @@ import * as fs from 'fs' import * as path from 'path' -import { parser } from 'stream-json' -import { pick } from 'stream-json/filters/Pick' -import { streamValues } from 'stream-json/streamers/StreamValues' -import { chain } from 'stream-chain' -import { KNOWN_PLATFORMS, ChatType, MessageType } from '../../../../src/types/base' +import streamJson from 'stream-json' +import pickModule from 'stream-json/filters/Pick.js' +import streamValuesModule from 'stream-json/streamers/StreamValues.js' +import streamChain from 'stream-chain' +import { KNOWN_PLATFORMS, ChatType, MessageType } from '@openchatlab/shared-types' import type { FormatFeature, FormatModule, @@ -32,6 +34,11 @@ import type { } from '../types' import { getFileSize, createProgress, readFileHeadBytes } from '../utils' +const { parser } = streamJson +const { pick } = pickModule +const { streamValues } = streamValuesModule +const { chain } = streamChain + // ==================== 辅助函数 ==================== /** @@ -46,21 +53,23 @@ function extractNameFromFilePath(filePath: string): string { // ==================== 特征定义 ==================== export const feature: FormatFeature = { - id: 'ycccccccy-echotrace', - name: 'ycccccccy/echotrace 导出', + id: 'weflow', + name: 'WeFlow 导出', platform: KNOWN_PLATFORMS.WECHAT, priority: 15, extensions: ['.json'], signatures: { - // 检测顶层字段和特征 - head: [/"session"\s*:/, /"senderUsername"\s*:/, /"senderDisplayName"\s*:/], - requiredFields: ['session', 'messages'], + // weflow 对象是唯一识别特征 + // 注意:session.avatar 包含 base64 图片,可能很大,所以 messages 字段可能不在 8KB 文件头中 + // 只检测 weflow 和 session(它们在文件开头) + head: [/"weflow"\s*:\s*\{/], + requiredFields: ['weflow', 'session'], }, } -// ==================== 消息结构 ==================== +// ==================== 数据结构 ==================== -interface EchotraceSession { +interface WeFlowSession { wxid: string nickname: string remark: string @@ -68,9 +77,10 @@ interface EchotraceSession { type: '群聊' | '私聊' lastTimestamp: number messageCount: number + avatar?: string // 群/用户头像(base64 Data URL) } -interface EchotraceMessage { +interface WeFlowMessage { localId: number createTime: number // Unix 时间戳(秒) formattedTime: string @@ -84,17 +94,10 @@ interface EchotraceMessage { source: string } -// ==================== 头像信息结构 ==================== - -interface EchotraceAvatarInfo { - displayName: string - base64: string // 原始 base64,不包含 Data URL 前缀 -} - // ==================== 消息类型映射 ==================== /** - * 将 echotrace 中文消息类型转换为标准 MessageType + * 将 WeFlow 中文消息类型转换为标准 MessageType */ function convertMessageType(typeStr: string): MessageType { switch (typeStr) { @@ -136,6 +139,10 @@ function convertMessageType(typeStr: string): MessageType { } } +// ==================== 头像信息结构 ==================== +// WeFlow 的 avatars 对象直接存储 base64 Data URL 字符串 +// 格式:{ "wxid": "data:image/jpeg;base64,..." } + // ==================== 成员信息追踪 ==================== interface MemberInfo { @@ -146,7 +153,7 @@ interface MemberInfo { // ==================== 解析器实现 ==================== -async function* parseEchotrace(options: ParseOptions): AsyncGenerator { +async function* parseWeFlow(options: ParseOptions): AsyncGenerator { const { filePath, batchSize = 5000, onProgress, onLog } = options const totalBytes = getFileSize(filePath) @@ -159,18 +166,82 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator((resolve) => { + const sessionStream = fs.createReadStream(filePath, { encoding: 'utf-8' }) + + let sessionContent = '' + let inSession = false + let braceDepth = 0 + let inString = false + let escape = false + + sessionStream.on('data', (chunk: string | Buffer) => { + const str = typeof chunk === 'string' ? chunk : chunk.toString() + + for (let i = 0; i < str.length; i++) { + const char = str[i] + + if (!inSession) { + // 查找 "session": 的位置 + const searchStr = '"session":' + if (str.slice(i, i + searchStr.length) === searchStr) { + inSession = true + i += searchStr.length - 1 + continue + } + } else { + sessionContent += char + + if (escape) { + escape = false + continue + } + + if (char === '\\' && inString) { + escape = true + continue + } + + if (char === '"') { + inString = !inString + continue + } + + if (!inString) { + if (char === '{') braceDepth++ + if (char === '}') { + braceDepth-- + if (braceDepth === 0) { + sessionStream.destroy() + return + } + } + } + } + } + }) + + sessionStream.on('close', () => { + if (sessionContent) { + try { + session = JSON.parse(sessionContent) as WeFlowSession + } catch { + // 解析失败 + } + } + resolve() + }) + + sessionStream.on('error', () => resolve()) + }) } catch { // 使用默认值 } @@ -179,22 +250,24 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator - for (const [wxid, avatarInfo] of Object.entries(avatarsObj)) { - if (avatarInfo && typeof avatarInfo === 'object' && avatarInfo.base64) { - // 添加 Data URL 前缀 - avatarsMap.set(wxid, `data:image/jpeg;base64,${avatarInfo.base64}`) + // WeFlow 的 avatars 值直接是 base64 Data URL 字符串 + const avatarsObj = JSON.parse(avatarsContent) as Record + for (const [wxid, avatarDataUrl] of Object.entries(avatarsObj)) { + if (avatarDataUrl && typeof avatarDataUrl === 'string') { + avatarsMap.set(wxid, avatarDataUrl) } } } @@ -270,7 +343,7 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator((resolve) => { @@ -334,10 +407,11 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator { if (avatarsContent) { try { - const avatarsObj = JSON.parse(avatarsContent) as Record - for (const [wxid, avatarInfo] of Object.entries(avatarsObj)) { - if (avatarInfo && typeof avatarInfo === 'object' && avatarInfo.base64) { - avatarsMap.set(wxid, `data:image/jpeg;base64,${avatarInfo.base64}`) + // WeFlow 的 avatars 值直接是 base64 Data URL 字符串 + const avatarsObj = JSON.parse(avatarsContent) as Record + for (const [wxid, avatarDataUrl] of Object.entries(avatarsObj)) { + if (avatarDataUrl && typeof avatarDataUrl === 'string') { + avatarsMap.set(wxid, avatarDataUrl) } } } catch { @@ -354,8 +428,8 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator { + scanPipeline.on('data', ({ value }: { value: WeFlowMessage }) => { if (value.isSend === 1 && value.senderUsername && !value.senderUsername.endsWith('@chatroom')) { ownerId = value.senderUsername scanStream.destroy() // 找到后立即停止扫描 @@ -404,7 +478,7 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator { + const processMessage = (msg: WeFlowMessage): ParsedMessage | null => { // 验证必要字段 if (!msg.senderUsername || msg.createTime === undefined) { return null @@ -420,7 +494,7 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator { + pipeline.on('data', ({ value }: { value: WeFlowMessage }) => { const parsed = processMessage(value) if (parsed) { batchCollector.push(parsed) @@ -530,22 +606,25 @@ async function* parseEchotrace(options: ParseOptions): AsyncGenerator / 圖片已略去 等 + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as readline from 'readline' +import { KNOWN_PLATFORMS, ChatType, MessageType } from '@openchatlab/shared-types' +import type { + FormatFeature, + FormatModule, + Parser, + ParseOptions, + ParseEvent, + ParsedMeta, + ParsedMember, + ParsedMessage, +} from '../types' +import { getFileSize, createProgress } from '../utils' + +// ==================== 辅助函数 ==================== + +/** + * 从文件名提取聊天名称 + * 例如:与开心每一天的 WhatsApp 聊天.txt → 开心每一天 + * 例如:与gaoberry37的 WhatsApp 聊天.txt → gaoberry37 + */ +function extractNameFromFilePath(filePath: string): string { + const basename = path.basename(filePath) + // 简体中文:与xxx的 WhatsApp 聊天.txt + const matchZhCn = basename.match(/^与(.+?)的\s*WhatsApp\s*聊天\.txt$/i) + if (matchZhCn) return matchZhCn[1].trim() + // 繁体中文:與xxx的WhatsApp對話.txt + const matchZhTw = basename.match(/^與(.+?)的\s*WhatsApp\s*對話\.txt$/i) + if (matchZhTw) return matchZhTw[1].trim() + // 兜底:移除扩展名 + return basename.replace(/\.txt$/i, '') || '未知聊天' +} + +// ==================== 特征定义 ==================== + +export const feature: FormatFeature = { + id: 'whatsapp-native-txt', + name: 'WhatsApp 官方导出 (TXT)', + platform: KNOWN_PLATFORMS.WHATSAPP, + priority: 25, + extensions: ['.txt'], + signatures: { + // WhatsApp 导出文件的特征(中文/英文) + // 注意:仅保留 WhatsApp 独有的特征,避免误匹配其他 TXT 格式(如 LINE) + head: [ + /消息和通话已进行端到端加密/, // 简体中文加密提示(WhatsApp 独有) + /訊息與通話已受端對端加密保護/, // 繁体中文加密提示(WhatsApp 独有) + /Messages and calls are end-to-end encrypted/i, // 英文加密提示(WhatsApp 独有) + /你发送给自己的消息已进行端到端加密/, // 简体中文自己对话提示(WhatsApp 独有) + /\d{1,4}\/\d{1,2}\/\d{2,4},?\s+\d{1,2}:\d{2} - /, // 消息行格式特征(无方括号,含 " - " 分隔符,WhatsApp 独有) + /\[\d{1,4}\/\d{1,2}\/\d{2,4}[\s,].*\d{1,2}:\d{2}:\d{2}.*\] /, // 消息行格式特征(方括号 + 含日期和时间的时间戳,兼容各地区变体) + ], + // 文件名特征:与xxx的 WhatsApp 聊天.txt + filename: [/^与.+的\s*WhatsApp\s*聊天\.txt$/i, /^與.+的\s*WhatsApp\s*對話\.txt$/i, /WhatsApp/i], + }, +} + +// ==================== 辅助函数:清理不可见字符 ==================== + +/** + * 清理行首/行尾的不可见 Unicode 字符 + * WhatsApp 导出文件中可能包含 BOM、Left-to-Right Mark (U+200E) 等 + */ +function cleanLine(line: string): string { + // 移除常见的不可见字符:BOM、LTR Mark、RTL Mark、零宽字符等 + return line.replace(/^(?:\uFEFF|\u200E|\u200F|\u200B|\u200C|\u200D|\u2060)+/, '').trim() +} + +// ==================== 消息头正则 ==================== + +// 格式1(无方括号):日期/日期/日期[,] 时:分 - 内容 +// 兼容各地区变体:YYYY/MM/DD HH:MM | DD/MM/YYYY, HH:MM | M/D/YY, HH:MM +const MESSAGE_LINE_REGEX_V1 = /^(\d{1,4}\/\d{1,2}\/\d{2,4},?\s+\d{1,2}:\d{2}) - (.+)$/ + +// 格式2(方括号格式):宽松捕获 [时间戳] 后的内容 +// 时间戳的地区变体由 parseFlexibleV2Timestamp() 弹性解析器处理 +// 已知变体包括但不限于:中文 上午/下午、英文 AM/PM、韩文 오전/오후、各种日期顺序 +const MESSAGE_LINE_REGEX_V2 = /^\[([^\]]+)\] (.+)$/ + +// 从消息内容中分离昵称和实际内容 +// 格式:昵称: 内容(冒号后可能是空格、U+200E LTR Mark 或两者组合) +const SENDER_CONTENT_REGEX = /^(.+?):[\s\u200E]+(.*)$/ + +// ==================== 系统消息识别 ==================== + +const SYSTEM_MESSAGE_PATTERNS = [ + // 简体中文系统消息 + /消息和通话已进行端到端加密/, + /创建了此群组/, + /加入群组/, + /添加了/, + /退出了群组/, + /移除了/, + /更改了本群组/, + /已将此群组的设置更改为/, + /这条消息已删除/, + /限时消息功能/, + /正在等待此消息/, + // 繁体中文系统消息 + /訊息與通話已受端對端加密保護/, + /建立了此群組/, + /加入了群組/, + /已新增/, + /已離開群組/, + /已移除/, + /已變更本群組/, + /此訊息已刪除/, + /限時訊息/, + // 英文系统消息 + /Messages and calls are end-to-end encrypted/i, + /created this group/i, + /joined the group/i, + /added/i, + /left the group/i, + /removed/i, + /changed this group/i, + /This message was deleted/i, + /disappearing messages/i, +] + +function isSystemMessage(content: string): boolean { + return SYSTEM_MESSAGE_PATTERNS.some((pattern) => pattern.test(content)) +} + +// ==================== 消息类型判断 ==================== + +function detectMessageType(content: string): MessageType { + const trimmed = content.trim() + + // 媒体消息(简体/繁体多种变体) + if ( + trimmed === '<省略影音内容>' || + trimmed === '<已省略多媒體檔案>' || + trimmed === '圖片已略去' || + trimmed === '影片已略去' || + trimmed === '音訊已略去' || + trimmed === 'image omitted' || + trimmed === 'video omitted' || + trimmed === 'audio omitted' + ) + return MessageType.IMAGE + if (trimmed.includes('<已附加:') || trimmed.includes('<附件:') || trimmed.includes('<已附加:')) + return MessageType.FILE + + // 贴图/贴纸(繁体中文导出标记) + if (trimmed === '貼圖已忽略' || trimmed === '貼圖已略去') return MessageType.EMOJI + + // 删除消息(简体/繁体多种变体) + if (trimmed === '这条消息已删除' || trimmed.startsWith('此訊息已刪除') || trimmed.startsWith('你已刪除此訊息')) + return MessageType.RECALL + + // 系统消息 + if (isSystemMessage(trimmed)) return MessageType.SYSTEM + + return MessageType.TEXT +} + +// ==================== 时间解析 ==================== + +// 多语言 AM/PM 标记映射(值为 true 表示 PM,false 表示 AM) +const AMPM_MARKERS: [RegExp, boolean][] = [ + [/\bPM\b/i, true], + [/\bP\.M\.\b/i, true], + [/下午/, true], + [/午後/, true], + [/오후/, true], + [/\bAM\b/i, false], + [/\bA\.M\.\b/i, false], + [/上午/, false], + [/午前/, false], + [/오전/, false], +] + +/** + * 弹性解析 V2(方括号)时间戳 + * 自动处理各地区变体:日期顺序、AM/PM 多语言标记、特殊空格等 + * @returns 秒级时间戳,解析失败返回 null + */ +function parseFlexibleV2Timestamp(raw: string): number | null { + // 1. 规范化特殊空格和不可见字符 + let str = raw.replace(/(?:\u2009|\u202F|\uFEFF|\u200E|\u200F|\u200B|\u200C|\u200D|\u2060)/g, ' ').trim() + + // 2. 提取并移除 AM/PM 标记(任何语言) + let isPM: boolean | null = null + for (const [pattern, pm] of AMPM_MARKERS) { + if (pattern.test(str)) { + isPM = pm + str = str.replace(pattern, '').trim() + break + } + } + + // 3. 移除逗号,规范化连续空格 + str = str.replace(/,/g, ' ').replace(/\s+/g, ' ').trim() + + // 4. 提取日期部分(含 /)和时间部分(含 :) + const match = str.match(/^(\d{1,4}\/\d{1,2}\/\d{2,4})\s+(\d{1,2}:\d{2}(?::\d{2})?)$/) + if (!match) return null + + const [, datePart, timePart] = match + const dateParts = datePart.split('/').map((s) => parseInt(s, 10)) + const timeParts = timePart.split(':').map((s) => parseInt(s, 10)) + + // 5. 推断日期顺序 + let year: number, month: number, day: number + if (dateParts[0] > 31) { + // 第一段 > 31 → 一定是年份 → YYYY/MM/DD + year = dateParts[0] + month = dateParts[1] + day = dateParts[2] + } else if (dateParts[2] > 31) { + // 第三段 > 31 → 一定是年份 → DD/MM/YYYY + day = dateParts[0] + month = dateParts[1] + year = dateParts[2] + } else { + // 全部 ≤ 31 → M/D/YY(2 位年份,00-99 → 2000-2099) + month = dateParts[0] + day = dateParts[1] + year = 2000 + dateParts[2] + } + + // 6. 解析时间 + let hour = timeParts[0] + const minute = timeParts[1] + const second = timeParts[2] ?? 0 + + if (isPM === true && hour !== 12) hour += 12 + if (isPM === false && hour === 12) hour = 0 + + // 7. 构造日期并校验 + const date = new Date(year, month - 1, day, hour, minute, second) + const ts = Math.floor(date.getTime() / 1000) + return isNaN(ts) ? null : ts +} + +/** + * 解析 V1(无方括号)时间格式 + * 支持:YYYY/M/D H:MM | DD/MM/YYYY, HH:MM | M/D/YY, HH:MM + */ +function parseV1Timestamp(timeStr: string): number { + // 移除逗号,规范化空格 + const str = timeStr.replace(/,/g, '').replace(/\s+/g, ' ').trim() + const match = str.match(/^(\d{1,4})\/(\d{1,2})\/(\d{2,4})\s+(\d{1,2}):(\d{2})$/) + if (!match) { + const normalized = timeStr.replace(/,/g, '').replace(/\//g, '-').replace(/\s+/, 'T') + ':00' + return Math.floor(new Date(normalized).getTime() / 1000) + } + + const parts = [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)] + const hour = parseInt(match[4], 10) + const minute = parseInt(match[5], 10) + + let year: number, month: number, day: number + if (parts[0] > 31) { + year = parts[0] + month = parts[1] + day = parts[2] + } else if (parts[2] > 31) { + day = parts[0] + month = parts[1] + year = parts[2] + } else { + month = parts[0] + day = parts[1] + year = 2000 + parts[2] + } + + const date = new Date(year, month - 1, day, hour, minute, 0) + const ts = Math.floor(date.getTime() / 1000) + return isNaN(ts) ? 0 : ts +} + +// ==================== 成员信息 ==================== + +interface MemberInfo { + platformId: string + nickname: string +} + +// ==================== 解析器实现 ==================== + +async function* parseWhatsApp(options: ParseOptions): AsyncGenerator { + const { filePath, batchSize = 5000, onProgress, onLog } = options + + const totalBytes = getFileSize(filePath) + let bytesRead = 0 + let messagesProcessed = 0 + let skippedLines = 0 + + // 发送初始进度 + const initialProgress = createProgress('parsing', 0, totalBytes, 0, '') + yield { type: 'progress', data: initialProgress } + onProgress?.(initialProgress) + + // 记录解析开始 + onLog?.('info', `开始解析 WhatsApp TXT 文件,大小: ${(totalBytes / 1024 / 1024).toFixed(2)} MB`) + + // 收集数据 + const chatName = extractNameFromFilePath(filePath) + const memberMap = new Map() + const messages: ParsedMessage[] = [] + + // 当前正在解析的消息(可能跨多行) + let currentMessage: { + timestamp: number + sender: string | null // null 表示系统消息 + contentLines: string[] + } | null = null + + // 保存当前消息 + const saveCurrentMessage = () => { + if (currentMessage) { + const content = currentMessage.contentLines.join('\n').trim() + const type = detectMessageType(content) + + // 系统消息使用特殊 ID 和统一名称 + const senderPlatformId = currentMessage.sender || 'system' + const senderName = currentMessage.sender || '系统消息' + + messages.push({ + senderPlatformId, + senderAccountName: senderName, + timestamp: currentMessage.timestamp, + type, + content: content || null, + }) + + // 更新成员信息(跳过系统消息) + if (currentMessage.sender) { + memberMap.set(senderPlatformId, { + platformId: senderPlatformId, + nickname: senderName, + }) + } + + messagesProcessed++ + } + } + + // 逐行读取文件 + const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }) + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }) + + fileStream.on('data', (chunk: string | Buffer) => { + bytesRead += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length + }) + + for await (const line of rl) { + // 清理行首不可见字符 + const cleanedLine = cleanLine(line) + + // 尝试匹配消息行:优先 V1(严格),再 V2(宽松 + 弹性时间解析) + const v1Match = cleanedLine.match(MESSAGE_LINE_REGEX_V1) + if (v1Match) { + saveCurrentMessage() + const restContent = v1Match[2] + const senderMatch = restContent.match(SENDER_CONTENT_REGEX) + if (senderMatch && !isSystemMessage(restContent)) { + currentMessage = { + timestamp: parseV1Timestamp(v1Match[1]), + sender: senderMatch[1].trim(), + contentLines: [senderMatch[2]], + } + } else { + currentMessage = { + timestamp: parseV1Timestamp(v1Match[1]), + sender: null, + contentLines: [restContent], + } + } + if (messagesProcessed % 500 === 0) { + onProgress?.( + createProgress('parsing', bytesRead, totalBytes, messagesProcessed, `已处理 ${messagesProcessed} 条消息...`) + ) + } + continue + } + + const v2Match = cleanedLine.match(MESSAGE_LINE_REGEX_V2) + if (v2Match) { + const timestamp = parseFlexibleV2Timestamp(v2Match[1]) + if (timestamp !== null) { + // 弹性解析成功 → 新消息 + saveCurrentMessage() + const restContent = v2Match[2] + const senderMatch = restContent.match(SENDER_CONTENT_REGEX) + if (senderMatch && !isSystemMessage(restContent)) { + currentMessage = { + timestamp, + sender: senderMatch[1].trim(), + contentLines: [senderMatch[2]], + } + } else { + currentMessage = { + timestamp, + sender: null, + contentLines: [restContent], + } + } + if (messagesProcessed % 500 === 0) { + onProgress?.( + createProgress('parsing', bytesRead, totalBytes, messagesProcessed, `已处理 ${messagesProcessed} 条消息...`) + ) + } + continue + } + // 弹性解析失败 → 方括号内容不是有效时间戳,降级为续行 + } + + // 非消息行:可能是多行消息的延续 + if (currentMessage && cleanedLine) { + currentMessage.contentLines.push(cleanedLine) + } else if (cleanedLine) { + // 无法解析的非空行 + skippedLines++ + } + } + + // 保存最后一条消息 + saveCurrentMessage() + + // 确定聊天类型:根据参与者数量判断 + // - 排除系统成员后,2 人或更少:私聊 + // - 超过 2 人:群聊 + const hasSystemMember = memberMap.has('system') + const realMemberCount = hasSystemMember ? memberMap.size - 1 : memberMap.size + + const chatType = realMemberCount > 2 ? ChatType.GROUP : ChatType.PRIVATE + + // 发送 meta + const meta: ParsedMeta = { + name: chatName, + platform: KNOWN_PLATFORMS.WHATSAPP, + type: chatType, + } + yield { type: 'meta', data: meta } + + // 发送成员 + const members: ParsedMember[] = Array.from(memberMap.values()).map((m) => ({ + platformId: m.platformId, + accountName: m.nickname, + })) + yield { type: 'members', data: members } + + // 分批发送消息 + for (let i = 0; i < messages.length; i += batchSize) { + const batch = messages.slice(i, i + batchSize) + yield { type: 'messages', data: batch } + } + + // 完成 + const doneProgress = createProgress('done', totalBytes, totalBytes, messagesProcessed, '') + yield { type: 'progress', data: doneProgress } + onProgress?.(doneProgress) + + // 统计消息类型 + const typeCounts = new Map() + for (const msg of messages) { + typeCounts.set(msg.type, (typeCounts.get(msg.type) || 0) + 1) + } + + // 记录解析摘要 + onLog?.('info', `解析完成: ${messagesProcessed} 条消息, ${memberMap.size} 个成员, 类型: ${chatType}`) + onLog?.( + 'info', + `消息类型统计: ${Array.from(typeCounts.entries()) + .map(([type, count]) => `${type}=${count}`) + .join(', ')}` + ) + if (skippedLines > 0) { + onLog?.('info', `跳过 ${skippedLines} 行无法解析的内容`) + } + + yield { + type: 'done', + data: { messageCount: messagesProcessed, memberCount: memberMap.size }, + } +} + +// ==================== 导出解析器 ==================== + +export const parser_: Parser = { + feature, + parse: parseWhatsApp, +} + +// ==================== 导出格式模块 ==================== + +const module_: FormatModule = { + feature, + parser: parser_, + // TXT 格式不需要预处理器 +} + +export default module_ diff --git a/packages/parser/src/formats/ycccccccy-echotrace.ts b/packages/parser/src/formats/ycccccccy-echotrace.ts new file mode 100644 index 000000000..23da775e1 --- /dev/null +++ b/packages/parser/src/formats/ycccccccy-echotrace.ts @@ -0,0 +1,55 @@ +/** + * ycccccccy/echotrace 导出格式解析器 + * 适配项目: https://github.com/ycccccccy/echotrace + * + * 特征: + * - 顶层包含 session 和 messages 字段(无 weflow 对象) + * - session.wxid: ID(群聊以 @chatroom 结尾) + * - session.type: "群聊" 或 "私聊" + * - messages[].senderUsername: 发送者ID + * - messages[].senderDisplayName: 发送者显示名 + * + * 注意:此格式与 WeFlow 格式共享解析逻辑,区别在于签名检测 + */ + +import { KNOWN_PLATFORMS } from '@openchatlab/shared-types' +import type { FormatFeature, FormatModule, Parser } from '../types' +import { parseWeFlow } from './weflow' +import { weflowPreprocessor } from './weflow-preprocessor' + +// ==================== 特征定义 ==================== + +export const feature: FormatFeature = { + id: 'ycccccccy-echotrace', + name: 'ycccccccy/echotrace 导出', + platform: KNOWN_PLATFORMS.WECHAT, + priority: 16, // 比 WeFlow (15) 略低,优先检测 WeFlow + extensions: ['.json'], + signatures: { + // echotrace 格式没有 weflow 对象,但有 session 和 senderUsername/senderDisplayName + head: [/"session"\s*:/, /"senderUsername"\s*:/, /"senderDisplayName"\s*:/], + requiredFields: ['session', 'messages'], + }, +} + +// ==================== 导出解析器 ==================== + +// 复用 WeFlow 的解析逻辑(两种格式的数据结构相同) +export const parser_: Parser = { + feature, + parse: parseWeFlow, +} + +// ==================== 预处理器(复用 WeFlow) ==================== + +export const preprocessor = weflowPreprocessor + +// ==================== 导出格式模块 ==================== + +const module_: FormatModule = { + feature, + parser: parser_, + preprocessor: weflowPreprocessor, +} + +export default module_ diff --git a/electron/main/parser/index.ts b/packages/parser/src/index.ts similarity index 60% rename from electron/main/parser/index.ts rename to packages/parser/src/index.ts index 0b1a59390..2bce887a2 100644 --- a/electron/main/parser/index.ts +++ b/packages/parser/src/index.ts @@ -3,6 +3,8 @@ * 三层架构:标准层、嗅探层、解析层 */ +import * as fs from 'fs' +import * as path from 'path' import { FormatSniffer, createSniffer } from './sniffer' import { formats } from './formats' import { getFileSize } from './utils' @@ -11,12 +13,13 @@ import type { ParseEvent, ParseResult, ParseProgress, + LogLevel, FormatFeature, Parser, ParsedMeta, ParsedMember, ParsedMessage, - FormatDiagnosis, + MultiChatInfo, } from './types' // ==================== 全局嗅探器实例 ==================== @@ -36,13 +39,13 @@ export function detectFormat(filePath: string): FormatFeature | null { } /** - * 诊断文件格式 - * 当检测失败时,返回详细的诊断信息,帮助用户了解问题所在 + * 检测所有匹配的文件格式(按优先级排序) + * 用于 fallback 机制:当第一个格式解析失败时尝试下一个 * @param filePath 文件路径 - * @returns 诊断结果,包含每个格式的匹配详情和建议 + * @returns 所有匹配的格式特征列表 */ -export function diagnoseFormat(filePath: string): FormatDiagnosis { - return sniffer.diagnose(filePath) +export function detectAllFormats(filePath: string): FormatFeature[] { + return sniffer.sniffAll(filePath) } /** @@ -61,6 +64,15 @@ export function getSupportedFormats(): FormatFeature[] { return sniffer.getSupportedFormats() } +/** + * 根据格式 ID 获取格式特征 + * 用于手动指定格式时跳过嗅探 + */ +export function getFormatFeatureById(formatId: string): FormatFeature | null { + const all = sniffer.getSupportedFormats() + return all.find((f) => f.id === formatId) ?? null +} + /** * 获取格式的预处理器(如果有) */ @@ -72,6 +84,30 @@ export function getPreprocessor(filePath: string) { return module?.preprocessor || null } +/** + * 扫描多聊天文件中的聊天列表 + * 自动检测格式并调用对应格式模块的 scanChats + * @param filePath 文件路径 + * @returns 聊天列表,如果格式不支持多聊天则抛出错误 + */ +export async function scanMultiChatFile(filePath: string): Promise { + const feature = sniffer.sniff(filePath) + if (!feature) { + throw new Error(`无法识别文件格式: ${filePath}`) + } + + if (!feature.multiChat) { + throw new Error(`格式 "${feature.name}" 不是多聊天格式`) + } + + const module = formats.find((m) => m.feature.id === feature.id) + if (!module?.scanChats) { + throw new Error(`格式 "${feature.name}" 声明了 multiChat 但未实现 scanChats`) + } + + return module.scanChats(filePath) +} + /** * 检查文件是否需要预处理 */ @@ -95,7 +131,27 @@ export async function* parseFile(options: ParseOptions): AsyncGenerator { + const parser = sniffer.getParserById(formatId) + if (!parser) { + yield { type: 'error', data: new Error(`未知的格式 ID: ${formatId}`) } + return + } + + console.log(`[Parser V2] Using parser (explicit): ${parser.feature.name}`) yield* parser.parse(options) } @@ -184,8 +240,6 @@ export async function parseFileInfo( } } - // 获取文件大小 - const fs = await import('fs') const fileSize = fs.statSync(filePath).size return { @@ -198,6 +252,41 @@ export async function parseFileInfo( } } +/** + * Scan a directory for a recognizable entry file (e.g. manifest.json for chunked formats). + * Uses existing format detection; no format-specific filenames are hardcoded here. + * @param dirPath directory path to scan + * @param maxDepth max depth to scan (default 1, only top-level files) + * @returns path to the entry file, or null if none found + */ +export function findEntryFileInDirectory(dirPath: string, maxDepth = 1): string | null { + function walk(current: string, depth: number): string | null { + if (depth > maxDepth) return null + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(current, { withFileTypes: true }) + } catch { + return null + } + for (const entry of entries) { + const full = path.join(current, entry.name) + if (entry.isFile() && entry.name.endsWith('.json')) { + const format = detectFormat(full) + if (format) return full + } + } + for (const entry of entries) { + if (entry.isDirectory()) { + const found = walk(path.join(current, entry.name), depth + 1) + if (found) return found + } + } + return null + } + + return walk(dirPath, 0) +} + // ==================== 导出类型 ==================== export type { @@ -210,7 +299,7 @@ export type { ParsedMeta, ParsedMember, ParsedMessage, - FormatDiagnosis, + MultiChatInfo, } // ==================== 导出嗅探器(高级用法) ==================== @@ -232,25 +321,35 @@ export interface StreamParseCallbacks { onMembers: (members: ParsedMember[]) => void onMessageBatch: (messages: ParsedMessage[]) => void /** 日志回调(可选) */ - onLog?: (level: 'info' | 'error', message: string) => void + onLog?: (level: LogLevel, message: string) => void } export interface StreamParseOptions extends StreamParseCallbacks { filePath: string batchSize?: number + formatOptions?: Record } /** * 回调模式的流式解析 * 内部使用 AsyncGenerator,对外提供回调接口 + * @param filePath 文件路径 + * @param callbacks 回调选项 + * @param formatId 可选,指定格式 ID(用于 fallback 机制,跳过自动检测) */ export async function streamParseFile( filePath: string, - callbacks: Omit + callbacks: Omit, + formatId?: string ): Promise { - const { onProgress, onMeta, onMembers, onMessageBatch, onLog, batchSize = 5000 } = callbacks + const { onProgress, onMeta, onMembers, onMessageBatch, onLog, batchSize = 5000, formatOptions } = callbacks + + // 根据是否指定 formatId 选择解析方式 + const generator = formatId + ? parseFileWithFormat(formatId, { filePath, batchSize, formatOptions, onProgress, onLog }) + : parseFile({ filePath, batchSize, formatOptions, onProgress, onLog }) - for await (const event of parseFile({ filePath, batchSize, onProgress, onLog })) { + for await (const event of generator) { switch (event.type) { case 'meta': onMeta(event.data) diff --git a/packages/parser/src/sniffer.ts b/packages/parser/src/sniffer.ts new file mode 100644 index 000000000..40ec1b940 --- /dev/null +++ b/packages/parser/src/sniffer.ts @@ -0,0 +1,230 @@ +/** + * Parser V2 - 嗅探层 + * 负责检测文件格式,匹配对应的解析器 + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { FormatFeature, FormatModule, Parser } from './types' + +/** 文件头检测大小 (64KB) - 考虑到现代聊天记录文件可能包含 base64 头像等大数据 */ +const HEAD_SIZE = 64 * 1024 + +/** + * 读取文件头部内容 + */ +function readFileHead(filePath: string, size: number = HEAD_SIZE): string { + const fd = fs.openSync(filePath, 'r') + const buffer = Buffer.alloc(size) + const bytesRead = fs.readSync(fd, buffer, 0, size, 0) + fs.closeSync(fd) + return buffer.slice(0, bytesRead).toString('utf-8') +} + +/** + * 获取文件扩展名(小写) + */ +function getExtension(filePath: string): string { + return path.extname(filePath).toLowerCase() +} + +/** + * 检查文件头是否匹配签名 + */ +function matchHeadSignatures(headContent: string, patterns: RegExp[]): boolean { + return patterns.some((pattern) => pattern.test(headContent)) +} + +/** + * 检查文件名是否匹配签名 + */ +function matchFilenameSignatures(filePath: string, patterns: RegExp[]): boolean { + const filename = path.basename(filePath) + return patterns.some((pattern) => pattern.test(filename)) +} + +/** + * 检查必需字段是否存在 + */ +function matchRequiredFields(headContent: string, fields: string[]): boolean { + // 简单检查:字段名是否出现在文件头中 + // 对于 JSON 文件,检查 "fieldName" 是否存在 + return fields.every((field) => { + const pattern = new RegExp(`"${field.replace('.', '"\\s*:\\s*.*"')}"\\s*:`) + return pattern.test(headContent) || headContent.includes(`"${field}"`) + }) +} + +/** + * 格式嗅探器 + * 管理所有格式特征,负责检测文件格式 + */ +export class FormatSniffer { + private formats: FormatModule[] = [] + + /** + * 注册格式模块 + */ + register(module: FormatModule): void { + this.formats.push(module) + // 按优先级排序(优先级数字越小越靠前) + this.formats.sort((a, b) => a.feature.priority - b.feature.priority) + } + + /** + * 批量注册格式模块 + */ + registerAll(modules: FormatModule[]): void { + for (const module of modules) { + this.register(module) + } + } + + /** + * 嗅探文件格式 + * @param filePath 文件路径 + * @returns 匹配的格式特征,如果无法识别则返回 null + */ + sniff(filePath: string): FormatFeature | null { + const ext = getExtension(filePath) + const headContent = readFileHead(filePath) + + for (const { feature } of this.formats) { + if (this.matchFeature(feature, ext, headContent, filePath)) { + return feature + } + } + + return null + } + + /** + * 获取文件对应的解析器 + * @param filePath 文件路径 + * @returns 匹配的解析器,如果无法识别则返回 null + */ + getParser(filePath: string): Parser | null { + const ext = getExtension(filePath) + const headContent = readFileHead(filePath) + + for (const { feature, parser } of this.formats) { + if (this.matchFeature(feature, ext, headContent, filePath)) { + return parser + } + } + + return null + } + + /** + * 嗅探所有匹配的格式(按优先级排序) + * 用于 fallback 机制:当第一个格式解析失败时尝试下一个 + * @param filePath 文件路径 + * @returns 所有匹配的格式特征列表 + */ + sniffAll(filePath: string): FormatFeature[] { + const ext = getExtension(filePath) + const headContent = readFileHead(filePath) + const results: FormatFeature[] = [] + + for (const { feature } of this.formats) { + if (this.matchFeature(feature, ext, headContent, filePath)) { + results.push(feature) + } + } + + return results + } + + /** + * 获取所有匹配的解析器(按优先级排序) + * 用于 fallback 机制 + * @param filePath 文件路径 + * @returns 所有匹配的解析器列表 + */ + getParserCandidates(filePath: string): Parser[] { + const ext = getExtension(filePath) + const headContent = readFileHead(filePath) + const results: Parser[] = [] + + for (const { feature, parser } of this.formats) { + if (this.matchFeature(feature, ext, headContent, filePath)) { + results.push(parser) + } + } + + return results + } + + /** + * 根据格式 ID 获取解析器 + */ + getParserById(formatId: string): Parser | null { + const module = this.formats.find((m) => m.feature.id === formatId) + return module?.parser || null + } + + /** + * 获取所有支持的格式 + */ + getSupportedFormats(): FormatFeature[] { + return this.formats.map((m) => m.feature) + } + + /** + * 检查特征是否匹配 + */ + private matchFeature(feature: FormatFeature, ext: string, headContent: string, filePath?: string): boolean { + // 1. 检查扩展名 + if (!feature.extensions.includes(ext)) { + return false + } + + const { signatures } = feature + + // 2. 检查文件头签名(如果定义了) + let headMatch = true + if (signatures.head && signatures.head.length > 0) { + headMatch = matchHeadSignatures(headContent, signatures.head) + } + + // 3. 检查文件名签名(如果定义了,作为文件头匹配失败的补充) + let filenameMatch = false + if (signatures.filename && signatures.filename.length > 0 && filePath) { + filenameMatch = matchFilenameSignatures(filePath, signatures.filename) + } + + // 文件头签名或文件名签名至少有一个匹配 + if (!headMatch && !filenameMatch) { + // 如果两个都没定义,则认为匹配(只检查扩展名) + if ((signatures.head && signatures.head.length > 0) || (signatures.filename && signatures.filename.length > 0)) { + return false + } + } + + // 4. 检查必需字段(如果定义了) + if (signatures.requiredFields && signatures.requiredFields.length > 0) { + if (!matchRequiredFields(headContent, signatures.requiredFields)) { + return false + } + } + + // 5. 检查字段值模式(如果定义了) + if (signatures.fieldPatterns) { + for (const [, pattern] of Object.entries(signatures.fieldPatterns)) { + if (!pattern.test(headContent)) { + return false + } + } + } + + return true + } +} + +/** + * 创建并返回全局嗅探器实例 + */ +export function createSniffer(): FormatSniffer { + return new FormatSniffer() +} diff --git a/electron/main/parser/types.ts b/packages/parser/src/types.ts similarity index 77% rename from electron/main/parser/types.ts rename to packages/parser/src/types.ts index 116846da2..7eaa7b97c 100644 --- a/electron/main/parser/types.ts +++ b/packages/parser/src/types.ts @@ -3,7 +3,7 @@ * 三层架构:标准层、嗅探层、解析层 */ -import type { ChatPlatform, ChatType, ParsedMember, ParsedMessage } from '../../../src/types/base' +import type { ChatPlatform, ChatType, ParsedMember, ParsedMessage } from '@openchatlab/shared-types' // ==================== 标准层:统一输出结构 ==================== @@ -24,7 +24,8 @@ export interface ParsedMeta { */ export interface ParseProgress { /** 阶段 */ - stage: 'detecting' | 'parsing' | 'done' | 'error' + // 导入流程会复用解析进度结构,因此这里补充导入阶段枚举。 + stage: 'detecting' | 'parsing' | 'importing' | 'saving' | 'done' | 'error' /** 已读取字节数 */ bytesRead: number /** 文件总字节数 */ @@ -65,6 +66,8 @@ export interface ParseResult { export interface FormatSignatures { /** 文件头正则匹配(任意一个匹配即可) */ head?: RegExp[] + /** 文件名正则匹配(任意一个匹配即可,作为文件头匹配的补充) */ + filename?: RegExp[] /** 必须存在的 JSON 字段路径 */ requiredFields?: string[] /** 字段值模式匹配 */ @@ -87,12 +90,14 @@ export interface FormatFeature { extensions: string[] /** 内容特征签名 */ signatures: FormatSignatures + /** 是否为多聊天格式(一个文件包含多个聊天) */ + multiChat?: boolean } // ==================== 解析层:解析器接口 ==================== /** 日志级别 */ -export type LogLevel = 'info' | 'error' +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' /** * 解析选项 @@ -102,6 +107,8 @@ export interface ParseOptions { filePath: string /** 每批消息数量(默认 5000) */ batchSize?: number + /** 格式特定的额外选项(如 Telegram 的 chatIndex) */ + formatOptions?: Record /** 进度回调(可选,用于外部监听) */ onProgress?: (progress: ParseProgress) => void /** 日志回调(可选,用于记录解析过程中的信息、警告、错误) */ @@ -137,52 +144,35 @@ export interface Preprocessor { cleanup(tempPath: string): void } -/** - * 格式模块导出结构 - * 每个格式文件同时导出 feature、parser,以及可选的 preprocessor - */ -export interface FormatModule { - feature: FormatFeature - parser: Parser - preprocessor?: Preprocessor -} - -// ==================== 诊断结果类型 ==================== +// ==================== 多聊天支持 ==================== /** - * 单个格式的匹配检查结果 + * 多聊天文件中单个聊天的信息 + * 用于「一个文件包含多个聊天」的格式(如 Telegram 官方导出) */ -export interface FormatMatchCheck { - /** 格式 ID */ - formatId: string - /** 格式显示名称 */ - formatName: string - /** 扩展名是否匹配 */ - extensionMatch: boolean - /** 文件头签名是否匹配(如果定义了) */ - headSignatureMatch: boolean | null - /** 必需字段是否匹配(如果定义了) */ - requiredFieldsMatch: boolean | null - /** 缺失的必需字段(如果有) */ - missingFields: string[] - /** 是否完全匹配 */ - fullMatch: boolean +export interface MultiChatInfo { + /** 在源文件中的索引 */ + index: number + /** 聊天名称 */ + name: string + /** 聊天类型(平台特定的原始类型字符串) */ + type: string + /** 聊天 ID */ + id: number + /** 消息数量 */ + messageCount: number } /** - * 格式诊断结果 + * 格式模块导出结构 + * 每个格式文件同时导出 feature、parser,以及可选的 preprocessor 和 scanChats */ -export interface FormatDiagnosis { - /** 是否成功识别到格式 */ - recognized: boolean - /** 识别到的格式(如果有) */ - matchedFormat: FormatFeature | null - /** 所有格式的检查详情 */ - checks: FormatMatchCheck[] - /** 部分匹配的格式(扩展名匹配但内容不匹配) */ - partialMatches: FormatMatchCheck[] - /** 诊断建议信息 */ - suggestion: string +export interface FormatModule { + feature: FormatFeature + parser: Parser + preprocessor?: Preprocessor + /** 扫描多聊天文件中的聊天列表(仅 multiChat 格式需要实现) */ + scanChats?: (filePath: string) => Promise } // ==================== 工具类型 ==================== diff --git a/electron/main/parser/utils.ts b/packages/parser/src/utils.ts similarity index 96% rename from electron/main/parser/utils.ts rename to packages/parser/src/utils.ts index 7953b7ea3..44ef0059d 100644 --- a/electron/main/parser/utils.ts +++ b/packages/parser/src/utils.ts @@ -3,7 +3,7 @@ */ import * as fs from 'fs' -import type { ParseProgress, CreateProgress } from './types' +import type { CreateProgress } from './types' /** * 获取文件大小 @@ -72,4 +72,3 @@ export function formatFileSize(bytes: number): string { if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` } - diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts new file mode 100644 index 000000000..6126d3ed6 --- /dev/null +++ b/packages/shared-types/index.ts @@ -0,0 +1,528 @@ +/** + * @openchatlab/shared-types + * 平台无关的共享类型定义,三端(Electron / Node 服务 / Web)统一使用 + */ + +// ==================== 时间筛选 ==================== + +export interface TimeFilter { + startTs?: number + endTs?: number + memberId?: number | null +} + +// ==================== 枚举与平台 ==================== + +/** + * 消息类型枚举 + * + * 分类说明: + * - 基础消息 (0-19): 常见的内容类型 + * - 交互消息 (20-39): 涉及互动的消息类型 + * - 系统消息 (80-89): 系统相关消息 + * - 其他 (99): 未知或无法分类的消息 + */ +export enum MessageType { + // ========== 基础消息类型 (0-19) ========== + TEXT = 0, + IMAGE = 1, + VOICE = 2, + VIDEO = 3, + FILE = 4, + EMOJI = 5, + LINK = 7, + LOCATION = 8, + + // ========== 交互消息类型 (20-39) ========== + RED_PACKET = 20, + TRANSFER = 21, + POKE = 22, + CALL = 23, + SHARE = 24, + REPLY = 25, + FORWARD = 26, + CONTACT = 27, + + // ========== 系统消息类型 (80-89) ========== + SYSTEM = 80, + RECALL = 81, + + // ========== 其他 (99) ========== + OTHER = 99, +} + +/** + * 聊天平台类型(字符串,允许任意值) + * 常见平台示例:qq, weixin, discord, whatsapp 等 + * 合并多平台记录时使用 'mixed' + */ +export type ChatPlatform = string + +export const KNOWN_PLATFORMS = { + QQ: 'qq', + WECHAT: 'weixin', + DISCORD: 'discord', + WHATSAPP: 'whatsapp', + TELEGRAM: 'telegram', + INSTAGRAM: 'instagram', + GOOGLE_CHAT: 'google-chat', + LINE: 'line', + UNKNOWN: 'unknown', +} as const + +/** + * 聊天类型枚举 + */ +export enum ChatType { + GROUP = 'group', + PRIVATE = 'private', +} + +// ==================== 成员角色 ==================== + +export interface MemberRole { + id: string + name?: string +} + +export const STANDARD_ROLE_IDS = { + OWNER: 'owner', + ADMIN: 'admin', +} as const + +// ==================== 标准协议(Parser 输出) ==================== + +export interface ParsedMember { + platformId: string + accountName: string + groupNickname?: string + avatar?: string + roles?: MemberRole[] +} + +export interface ParsedMessage { + platformMessageId?: string + senderPlatformId: string + senderAccountName: string + senderGroupNickname?: string + timestamp: number + type: MessageType + content: string | null + replyToMessageId?: string +} + +// ==================== Preferences (跨端偏好设置) ==================== + +export interface WordFilterScheme { + id: string + name: string + words: string[] + createdAt: number +} + +export interface ContextCompressionSettings { + enabled: boolean + tokenThresholdPercent: number + bufferSizePercent: number + maxToolResultPercent: number +} + +export type ChartAutoMode = 'explicit' | 'suggest' | 'aggressive' + +export interface AIGlobalSettings { + maxMessagesPerRequest: number + exportFormat: 'markdown' | 'txt' + sqlExportFormat: 'csv' | 'json' + enableAutoSkill: boolean + chartAutoMode: ChartAutoMode + searchContextBefore: number + searchContextAfter: number + contextCompression: ContextCompressionSettings +} + +export interface KeywordTemplate { + id: string + name: string + keywords: string[] + [key: string]: unknown +} + +export interface DesensitizeRule { + id: string + label: string + pattern: string + replacement: string + enabled: boolean + builtin: boolean + locales: string[] + group?: string +} + +export interface AIPreprocessConfig { + dataCleaning: boolean + mergeConsecutive: boolean + mergeWindowSeconds: number + blacklistKeywords: string[] + denoise: boolean + desensitize: boolean + desensitizeRulesSchemaVersion?: number + desensitizeBuiltinRuleOverrides?: Record + desensitizeRules: DesensitizeRule[] + anonymizeNames: boolean +} + +export interface FilterHistoryItem { + id: string + sessionId: string + createdAt: number + name: string + mode: 'condition' | 'session' + conditionFilter?: { + keywords: string[] + timeRange: { start: number; end: number } | null + senderIds: number[] + contextSize: number + } + selectedSessionIds?: number[] +} + +export type OwnerMatchMode = 'platform_id' | 'name' + +/** + * Platform-level owner identity ("who am I" on this chat platform). + * Stored in preferences.json and shared across sessions of the same platform. + */ +export interface OwnerProfile { + platformId: string + displayName: string + /** Original (non-normalized) names confirmed by the user; normalization happens at match time. */ + confirmedNames: string[] + matchMode: OwnerMatchMode + updatedAt: number +} + +export type ApplyOwnerProfileReason = 'no_profile' | 'no_match' | 'ambiguous' | 'already_set' | 'missing_session' + +export interface ApplyOwnerProfileResult { + applied: boolean + ownerId?: string + reason?: ApplyOwnerProfileReason + /** Whether the user chose "do not remind me" for this session (UI hint only). */ + dismissed: boolean +} + +export interface SetOwnerAndApplyProfileResult { + sessionId: string + platform: string + ownerId: string + /** Other same-platform sessions auto-filled by the updated profile. */ + updatedSessionIds: string[] + /** + * The actual owner_id written to each updated session. + * On name-match platforms the matched member's platformId can differ from + * ownerId (the source session's platformId), so callers must use this map + * rather than ownerId when caching the result for updated sessions. + */ + updatedSessionOwnerIds: Record +} + +// ==================== Contacts (cross-session relationship view) ==================== + +export type ContactPool = 'friend' | 'non_friend' + +export type ContactFriendSource = 'private' | 'manual' + +export type ContactsCacheStatus = 'fresh' | 'stale' | 'missing' + +export type ContactsTaskStatus = 'idle' | 'running' | 'succeeded' | 'failed' | 'superseded' + +export const CONTACTS_TIME_RANGE_PRESETS = ['1y', '2y', '3y', '5y', 'all'] as const + +export type ContactsTimeRangePreset = (typeof CONTACTS_TIME_RANGE_PRESETS)[number] + +export interface ContactsTimeRangeState { + preset: ContactsTimeRangePreset + anchorTs: number | null + startTs: number | null +} + +export interface ContactScoreBreakdown { + privateMessageScore?: number + privateRegularityScore?: number + commonGroupScore?: number + coOccurrenceScore?: number + replyInteractionScore?: number + privateMessageCount?: number + activePrivateMonths?: number + commonGroupCount?: number + coOccurrenceCount?: number + coOccurrenceRawScore?: number + replyInteractionCount?: number + repliesFromOwnerToContact?: number + repliesFromContactToOwner?: number +} + +export interface ContactSourceSession { + id: string + name: string + platform: ChatPlatform + type: ChatType + messageCount?: number + privateMessageCount?: number + coOccurrenceCount?: number + coOccurrenceRawScore?: number + replyInteractionCount?: number + repliesFromOwnerToContact?: number + repliesFromContactToOwner?: number + lastMessageTs?: number | null + lastInteractionTs?: number | null +} + +export interface ContactItem { + key: string + platform: ChatPlatform + platformId: string + sessionScoped: boolean + sessionId?: string + displayName: string + aliases: string[] + avatar: string | null + isFriend: boolean + pool: ContactPool + friendSource?: ContactFriendSource + score: number + scoreBreakdown: ContactScoreBreakdown + sourceSessions: ContactSourceSession[] + searchText: string + lastInteractionTs: number | null +} + +export type ContactListItem = Omit + +export interface ContactsDiagnostics { + privateSessionCount: number + activePrivateSessionCount: number + contactsEnabled: boolean + skippedMissingOwnerSessions: number + skippedUnresolvedOwnerSessions: number + skippedAmbiguousPrivateSessions: number + skippedInvalidPlatformIdMembers: number + skippedFailedSessions: number + warnings: string[] +} + +export interface ContactsCacheState { + status: ContactsCacheStatus + computedAt: number | null + signature?: string + staleReason?: string +} + +export interface ContactsTaskState { + id: string | null + status: ContactsTaskStatus + startedAt: number | null + finishedAt: number | null + processedSessions: number + totalSessions: number + timeRangePreset?: ContactsTimeRangePreset + currentSessionId?: string + lastError?: string +} + +export interface ContactsPagination { + page: number + pageSize: number + total: number + hasMore: boolean +} + +export interface ContactsStats { + friendsTotal: number + nonFriendsTotal: number +} + +export interface ContactsListResponse { + contacts: ContactListItem[] + diagnostics: ContactsDiagnostics + cache: ContactsCacheState + timeRange: ContactsTimeRangeState + algorithmVersion: string + pagination: ContactsPagination + stats: ContactsStats + task?: ContactsTaskState +} + +export interface ContactDetailResponse { + contact: ContactItem | null + cache: ContactsCacheState + timeRange: ContactsTimeRangeState + algorithmVersion: string + task?: ContactsTaskState +} + +export type ContactsResponse = ContactsListResponse + +// ==================== People Relationships (galaxy graph) ==================== + +export type PeopleRelationshipsCacheStatus = ContactsCacheStatus +export type PeopleRelationshipsTaskStatus = ContactsTaskStatus +export type PeopleRelationshipsGraphScope = 'panorama' | 'close' | 'friends' + +export interface PeopleRelationshipGraphNode { + key: string + kind?: 'contact' | 'owner' + platform: ChatPlatform + platformId: string + sessionScoped: boolean + sessionId?: string + displayName: string + aliases: string[] + avatar: string | null + pool: ContactPool + friendSource?: ContactFriendSource + score: number + rank: number + communityId: string + x: number + y: number + size: number + color: string + labelVisibility: 0 | 1 | 2 + lastInteractionTs: number | null + privateMessageCount: number + groupMessageCount: number + commonGroupCount: number + searchText: string +} + +export interface PeopleRelationshipGraphEdge { + id: string + sourceKey: string + targetKey: string + weight: number + coOccurrenceCount: number + coOccurrenceRawScore: number + replyInteractionCount: number + repliesFromSourceToTarget: number + repliesFromTargetToSource: number + sourceGroupCount: number + sourceSessionIds: string[] + lastInteractionTs: number | null + visibility: 0 | 1 | 2 +} + +export interface PeopleRelationshipCommunity { + id: string + label: string + size: number + x: number + y: number + color: string +} + +export interface PeopleRelationshipsGraphData { + nodes: PeopleRelationshipGraphNode[] + edges: PeopleRelationshipGraphEdge[] + communities: PeopleRelationshipCommunity[] +} + +export interface PeopleRelationshipsDiagnostics { + processedPrivateSessions: number + processedGroupSessions: number + skippedMissingOwnerSessions: number + skippedUnresolvedOwnerSessions: number + skippedAmbiguousPrivateSessions: number + skippedFailedSessions: number + totalNodes: number + totalEdges: number + panoramaIncludedGroupSessions: number + panoramaExcludedLowValueGroupSessions: number + panoramaIncludedGroupMembers: number + panoramaExcludedGroupMembers: number + panoramaCandidateNodes: number + panoramaGroupInclusionReasons: Record + coreNodeCount: number + coreEdgeCount: number + warnings: string[] +} + +export interface PeopleRelationshipsCacheState { + status: PeopleRelationshipsCacheStatus + computedAt: number | null + signature?: string + staleReason?: string +} + +export interface PeopleRelationshipsTaskState { + id: string | null + status: PeopleRelationshipsTaskStatus + startedAt: number | null + finishedAt: number | null + processedSessions: number + totalSessions: number + timeRangePreset?: ContactsTimeRangePreset + currentSessionId?: string + lastError?: string +} + +export interface PeopleRelationshipsSearchResult { + key: string + kind?: 'contact' | 'owner' + displayName: string + platform: ChatPlatform + platformId: string + avatar: string | null + pool: ContactPool + friendSource?: ContactFriendSource + score: number + rank: number + communityId: string + inCoreGraph: boolean +} + +export interface PeopleRelationshipsGraphResponse { + graph: PeopleRelationshipsGraphData + searchResults: PeopleRelationshipsSearchResult[] + diagnostics: PeopleRelationshipsDiagnostics + cache: PeopleRelationshipsCacheState + timeRange: ContactsTimeRangeState + algorithmVersion: string + task?: PeopleRelationshipsTaskState +} + +export interface PeopleRelationshipsNeighborhoodResponse { + contact: PeopleRelationshipGraphNode | null + graph: PeopleRelationshipsGraphData + diagnostics: PeopleRelationshipsDiagnostics + cache: PeopleRelationshipsCacheState + timeRange: ContactsTimeRangeState + algorithmVersion: string + task?: PeopleRelationshipsTaskState +} + +export interface Preferences { + pinnedSessionIds: string[] + aiPreprocessConfig: AIPreprocessConfig + aiGlobalSettings: AIGlobalSettings + customKeywordTemplates: KeywordTemplate[] + deletedPresetTemplateIds: string[] + wordFilter: { + schemes: WordFilterScheme[] + defaultSchemeId: string | null + sessionSchemeOverrides: Record + } + filterHistory: FilterHistoryItem[] + /** Per-model thinking level, keyed by `${configId}:${modelId}`. */ + thinkingLevels: Record + /** Platform-level owner identity, keyed by platform (e.g. 'whatsapp'). */ + ownerProfilesByPlatform: Record + /** Sessions where the user chose "do not remind me"; suppresses the owner prompt UI only. */ + ownerPromptDismissedSessionIds: string[] +} + +export interface UiConfig { + default_session_tab: 'overview' | 'ai-chat' + session_gap_threshold: number + summary_strategy?: 'brief' | 'standard' +} diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 000000000..b18697ab9 --- /dev/null +++ b/packages/shared-types/package.json @@ -0,0 +1,9 @@ +{ + "name": "@openchatlab/shared-types", + "version": "0.0.0", + "private": true, + "description": "ChatLab 跨平台共享类型定义", + "type": "module", + "main": "./index.ts", + "types": "./index.ts" +} diff --git a/packages/sync/package.json b/packages/sync/package.json new file mode 100644 index 000000000..5d2e6c40f --- /dev/null +++ b/packages/sync/package.json @@ -0,0 +1,9 @@ +{ + "name": "@openchatlab/sync", + "version": "0.0.0", + "private": true, + "description": "ChatLab platform-agnostic data sync engine: config, data source, pull scheduler", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts" +} diff --git a/packages/sync/src/config-manager.ts b/packages/sync/src/config-manager.ts new file mode 100644 index 000000000..64f42ddbc --- /dev/null +++ b/packages/sync/src/config-manager.ts @@ -0,0 +1,90 @@ +/** + * @openchatlab/sync — API server config manager + * + * Extracted from electron/main/api/config.ts. + * Parameterized by `configDir` so it works in both Electron and CLI. + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import { NOOP_LOGGER } from './types' +import type { ApiServerConfig, SyncLogger } from './types' + +const CONFIG_FILE = 'api-server.json' + +const DEFAULT_CONFIG: ApiServerConfig = { + enabled: false, + port: 3110, + token: '', + createdAt: 0, +} + +function generateToken(): string { + return `clb_${crypto.randomBytes(32).toString('hex')}` +} + +export class ConfigManager { + private configDir: string + private logger: SyncLogger + + constructor(configDir: string, logger?: SyncLogger) { + this.configDir = configDir + this.logger = logger ?? NOOP_LOGGER + } + + private getConfigPath(): string { + return path.join(this.configDir, CONFIG_FILE) + } + + private ensureDir(): void { + fs.mkdirSync(this.configDir, { recursive: true }) + } + + load(): ApiServerConfig { + try { + const filePath = this.getConfigPath() + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf-8') + const parsed = JSON.parse(raw) as Partial + return { ...DEFAULT_CONFIG, ...parsed } + } + } catch (err) { + this.logger.error('[Config] Failed to load config', err) + } + return { ...DEFAULT_CONFIG } + } + + save(config: ApiServerConfig): void { + try { + this.ensureDir() + fs.writeFileSync(this.getConfigPath(), JSON.stringify(config, null, 2), 'utf-8') + } catch (err) { + this.logger.error('[Config] Failed to save config', err) + } + } + + update(partial: Partial): ApiServerConfig { + const current = this.load() + const updated = { ...current, ...partial } + this.save(updated) + return updated + } + + ensureToken(config: ApiServerConfig): ApiServerConfig { + if (!config.token) { + config.token = generateToken() + config.createdAt = Math.floor(Date.now() / 1000) + this.save(config) + } + return config + } + + regenerateToken(): ApiServerConfig { + const config = this.load() + config.token = generateToken() + config.createdAt = Math.floor(Date.now() / 1000) + this.save(config) + return config + } +} diff --git a/packages/sync/src/data-source-manager.ts b/packages/sync/src/data-source-manager.ts new file mode 100644 index 000000000..915d808ad --- /dev/null +++ b/packages/sync/src/data-source-manager.ts @@ -0,0 +1,190 @@ +/** + * @openchatlab/sync — Data source configuration manager + * + * Extracted from electron/main/api/dataSource.ts. + * Parameterized by `configDir` so it works in both Electron and CLI. + * + * DataSource (remote server) → ImportSession[] (subscribed conversations) + */ + +import * as fs from 'fs' +import * as path from 'path' +import * as crypto from 'crypto' +import { NOOP_LOGGER } from './types' +import type { DataSource, DataSourceUpdatable, ImportSession, SyncLogger } from './types' + +const CONFIG_FILE = 'data-sources.json' + +function generateId(prefix: string = 'ds'): string { + return `${prefix}_${crypto.randomBytes(6).toString('hex')}` +} + +export function normalizeBaseUrl(input: string): string { + let url = input.trim().replace(/\/+$/, '') + if (url && !/^https?:\/\//i.test(url)) { + url = `http://${url}` + } + if (url && !url.endsWith('/api/v1')) { + url = url.replace(/\/api\/v1$/, '') + '/api/v1' + } + return url +} + +function isValidDataSourceArray(data: unknown): data is DataSource[] { + if (!Array.isArray(data)) return false + return data.every((item) => item && typeof item === 'object' && Array.isArray((item as any).sessions)) +} + +export class DataSourceManager { + private configDir: string + private logger: SyncLogger + + constructor(configDir: string, logger?: SyncLogger) { + this.configDir = configDir + this.logger = logger ?? NOOP_LOGGER + } + + private getConfigPath(): string { + return path.join(this.configDir, CONFIG_FILE) + } + + private ensureDir(): void { + fs.mkdirSync(this.configDir, { recursive: true }) + } + + // ==================== Load / Save ==================== + + loadAll(): DataSource[] { + try { + const filePath = this.getConfigPath() + if (fs.existsSync(filePath)) { + const raw = fs.readFileSync(filePath, 'utf-8') + const parsed = JSON.parse(raw) + + if (!isValidDataSourceArray(parsed)) { + this.logger.warn('[DataSource] Incompatible config format detected, returning empty.') + return [] + } + + for (const ds of parsed) { + if (!ds.pullLimit) ds.pullLimit = 1000 + } + return parsed + } + } catch (err) { + this.logger.error('[DataSource] Failed to load config', err) + } + return [] + } + + private saveAll(sources: DataSource[]): void { + try { + this.ensureDir() + fs.writeFileSync(this.getConfigPath(), JSON.stringify(sources, null, 2), 'utf-8') + } catch (err) { + this.logger.error('[DataSource] Failed to save config', err) + } + } + + // ==================== DataSource CRUD ==================== + + get(id: string): DataSource | null { + return this.loadAll().find((s) => s.id === id) || null + } + + add(partial: { + name?: string + baseUrl: string + token: string + intervalMinutes: number + pullLimit?: number + }): DataSource { + const sources = this.loadAll() + const ds: DataSource = { + id: generateId('src'), + name: partial.name || '', + baseUrl: normalizeBaseUrl(partial.baseUrl), + token: partial.token, + intervalMinutes: partial.intervalMinutes, + pullLimit: partial.pullLimit || 1000, + enabled: true, + createdAt: Math.floor(Date.now() / 1000), + sessions: [], + } + sources.push(ds) + this.saveAll(sources) + return ds + } + + update(id: string, updates: DataSourceUpdatable): DataSource | null { + const sources = this.loadAll() + const idx = sources.findIndex((s) => s.id === id) + if (idx === -1) return null + const ds = sources[idx] + if (updates.name !== undefined) ds.name = updates.name + if (updates.baseUrl !== undefined) ds.baseUrl = normalizeBaseUrl(updates.baseUrl) + if (updates.token !== undefined) ds.token = updates.token + if (updates.intervalMinutes !== undefined) ds.intervalMinutes = updates.intervalMinutes + if (updates.pullLimit !== undefined) ds.pullLimit = updates.pullLimit + if (updates.enabled !== undefined) ds.enabled = updates.enabled + this.saveAll(sources) + return ds + } + + delete(id: string): boolean { + const sources = this.loadAll() + const filtered = sources.filter((s) => s.id !== id) + if (filtered.length === sources.length) return false + this.saveAll(filtered) + return true + } + + // ==================== ImportSession CRUD ==================== + + addSessions(sourceId: string, sessions: Array<{ name: string; remoteSessionId: string }>): ImportSession[] { + const sources = this.loadAll() + const ds = sources.find((s) => s.id === sourceId) + if (!ds) return [] + + const added: ImportSession[] = [] + for (const sess of sessions) { + if (ds.sessions.some((s) => s.remoteSessionId === sess.remoteSessionId)) continue + const imp: ImportSession = { + id: generateId('sess'), + name: sess.name, + remoteSessionId: sess.remoteSessionId, + targetSessionId: '', + lastPullAt: 0, + lastStatus: 'idle', + lastError: '', + lastNewMessages: 0, + } + ds.sessions.push(imp) + added.push(imp) + } + this.saveAll(sources) + return added + } + + removeSession(sourceId: string, sessionId: string): ImportSession | null { + const sources = this.loadAll() + const ds = sources.find((s) => s.id === sourceId) + if (!ds) return null + const removed = ds.sessions.find((s) => s.id === sessionId) + if (!removed) return null + ds.sessions = ds.sessions.filter((s) => s.id !== sessionId) + this.saveAll(sources) + return removed + } + + updateSession(sourceId: string, sessionId: string, updates: Partial): ImportSession | null { + const sources = this.loadAll() + const ds = sources.find((s) => s.id === sourceId) + if (!ds) return null + const sess = ds.sessions.find((s) => s.id === sessionId) + if (!sess) return null + Object.assign(sess, updates, { id: sessionId }) + this.saveAll(sources) + return sess + } +} diff --git a/packages/sync/src/discovery.ts b/packages/sync/src/discovery.ts new file mode 100644 index 000000000..8607ba7a8 --- /dev/null +++ b/packages/sync/src/discovery.ts @@ -0,0 +1,51 @@ +/** + * @openchatlab/sync — Remote session discovery + * + * Moved from electron/main/api/pullDiscovery.shared.ts (already platform-agnostic). + * Builds discovery URLs and parses responses from remote ChatLab API servers. + */ + +import type { RemoteSessionDiscoveryQuery, RemoteSessionDiscoveryResult, RemoteSession } from './types' + +export function buildRemoteSessionsUrl(baseUrl: string, query: RemoteSessionDiscoveryQuery = {}): string { + const searchParams = new URLSearchParams() + searchParams.set('format', 'chatlab') + + if (query.keyword?.trim()) searchParams.set('keyword', query.keyword.trim()) + if (query.limit && query.limit > 0) searchParams.set('limit', String(query.limit)) + if (query.cursor) searchParams.set('cursor', query.cursor) + + return `${baseUrl}/sessions?${searchParams.toString()}` +} + +/** + * Parse remote sessions response with backward compatibility. + * Supports: Pull protocol `{ sessions, page? }`, ChatLab API `{ success, data }`, and plain array. + */ +export function parseRemoteSessionsResponse(body: string): RemoteSessionDiscoveryResult { + const parsed = JSON.parse(body) + + let sessions: RemoteSession[] + let pageSource: Record | undefined + + if (Array.isArray(parsed)) { + sessions = parsed + } else if (parsed && typeof parsed === 'object') { + sessions = parsed.sessions ?? parsed.data?.sessions ?? parsed.data ?? [] + if (!Array.isArray(sessions)) sessions = [] + pageSource = parsed.page ?? parsed.data?.page + } else { + sessions = [] + } + + return { + sessions, + page: + pageSource && typeof pageSource === 'object' + ? { + hasMore: Boolean(pageSource.hasMore), + nextCursor: typeof pageSource.nextCursor === 'string' ? pageSource.nextCursor : undefined, + } + : undefined, + } +} diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts new file mode 100644 index 000000000..0c32a1904 --- /dev/null +++ b/packages/sync/src/index.ts @@ -0,0 +1,37 @@ +/** + * @openchatlab/sync + * + * Platform-agnostic data sync engine for ChatLab. + * Provides config management, data source CRUD, remote discovery, + * pull engine, and scheduler. + */ + +export { NOOP_LOGGER } from './types' + +export type { + ApiServerConfig, + ImportSession, + DataSource, + DataSourceUpdatable, + RemoteSession, + RemoteSessionDiscoveryPage, + RemoteSessionDiscoveryResult, + RemoteSessionDiscoveryQuery, + FetchParams, + SyncMeta, + ImportResult, + PullSessionResult, + PullProgress, + HttpFetcher, + DataImporter, + SyncNotifier, + SyncLogger, +} from './types' + +export { ConfigManager } from './config-manager' +export { DataSourceManager, normalizeBaseUrl } from './data-source-manager' +export { buildRemoteSessionsUrl, parseRemoteSessionsResponse } from './discovery' +export { PullEngine, buildPullUrl, deriveLocalSessionId, parseSyncFromFile } from './pull-engine' +export type { PullEngineOptions } from './pull-engine' +export { initScheduler, stopAllTimers, stopTimer, reloadTimer } from './scheduler' +export type { SchedulerOptions } from './scheduler' diff --git a/packages/sync/src/pull-engine.test.ts b/packages/sync/src/pull-engine.test.ts new file mode 100644 index 000000000..cf81fdc71 --- /dev/null +++ b/packages/sync/src/pull-engine.test.ts @@ -0,0 +1,434 @@ +import { afterEach, describe, it } from 'node:test' +import assert from 'node:assert/strict' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { PullEngine } from './pull-engine' +import type { DataSource, DataImporter, FetchParams, HttpFetcher, ImportSession, SyncNotifier } from './types' + +const tempFiles: string[] = [] + +afterEach(() => { + for (const file of tempFiles.splice(0)) { + try { + if (fs.existsSync(file)) fs.unlinkSync(file) + } catch { + /* ignore */ + } + } +}) + +function writeTempJson(data: unknown): string { + const file = path.join(os.tmpdir(), `chatlab-pull-engine-test-${process.pid}-${tempFiles.length}.json`) + fs.writeFileSync(file, JSON.stringify(data), 'utf-8') + tempFiles.push(file) + return file +} + +async function withImmediateTimers(fn: () => Promise): Promise { + const originalSetTimeout = globalThis.setTimeout + ;(globalThis as any).setTimeout = (callback: (...args: any[]) => void) => { + callback() + return 0 + } + try { + return await fn() + } finally { + globalThis.setTimeout = originalSetTimeout + } +} + +function createDataSource(): DataSource { + return { + id: 'ds_test', + name: 'Test Source', + baseUrl: 'http://example.test/api/v1', + token: '', + intervalMinutes: 10, + pullLimit: 1000, + enabled: true, + createdAt: 1, + sessions: [], + } +} + +function createSession(): ImportSession { + return { + id: 'is_test', + name: 'Test Session', + remoteSessionId: 'remote_session', + targetSessionId: 'local_session', + lastPullAt: 100, + lastStatus: 'idle', + lastError: '', + lastNewMessages: 0, + } +} + +function createEngine(options: { + files: string[] + importResult: Awaited> + dataSource: DataSource + isImporting?: (sessionId: string | undefined) => boolean + fetchParams?: FetchParams[] + sessionUpdates?: Array<{ sessionId: string; updates: Partial }> + pullResults?: Array<{ status: 'success' | 'error'; detail: string }> +}): PullEngine { + const files = [...options.files] + const fetcher: HttpFetcher = { + async fetchToTempFile( + _baseUrl: string, + _remoteSessionId: string, + _token: string, + params: FetchParams + ): Promise { + options.fetchParams?.push({ ...params }) + const file = files.shift() + if (!file) throw new Error('Unexpected retry fetch') + return file + }, + } + const importer: DataImporter = { + sessionExists: () => true, + importFile: async () => options.importResult, + } + const notifier: SyncNotifier = { + onSessionListChanged: () => {}, + onPullResult: (_sourceId, _sessionId, status, detail) => { + options.pullResults?.push({ status, detail }) + }, + } + const dsManager = { + get: () => options.dataSource, + updateSession: (_sourceId: string, sessionId: string, updates: Partial) => { + options.sessionUpdates?.push({ sessionId, updates }) + }, + } + + return new PullEngine({ + fetcher, + importer, + notifier, + dsManager: dsManager as any, + isImporting: options.isImporting, + }) +} + +describe('PullEngine', () => { + it('imports a small final page instead of treating it as empty', async () => { + const session = createSession() + const dataSource = createDataSource() + dataSource.sessions = [session] + const smallFinalPage = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [{ sender: 'u1', timestamp: 101, type: 0, content: 'hi' }], + sync: { hasMore: false, nextSince: 101 }, + }) + let importCount = 0 + const engine = createEngine({ + files: [smallFinalPage], + dataSource, + importResult: { + success: true, + newMessageCount: 1, + sessionId: session.targetSessionId, + }, + }) + ;(engine as any).importer.importFile = async () => { + importCount++ + return { success: true, newMessageCount: 1, sessionId: session.targetSessionId } + } + + const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session)) + + assert.equal(result.success, true) + assert.equal(result.newMessageCount, 1) + assert.equal(importCount, 1) + }) + + it('does not skip pulls when a different local session is importing', async () => { + const session = createSession() + session.targetSessionId = 'local_session_b' + const dataSource = createDataSource() + dataSource.sessions = [session] + const page = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [{ sender: 'u1', timestamp: 101, type: 0, content: 'hi' }], + sync: { hasMore: false, nextSince: 101 }, + }) + const checkedSessionIds: Array = [] + const engine = createEngine({ + files: [page], + dataSource, + isImporting: (sessionId) => { + checkedSessionIds.push(sessionId) + return sessionId === 'local_session_a' + }, + importResult: { + success: true, + newMessageCount: 1, + sessionId: session.targetSessionId, + }, + }) + + const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session)) + + assert.equal(result.success, true) + assert.equal(result.newMessageCount, 1) + assert.deepEqual(checkedSessionIds, ['local_session_b']) + }) + + it('reports retry import failure instead of marking the pull successful', async () => { + const session = createSession() + const dataSource = createDataSource() + dataSource.sessions = [session] + const emptyInitialPage = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [], + messages: [], + sync: { hasMore: false, nextSince: 100 }, + }) + const retryPage = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [ + { + sender: 'u1', + timestamp: 101, + type: 0, + content: 'x'.repeat(1200), + }, + ], + sync: { hasMore: false, nextSince: 101 }, + }) + const emptyTerminalPage = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [], + messages: [], + sync: { hasMore: false, nextSince: 101 }, + }) + const emptyRetryPage1 = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [], + messages: [], + sync: { hasMore: false, nextSince: 101 }, + }) + const emptyRetryPage2 = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [], + messages: [], + sync: { hasMore: false, nextSince: 101 }, + }) + const emptyRetryPage3 = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [], + messages: [], + sync: { hasMore: false, nextSince: 101 }, + }) + const sessionUpdates: Array<{ sessionId: string; updates: Partial }> = [] + const pullResults: Array<{ status: 'success' | 'error'; detail: string }> = [] + const engine = createEngine({ + files: [emptyInitialPage, retryPage, emptyTerminalPage, emptyRetryPage1, emptyRetryPage2, emptyRetryPage3], + dataSource, + sessionUpdates, + pullResults, + importResult: { + success: false, + newMessageCount: 0, + sessionId: session.targetSessionId, + error: 'retry import failed', + }, + }) + const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session)) + + assert.equal(result.success, false) + assert.equal(result.error, 'retry import failed') + assert.equal(pullResults.at(-1)?.status, 'error') + assert.equal(sessionUpdates.at(-1)?.updates.lastStatus, 'error') + }) + + it('continues pagination with nextOffset when nextSince is absent', async () => { + const session = createSession() + session.lastPullAt = 0 + const dataSource = createDataSource() + dataSource.sessions = [session] + const firstPage = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [ + { sender: 'u1', timestamp: 100, type: 0, content: 'page 1a' }, + { sender: 'u1', timestamp: 101, type: 0, content: 'page 1b' }, + ], + sync: { hasMore: true, nextOffset: 2, watermark: 200 }, + }) + const secondPage = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [{ sender: 'u1', timestamp: 200, type: 0, content: 'page 2' }], + sync: { hasMore: false, watermark: 200 }, + }) + const fetchParams: FetchParams[] = [] + const engine = createEngine({ + files: [firstPage, secondPage], + dataSource, + fetchParams, + importResult: { + success: true, + newMessageCount: 1, + sessionId: session.targetSessionId, + }, + }) + + const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session)) + + assert.equal(result.success, true) + assert.equal(fetchParams.length, 2) + assert.equal(fetchParams[0]?.offset, undefined) + assert.equal(fetchParams[1]?.offset, 2) + }) + + it('persists the imported message cursor with overlap instead of the wall clock', async () => { + const session = createSession() + session.lastPullAt = 0 + const dataSource = createDataSource() + dataSource.sessions = [session] + const page = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [{ sender: 'u1', timestamp: 2000, type: 0, content: 'latest imported message' }], + sync: { hasMore: false, watermark: 999999 }, + }) + const sessionUpdates: Array<{ sessionId: string; updates: Partial }> = [] + const engine = createEngine({ + files: [page], + dataSource, + sessionUpdates, + importResult: { + success: true, + newMessageCount: 1, + sessionId: session.targetSessionId, + }, + }) + + const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session)) + + assert.equal(result.success, true) + assert.equal(sessionUpdates.at(-1)?.updates.lastPullAt, 1940) + }) + + it('persists terminal retry nextSince when it is newer than imported messages', async () => { + const session = createSession() + session.lastPullAt = 0 + const dataSource = createDataSource() + dataSource.sessions = [session] + const emptyInitialPage = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [], + messages: [], + sync: { hasMore: false, nextSince: 100 }, + }) + const terminalRetryPage = writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [{ platformId: 'u1', accountName: 'Alice' }], + messages: [{ sender: 'u1', timestamp: 2000, type: 0, content: 'late retry message' }], + sync: { hasMore: false, nextSince: 5000 }, + }) + const sessionUpdates: Array<{ sessionId: string; updates: Partial }> = [] + const engine = createEngine({ + files: [emptyInitialPage, terminalRetryPage], + dataSource, + sessionUpdates, + importResult: { + success: true, + newMessageCount: 1, + sessionId: session.targetSessionId, + }, + }) + + const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session)) + + assert.equal(result.success, true) + assert.equal(sessionUpdates.at(-1)?.updates.lastPullAt, 4940) + }) + + it('persists terminal retry nextSince from empty retry pages', async () => { + const session = createSession() + session.lastPullAt = 0 + const dataSource = createDataSource() + dataSource.sessions = [session] + const emptyPages = Array.from({ length: 4 }, () => + writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [], + messages: [], + sync: { hasMore: false, nextSince: 5000 }, + }) + ) + const sessionUpdates: Array<{ sessionId: string; updates: Partial }> = [] + const engine = createEngine({ + files: emptyPages, + dataSource, + sessionUpdates, + importResult: { + success: true, + newMessageCount: 0, + sessionId: session.targetSessionId, + }, + }) + + const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session)) + + assert.equal(result.success, true) + assert.equal(result.newMessageCount, 0) + assert.equal(sessionUpdates.at(-1)?.updates.lastPullAt, 4940) + }) + + it('keeps the pull cursor stable when a successful pull returns no new cursor', async () => { + const session = createSession() + session.lastPullAt = 100 + const dataSource = createDataSource() + dataSource.sessions = [session] + const emptyPages = Array.from({ length: 4 }, () => + writeTempJson({ + chatlab: { version: '0.0.2', exportedAt: 100 }, + meta: { name: 'Test Session', platform: 'test', type: 'group' }, + members: [], + messages: [], + sync: { hasMore: false }, + }) + ) + const sessionUpdates: Array<{ sessionId: string; updates: Partial }> = [] + const engine = createEngine({ + files: emptyPages, + dataSource, + sessionUpdates, + importResult: { + success: true, + newMessageCount: 0, + sessionId: session.targetSessionId, + }, + }) + + const result = await withImmediateTimers(() => engine.executePullSession(dataSource.id, dataSource, session)) + + assert.equal(result.success, true) + assert.equal(result.newMessageCount, 0) + assert.equal(sessionUpdates.at(-1)?.updates.lastPullAt, 100) + }) +}) diff --git a/packages/sync/src/pull-engine.ts b/packages/sync/src/pull-engine.ts new file mode 100644 index 000000000..ad3d63563 --- /dev/null +++ b/packages/sync/src/pull-engine.ts @@ -0,0 +1,534 @@ +/** + * @openchatlab/sync — Pull engine + * + * Core paginated pull loop extracted from electron/main/api/pullScheduler.ts. + * All platform-specific dependencies are injected via HttpFetcher, DataImporter, and SyncNotifier. + */ + +import * as fs from 'fs' +import * as crypto from 'crypto' +import { NOOP_LOGGER } from './types' +import type { + DataSource, + ImportSession, + HttpFetcher, + DataImporter, + SyncNotifier, + SyncLogger, + FetchParams, + SyncMeta, + PullSessionResult, + PullProgress, +} from './types' +import type { DataSourceManager } from './data-source-manager' + +const MAX_PAGES_PER_PULL = 5000 +const PULL_OVERLAP_SECONDS = 60 + +// ==================== Helpers ==================== + +export function buildPullUrl(baseUrl: string, remoteSessionId: string, params: FetchParams): string { + const base = `${baseUrl}/sessions/${remoteSessionId}/messages` + const qs: string[] = ['format=chatlab'] + if (params.since !== undefined && params.since > 0) qs.push(`since=${params.since}`) + if (params.offset !== undefined && params.offset > 0) qs.push(`offset=${params.offset}`) + if (params.limit !== undefined && params.limit > 0) qs.push(`limit=${params.limit}`) + return base + '?' + qs.join('&') +} + +export function deriveLocalSessionId(baseUrl: string, remoteSessionId: string): string { + const hash = crypto.createHash('sha256').update(`${baseUrl}\0${remoteSessionId}`).digest('hex').slice(0, 12) + return `remote_${hash}` +} + +export function parseSyncFromFile(filePath: string): SyncMeta | null { + try { + const isJsonl = filePath.endsWith('.jsonl') + if (isJsonl) { + const content = fs.readFileSync(filePath, 'utf-8') + const lines = content.trimEnd().split('\n') + for (let i = lines.length - 1; i >= Math.max(0, lines.length - 5); i--) { + try { + const obj = JSON.parse(lines[i]) + if (obj._type === 'sync') { + return { + hasMore: !!obj.hasMore, + nextSince: obj.nextSince, + nextOffset: obj.nextOffset, + watermark: obj.watermark, + } + } + } catch { + continue + } + } + return null + } + + const raw = fs.readFileSync(filePath, 'utf-8') + const parsed = JSON.parse(raw) + if (parsed.sync && typeof parsed.sync === 'object') { + const s = parsed.sync + return { hasMore: !!s.hasMore, nextSince: s.nextSince, nextOffset: s.nextOffset, watermark: s.watermark } + } + return null + } catch { + return null + } +} + +function fileContainsMessages(filePath: string): boolean { + try { + if (filePath.endsWith('.jsonl')) { + const content = fs.readFileSync(filePath, 'utf-8') + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + try { + const obj = JSON.parse(trimmed) + if (obj._type === 'message') return true + } catch { + continue + } + } + return false + } + + const raw = fs.readFileSync(filePath, 'utf-8') + const parsed = JSON.parse(raw) + return Array.isArray(parsed.messages) && parsed.messages.length > 0 + } catch { + return false + } +} + +function getMaxMessageTimestampFromFile(filePath: string): number | null { + try { + let maxTs: number | null = null + const visitTimestamp = (value: unknown) => { + const ts = typeof value === 'string' && value.trim() !== '' ? Number(value) : value + if (typeof ts === 'number' && Number.isFinite(ts)) { + maxTs = maxTs === null ? ts : Math.max(maxTs, ts) + } + } + + if (filePath.endsWith('.jsonl')) { + const content = fs.readFileSync(filePath, 'utf-8') + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + try { + const obj = JSON.parse(trimmed) + if (obj._type === 'message') visitTimestamp(obj.timestamp) + } catch { + continue + } + } + return maxTs + } + + const raw = fs.readFileSync(filePath, 'utf-8') + const parsed = JSON.parse(raw) + if (Array.isArray(parsed.messages)) { + for (const message of parsed.messages) { + visitTimestamp(message?.timestamp) + } + } + return maxTs + } catch { + return null + } +} + +function cleanupTempFile(filePath: string): void { + try { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath) + } catch { + /* ignore */ + } +} + +// ==================== Pull Engine ==================== + +export interface PullEngineOptions { + fetcher: HttpFetcher + importer: DataImporter + notifier: SyncNotifier + dsManager: DataSourceManager + logger?: SyncLogger + /** Return true when an import is already in progress for the local session (skip pull) */ + isImporting?: (sessionId: string | undefined) => boolean + /** + * Called after a pull session completes successfully with the local session ID. + * Used for post-import side effects (e.g. applying the platform owner profile). + * Errors thrown by the hook never fail the pull. + */ + onSessionImported?: (localSessionId: string) => void +} + +export class PullEngine { + private fetcher: HttpFetcher + private importer: DataImporter + private notifier: SyncNotifier + private dsManager: DataSourceManager + private logger: SyncLogger + private isImporting: (sessionId: string | undefined) => boolean + private onSessionImported?: (localSessionId: string) => void + private pullingSourceIds = new Set() + private progressMap = new Map() + + constructor(options: PullEngineOptions) { + this.fetcher = options.fetcher + this.importer = options.importer + this.notifier = options.notifier + this.dsManager = options.dsManager + this.logger = options.logger ?? NOOP_LOGGER + this.isImporting = options.isImporting ?? (() => false) + this.onSessionImported = options.onSessionImported + } + + getProgress(): PullProgress[] { + return Array.from(this.progressMap.values()) + } + + private async importTempFile( + baseUrl: string, + sess: ImportSession, + tempFile: string + ): Promise<{ + success: boolean + newMessageCount: number + sessionId?: string + error?: string + needFullResync?: boolean + }> { + let targetId = sess.targetSessionId + if (!targetId) { + const derived = deriveLocalSessionId(baseUrl, sess.remoteSessionId) + if (this.importer.sessionExists(derived)) { + targetId = derived + this.logger.info(`[Pull] Reusing existing local session ${derived} for "${sess.name}"`) + } + } + + const externalId = deriveLocalSessionId(baseUrl, sess.remoteSessionId) + return this.importer.importFile(tempFile, targetId || undefined, externalId) + } + + async executePullSession(sourceId: string, ds: DataSource, sess: ImportSession): Promise { + const currentDs = this.dsManager.get(sourceId) + if (!currentDs || !currentDs.sessions.some((s) => s.id === sess.id)) { + this.logger.info(`[Pull] Skipping "${sess.name}": session no longer exists`) + return { success: true, newMessageCount: 0 } + } + + const importStatusSessionId = sess.targetSessionId || deriveLocalSessionId(ds.baseUrl, sess.remoteSessionId) + if (this.isImporting(importStatusSessionId)) { + this.logger.info(`[Pull] Skipping "${sess.name}": import in progress`) + return { success: false, newMessageCount: 0, error: 'Import in progress' } + } + + this.logger.info(`[Pull] Pulling "${sess.name}" from ${ds.baseUrl}`) + + let totalNewMessages = 0 + let since = sess.lastPullAt + let offset: number | undefined + let nextPullSince = sess.lastPullAt + let pageCount = 0 + let resyncAttempted = false + + this.progressMap.set(sess.id, { sessionId: sess.id, sessionName: sess.name, current: 0, pages: 0, done: false }) + + try { + while (pageCount < MAX_PAGES_PER_PULL) { + pageCount++ + const tempFile = await this.fetcher.fetchToTempFile(ds.baseUrl, sess.remoteSessionId, ds.token, { + since, + offset, + limit: ds.pullLimit, + }) + + try { + const stat = fs.statSync(tempFile) + this.logger.info(`[Pull] "${sess.name}" page ${pageCount}: fetched ${stat.size} bytes`) + + const sync0 = parseSyncFromFile(tempFile) + if (stat.size < 1024) { + if (!fileContainsMessages(tempFile) && sync0?.hasMore === false) { + cleanupTempFile(tempFile) + if (sync0?.nextSince !== undefined) nextPullSince = Math.max(nextPullSince, sync0.nextSince) + const retryDelays = [2000, 3000, 5000] + let retrySuccess = false + let retryHasMore = false + for (let ri = 0; ri < retryDelays.length; ri++) { + this.logger.info( + `[Pull] "${sess.name}" page ${pageCount} got empty response, retry ${ri + 1}/${retryDelays.length} after ${retryDelays[ri]}ms` + ) + await new Promise((r) => setTimeout(r, retryDelays[ri])) + const retryFile = await this.fetcher.fetchToTempFile(ds.baseUrl, sess.remoteSessionId, ds.token, { + since, + offset, + limit: ds.pullLimit, + }) + const retryStat = fs.statSync(retryFile) + this.logger.info(`[Pull] "${sess.name}" retry ${ri + 1}: fetched ${retryStat.size} bytes`) + const retrySync = parseSyncFromFile(retryFile) + if (retryStat.size < 1024 && !fileContainsMessages(retryFile) && retrySync?.hasMore === false) { + // 空 retry 页也可能只返回服务端 watermark,跳过导入前仍要推进保存游标。 + if (retrySync.nextSince !== undefined) nextPullSince = Math.max(nextPullSince, retrySync.nextSince) + cleanupTempFile(retryFile) + continue + } + const retryMaxTs = getMaxMessageTimestampFromFile(retryFile) + const retryResult = await this.importTempFile(ds.baseUrl, sess, retryFile) + cleanupTempFile(retryFile) + + if (retryResult.needFullResync && !resyncAttempted) { + resyncAttempted = true + this.logger.info(`[Pull] Resetting since=0 for "${sess.name}" full resync`) + since = 0 + offset = undefined + nextPullSince = 0 + pageCount = 0 + sess.targetSessionId = '' + sess.lastPullAt = 0 + this.dsManager.updateSession(sourceId, sess.id, { targetSessionId: '', lastPullAt: 0 }) + retrySuccess = true + retryHasMore = true + break + } + + if (retryResult.needFullResync) { + const errMsg = 'Full resync failed' + this.logger.error(`[Pull] Full resync already attempted for "${sess.name}", aborting`) + this.dsManager.updateSession(sourceId, sess.id, { + lastPullAt: Math.floor(Date.now() / 1000), + lastStatus: 'error', + lastError: errMsg, + }) + this.notifier.onPullResult(sourceId, sess.id, 'error', errMsg) + this.markProgressDone(sess.id) + return { success: false, newMessageCount: 0, error: errMsg } + } + + if (!retryResult.success) { + const errMsg = retryResult.error || 'Import failed' + this.dsManager.updateSession(sourceId, sess.id, { + lastPullAt: Math.floor(Date.now() / 1000), + lastStatus: 'error', + lastError: errMsg, + }) + this.notifier.onPullResult(sourceId, sess.id, 'error', errMsg) + this.markProgressDone(sess.id) + return { success: false, newMessageCount: 0, error: errMsg } + } + + if (retryResult.success && retryResult.sessionId && !sess.targetSessionId) { + sess.targetSessionId = retryResult.sessionId + this.dsManager.updateSession(sourceId, sess.id, { targetSessionId: retryResult.sessionId }) + } + totalNewMessages += retryResult.newMessageCount + if (retryMaxTs !== null) nextPullSince = Math.max(nextPullSince, retryMaxTs) + // retry 终止页也可能携带服务端 watermark,必须保存,否则下次会重复拉取尾部窗口。 + if (retrySync?.nextSince !== undefined) nextPullSince = Math.max(nextPullSince, retrySync.nextSince) + this.progressMap.set(sess.id, { + sessionId: sess.id, + sessionName: sess.name, + current: totalNewMessages, + pages: pageCount, + done: false, + }) + if (retrySync?.hasMore && retrySync.nextSince !== undefined) { + since = retrySync.nextSince + offset = undefined + } else if (retrySync?.hasMore && retrySync.nextOffset !== undefined) { + offset = retrySync.nextOffset + } + retryHasMore = !!retrySync?.hasMore + retrySuccess = true + break + } + if (!retrySuccess) break + if (!retryHasMore) break + continue + } + } + + if (stat.size === 0) { + cleanupTempFile(tempFile) + break + } + + const sync = sync0 + const maxMessageTs = getMaxMessageTimestampFromFile(tempFile) + const result = await this.importTempFile(ds.baseUrl, sess, tempFile) + cleanupTempFile(tempFile) + + if (result.needFullResync && !resyncAttempted) { + resyncAttempted = true + this.logger.info(`[Pull] Resetting since=0 for "${sess.name}" full resync`) + since = 0 + offset = undefined + nextPullSince = 0 + pageCount = 0 + sess.targetSessionId = '' + sess.lastPullAt = 0 + this.dsManager.updateSession(sourceId, sess.id, { targetSessionId: '', lastPullAt: 0 }) + continue + } + + if (result.needFullResync) { + const errMsg = 'Full resync failed' + this.logger.error(`[Pull] Full resync already attempted for "${sess.name}", aborting`) + this.dsManager.updateSession(sourceId, sess.id, { + lastPullAt: Math.floor(Date.now() / 1000), + lastStatus: 'error', + lastError: errMsg, + }) + this.notifier.onPullResult(sourceId, sess.id, 'error', errMsg) + this.markProgressDone(sess.id) + return { success: false, newMessageCount: 0, error: errMsg } + } + + if (!result.success) { + const errMsg = result.error || 'Import failed' + this.dsManager.updateSession(sourceId, sess.id, { + lastPullAt: Math.floor(Date.now() / 1000), + lastStatus: 'error', + lastError: errMsg, + }) + this.notifier.onPullResult(sourceId, sess.id, 'error', errMsg) + this.markProgressDone(sess.id) + return { success: false, newMessageCount: 0, error: errMsg } + } + + if (!sess.targetSessionId && result.sessionId) { + sess.targetSessionId = result.sessionId + this.dsManager.updateSession(sourceId, sess.id, { targetSessionId: result.sessionId }) + } + + totalNewMessages += result.newMessageCount + if (maxMessageTs !== null) nextPullSince = Math.max(nextPullSince, maxMessageTs) + this.progressMap.set(sess.id, { + sessionId: sess.id, + sessionName: sess.name, + current: totalNewMessages, + pages: pageCount, + done: false, + }) + + if (sync?.nextSince !== undefined) nextPullSince = Math.max(nextPullSince, sync.nextSince) + + if (!sync || !sync.hasMore) break + + // 游标推进优先使用时间戳链;旧数据源只返回 nextOffset 时,继续使用 offset 续拉同一个 since 窗口。 + if (sync.nextSince !== undefined) { + since = sync.nextSince + offset = undefined + } else if (sync.nextOffset !== undefined) { + offset = sync.nextOffset + } else { + this.logger.warn(`[Pull] "${sess.name}" returned hasMore=true without nextSince or nextOffset, stopping`) + break + } + } catch (importErr) { + cleanupTempFile(tempFile) + throw importErr + } + } + + if (pageCount >= MAX_PAGES_PER_PULL) { + this.logger.warn(`[Pull] "${sess.name}" reached page limit (${MAX_PAGES_PER_PULL}), data may be incomplete`) + } + + // 保留 overlap 窗口,但成功拉取未观察到更新游标时不能把已保存游标向后移动。 + const savedLastPullAt = Math.max(sess.lastPullAt, Math.max(0, nextPullSince - PULL_OVERLAP_SECONDS)) + this.dsManager.updateSession(sourceId, sess.id, { + lastPullAt: savedLastPullAt, + lastStatus: 'success', + lastNewMessages: totalNewMessages, + lastError: '', + }) + if (totalNewMessages > 0) this.notifier.onSessionListChanged() + this.notifier.onPullResult(sourceId, sess.id, 'success', `+${totalNewMessages} messages`) + this.markProgressDone(sess.id) + if (this.onSessionImported && sess.targetSessionId) { + try { + this.onSessionImported(sess.targetSessionId) + } catch (hookErr) { + this.logger.warn(`[Pull] onSessionImported hook failed for "${sess.name}": ${hookErr}`) + } + } + return { success: true, newMessageCount: totalNewMessages } + } catch (error: any) { + const errMsg = error.message || 'Pull failed' + this.logger.error(`[Pull] Pull failed for "${sess.name}"`, error) + this.dsManager.updateSession(sourceId, sess.id, { + lastPullAt: Math.floor(Date.now() / 1000), + lastStatus: 'error', + lastError: errMsg, + }) + this.notifier.onPullResult(sourceId, sess.id, 'error', errMsg) + this.markProgressDone(sess.id) + return { success: false, newMessageCount: 0, error: errMsg } + } + } + + private markProgressDone(sessionId: string): void { + const p = this.progressMap.get(sessionId) + if (p) { + p.done = true + setTimeout(() => this.progressMap.delete(sessionId), 5000) + } + } + + async pullAllSessions(ds: DataSource): Promise { + if (this.pullingSourceIds.has(ds.id)) { + this.logger.info(`[Pull] Skipping pullAllSessions for "${ds.baseUrl}": pull already in progress`) + return + } + this.pullingSourceIds.add(ds.id) + try { + for (const sess of ds.sessions) { + await this.executePullSession(ds.id, ds, sess) + } + } finally { + this.pullingSourceIds.delete(ds.id) + } + } + + async triggerPull(sourceId: string, sessionId?: string): Promise<{ success: boolean; error?: string }> { + const ds = this.dsManager.get(sourceId) + if (!ds) return { success: false, error: 'Data source not found' } + + if (sessionId) { + const sess = ds.sessions.find((s) => s.id === sessionId) + if (!sess) return { success: false, error: 'Session not found' } + const result = await this.executePullSession(sourceId, ds, sess) + return { success: result.success, error: result.error } + } + + if (this.pullingSourceIds.has(sourceId)) { + this.logger.info(`[Pull] Skipping triggerPull for source ${sourceId}: pull already in progress`) + return { success: true } + } + this.pullingSourceIds.add(sourceId) + try { + const errors: string[] = [] + for (const sess of ds.sessions) { + const result = await this.executePullSession(sourceId, ds, sess) + if (!result.success && result.error) errors.push(`${sess.name}: ${result.error}`) + } + if (errors.length > 0) { + return { success: false, error: errors.join('; ') } + } + return { success: true } + } finally { + this.pullingSourceIds.delete(sourceId) + } + } + + async triggerPullAll(sourceId: string): Promise<{ success: boolean; error?: string }> { + return this.triggerPull(sourceId) + } +} diff --git a/packages/sync/src/scheduler.ts b/packages/sync/src/scheduler.ts new file mode 100644 index 000000000..978d79de1 --- /dev/null +++ b/packages/sync/src/scheduler.ts @@ -0,0 +1,95 @@ +/** + * @openchatlab/sync — Timer-based pull scheduler + * + * Manages one timer per DataSource; each tick pulls all ImportSessions. + * Pure setInterval logic, no platform dependencies. + */ + +import { NOOP_LOGGER } from './types' +import type { DataSource, SyncLogger } from './types' +import type { DataSourceManager } from './data-source-manager' +import type { PullEngine } from './pull-engine' + +const timers = new Map>() +let initialized = false + +export interface SchedulerOptions { + dsManager: DataSourceManager + pullEngine: PullEngine + logger?: SyncLogger +} + +let _dsManager: DataSourceManager +let _pullEngine: PullEngine +let _logger: SyncLogger + +function startTimer(ds: DataSource, skipInitialPull = false): void { + stopTimer(ds.id) + if (!ds.enabled || ds.intervalMinutes < 1 || ds.sessions.length === 0) return + + const intervalMs = ds.intervalMinutes * 60 * 1000 + + if (!skipInitialPull) { + _pullEngine.pullAllSessions(ds).catch((err) => { + _logger.error('[Pull] Initial pull failed', err) + }) + } + + const timer = setInterval(() => { + const current = _dsManager.loadAll().find((s) => s.id === ds.id) + if (!current || !current.enabled || current.sessions.length === 0) { + stopTimer(ds.id) + return + } + _pullEngine.pullAllSessions(current).catch((err) => { + _logger.error('[Pull] Scheduled pull failed', err) + }) + }, intervalMs) + + timers.set(ds.id, timer) + _logger.info( + `[Pull] Timer started for source ${ds.baseUrl} (${ds.sessions.length} sessions, every ${ds.intervalMinutes}min)` + ) +} + +export function stopTimer(id: string): void { + const timer = timers.get(id) + if (timer) { + clearInterval(timer) + timers.delete(id) + } +} + +export function initScheduler(options: SchedulerOptions): void { + if (initialized) return + initialized = true + + _dsManager = options.dsManager + _pullEngine = options.pullEngine + _logger = options.logger ?? NOOP_LOGGER + + const sources = _dsManager.loadAll() + for (const ds of sources) { + if (ds.enabled && ds.sessions.length > 0) { + startTimer(ds) + } + } + + _logger.info(`[Pull] Initialized with ${sources.filter((s) => s.enabled).length} active sources`) +} + +export function stopAllTimers(): void { + for (const [id] of timers) { + stopTimer(id) + } + initialized = false + _logger?.info('[Pull] All timers stopped') +} + +export function reloadTimer(dsId: string, skipInitialPull = false): void { + stopTimer(dsId) + const ds = _dsManager?.loadAll().find((s) => s.id === dsId) + if (ds && ds.enabled) { + startTimer(ds, skipInitialPull) + } +} diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts new file mode 100644 index 000000000..9929462a3 --- /dev/null +++ b/packages/sync/src/types.ts @@ -0,0 +1,153 @@ +/** + * @openchatlab/sync — Shared type definitions + * + * Platform-agnostic types used by config manager, data source manager, + * pull engine, and scheduler. + */ + +// ==================== API Server Config ==================== + +export interface ApiServerConfig { + enabled: boolean + port: number + token: string + createdAt: number +} + +// ==================== Data Source / Import Session ==================== + +export interface ImportSession { + id: string + name: string + remoteSessionId: string + targetSessionId: string + lastPullAt: number + lastStatus: 'idle' | 'success' | 'error' + lastError: string + lastNewMessages: number +} + +export interface DataSource { + id: string + name: string + baseUrl: string + token: string + intervalMinutes: number + pullLimit: number + enabled: boolean + createdAt: number + sessions: ImportSession[] +} + +export type DataSourceUpdatable = Partial< + Pick +> + +// ==================== Remote Discovery ==================== + +export interface RemoteSession { + id: string + name: string + platform: string + type: string + messageCount?: number + memberCount?: number + lastMessageAt?: number +} + +export interface RemoteSessionDiscoveryPage { + hasMore: boolean + nextCursor?: string +} + +export interface RemoteSessionDiscoveryResult { + sessions: RemoteSession[] + page?: RemoteSessionDiscoveryPage +} + +export interface RemoteSessionDiscoveryQuery { + keyword?: string + limit?: number + cursor?: string +} + +// ==================== Pull Engine Abstractions ==================== + +export interface FetchParams { + since?: number + offset?: number + end?: number + limit?: number +} + +export interface SyncMeta { + hasMore: boolean + nextSince?: number + nextOffset?: number + watermark?: number +} + +export interface ImportResult { + success: boolean + newMessageCount: number + sessionId?: string + error?: string + needFullResync?: boolean +} + +export interface PullSessionResult { + success: boolean + newMessageCount: number + error?: string +} + +export interface PullProgress { + sessionId: string + sessionName: string + current: number + pages: number + done: boolean +} + +/** + * Downloads remote data to a temporary file. + * Platform implementations: Electron uses `net.request`, Node.js uses `fetch`. + */ +export interface HttpFetcher { + fetchToTempFile(baseUrl: string, remoteSessionId: string, token: string, params: FetchParams): Promise +} + +/** + * Imports a downloaded temp file into a local session database. + * Platform implementations: Electron uses worker IPC, Server uses DatabaseManager. + */ +export interface DataImporter { + /** + * Import temp file into an existing or new local session. + * If `targetSessionId` is provided, attempt incremental import. + * Otherwise, create a new session (using `externalId` for deterministic naming). + */ + importFile(tempFile: string, targetSessionId: string | undefined, externalId: string): Promise + + /** Check if a local session database exists */ + sessionExists(sessionId: string): boolean +} + +/** + * Notifies the UI about sync events. + * Electron uses BrowserWindow.webContents.send, Server can use SSE or noop. + */ +export interface SyncNotifier { + onSessionListChanged(): void + onPullResult(sourceId: string, sessionId: string | undefined, status: 'success' | 'error', detail: string): void +} + +export interface SyncLogger { + info(message: string): void + warn(message: string): void + error(message: string, err?: unknown): void +} + +const noop = () => {} + +export const NOOP_LOGGER: SyncLogger = { info: noop, warn: noop, error: noop } diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json new file mode 100644 index 000000000..86467ed10 --- /dev/null +++ b/packages/sync/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.node.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/tools/package.json b/packages/tools/package.json new file mode 100644 index 000000000..17f500eec --- /dev/null +++ b/packages/tools/package.json @@ -0,0 +1,13 @@ +{ + "name": "@openchatlab/tools", + "version": "0.0.0", + "private": true, + "description": "ChatLab AI 工具链:工具定义 + 平台无关 handler,服务于 MCP/HTTP/Electron", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "@openchatlab/core": "workspace:*", + "@openchatlab/shared-types": "workspace:*" + } +} diff --git a/packages/tools/src/definitions/analysis-tools.test.ts b/packages/tools/src/definitions/analysis-tools.test.ts new file mode 100644 index 000000000..bb0ad25ea --- /dev/null +++ b/packages/tools/src/definitions/analysis-tools.test.ts @@ -0,0 +1,241 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { getSegmentMessagesTool } from './get-segment-messages' +import { getSegmentSummariesTool } from './get-segment-summaries' +import { searchMessagesTool } from './search-messages' +import { schemaTool, sqlQueryTool } from './sql-query' +import { SQL_TOOL_DEFS, createSqlToolDefinition } from '../sql' +import type { RawMessage, ToolDataProvider, ToolExecutionContext, ToolTimeRange } from '../types' + +function createContext( + dataProvider: Partial, + overrides: Partial = {} +): ToolExecutionContext { + return { + sessionId: 'session-1', + locale: 'en-US', + dataProvider: dataProvider as ToolDataProvider, + ...overrides, + } +} + +function createSqlTool(name: string) { + const def = SQL_TOOL_DEFS.find((tool) => tool.name === name) + assert.ok(def, `Expected SQL tool definition for ${name}`) + return { def, tool: createSqlToolDefinition(def) } +} + +describe('high-risk analysis tool definitions', () => { + it('search_messages passes filters to the provider and returns expanded context messages', async () => { + const contextFilter: ToolTimeRange = { startTs: 1710000000, endTs: 1710000100 } + const searchCalls: Array<{ keywords: string[]; options: unknown }> = [] + const contextCalls: Array<{ ids: number[]; before: number; after: number }> = [] + const expandedMessages: RawMessage[] = [ + { id: 10, senderName: 'Alice', content: 'before', timestamp: 1710000001 }, + { id: 11, senderName: 'Bob', content: 'alpha hit', timestamp: 1710000002 }, + { id: 12, senderName: 'Alice', content: 'after', timestamp: 1710000003 }, + ] + const context = createContext( + { + async searchMessages(keywords, options) { + searchCalls.push({ keywords, options }) + return { + total: 1, + messages: [{ id: 11, senderName: 'Bob', content: 'alpha hit', timestamp: 1710000002 }], + } + }, + async getSearchMessageContext(ids, before, after) { + contextCalls.push({ ids, before, after }) + return expandedMessages + }, + }, + { + timeFilter: contextFilter, + maxMessagesLimit: 4, + searchContextBefore: 1, + searchContextAfter: 1, + } + ) + + const result = await searchMessagesTool.handler({ keywords: ['alpha'], sender_id: 7, limit: 100 }, context) + + assert.deepEqual(searchCalls, [ + { + keywords: ['alpha'], + options: { timeFilter: contextFilter, limit: 4, senderId: 7 }, + }, + ]) + assert.deepEqual(contextCalls, [{ ids: [11], before: 1, after: 1 }]) + assert.deepEqual(result.rawMessages, expandedMessages) + assert.deepEqual((result.data as { total: number; returned: number }).total, 1) + assert.deepEqual((result.data as { total: number; returned: number }).returned, 3) + }) + + it('get_segment_messages applies maxMessagesLimit before returning raw messages', async () => { + const calls: Array<{ segmentId: number; limit?: number }> = [] + const context = createContext( + { + async getSegmentMessages(segmentId, limit) { + calls.push({ segmentId, limit }) + return { + segmentId, + startTs: 1704067200, + endTs: 1704067260, + messageCount: 3, + returnedCount: 2, + participants: ['Alice', 'Bob'], + messages: [ + { id: 1, senderName: 'Alice', content: 'first', timestamp: 1704067201 }, + { id: 2, senderName: 'Bob', content: 'second', timestamp: 1704067202 }, + ], + } + }, + }, + { maxMessagesLimit: 2 } + ) + + const result = await getSegmentMessagesTool.handler({ segment_id: 42, limit: 100 }, context) + const data = result.data as { segmentId: number; returnedCount: number; participants: string[] } + + assert.deepEqual(calls, [{ segmentId: 42, limit: 2 }]) + assert.equal(data.segmentId, 42) + assert.equal(data.returnedCount, 2) + assert.deepEqual(data.participants, ['Alice', 'Bob']) + assert.deepEqual(result.rawMessages, [ + { id: 1, senderName: 'Alice', content: 'first', timestamp: 1704067201 }, + { id: 2, senderName: 'Bob', content: 'second', timestamp: 1704067202 }, + ]) + }) + + it('get_segment_summaries filters empty and non-matching summaries after over-fetching', async () => { + const calls: Array<{ limit?: number; timeFilter?: ToolTimeRange }> = [] + const contextFilter: ToolTimeRange = { startTs: 1704067200, endTs: 1704153600 } + const context = createContext( + { + async getSegmentSummaries(options) { + calls.push(options ?? {}) + return [ + { + id: 1, + startTs: 1704067200, + endTs: 1704067260, + messageCount: 2, + participants: ['Alice'], + summary: 'Launch plan discussion', + }, + { + id: 2, + startTs: 1704067300, + endTs: 1704067360, + messageCount: 1, + participants: ['Bob'], + summary: null, + }, + { + id: 3, + startTs: 1704067400, + endTs: 1704067460, + messageCount: 1, + participants: ['Cara'], + summary: 'Unrelated topic', + }, + ] + }, + }, + { timeFilter: contextFilter } + ) + + const result = await getSegmentSummariesTool.handler({ keywords: ['launch'], limit: 1 }, context) + const data = result.data as { + total: number + returned: number + segments: Array<{ segmentId: number; summary: string | null }> + } + + assert.deepEqual(calls, [{ limit: 2, timeFilter: contextFilter }]) + assert.equal(data.total, 1) + assert.equal(data.returned, 1) + assert.deepEqual( + data.segments.map((s) => s.segmentId), + [1] + ) + assert.equal(data.segments[0]?.summary, 'Launch plan discussion') + }) + + it('execute_sql surfaces provider read-only errors without returning bogus data', async () => { + const context = createContext({ + async executeSql(sql) { + assert.equal(sql, 'DELETE FROM message') + throw new Error('Only SELECT statements are allowed') + }, + }) + + const result = await sqlQueryTool.handler({ sql: 'DELETE FROM message' }, context) + + assert.equal(result.data, undefined) + assert.deepEqual(JSON.parse(result.content), { error: 'Only SELECT statements are allowed' }) + }) + + it('get_schema returns table definitions from the provider', async () => { + const schema = [{ name: 'message', sql: 'CREATE TABLE message (id INTEGER)' }] + const context = createContext({ + async getSchema() { + return schema + }, + }) + + const result = await schemaTool.handler({}, context) + + assert.deepEqual(result.data, schema) + assert.deepEqual(JSON.parse(result.content), { tables: schema }) + }) + + it('mutual_interaction_pairs uses adjacent-message windows instead of a message self-join', async () => { + const { def } = createSqlTool('mutual_interaction_pairs') + + assert.doesNotMatch(def.execution.query, /JOIN message b ON b\.sender_id != a\.sender_id/) + assert.match(def.execution.query, /LAG\(sender_id\) OVER/) + assert.match(def.execution.query, /ordered_messages/) + }) + + it('mutual_interaction_pairs formats adjacent interaction rows from the provider', async () => { + const { tool } = createSqlTool('mutual_interaction_pairs') + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext({ + async executeParameterizedSql>(query: string, params: Record) { + calls.push({ query, params }) + return [{ member_a: 'Alice', member_b: 'Bob', interaction_count: 2 }] as T[] + }, + }) + + const result = await tool.handler({ days: 30, limit: 5 }, context) + + assert.equal(calls.length, 1) + assert.equal(calls[0]?.params.days, 30) + assert.equal(calls[0]?.params.limit, 5) + assert.match(result.content, /Alice/) + assert.match(result.content, /Bob/) + assert.deepEqual((result.data as { rows: unknown[]; rowCount: number }).rowCount, 1) + }) + + it('reply_interaction_ranking still ranks explicit reply relationships', async () => { + const { def, tool } = createSqlTool('reply_interaction_ranking') + const context = createContext({ + async executeParameterizedSql>(query: string, params: Record) { + assert.equal(query, def.execution.query) + assert.deepEqual(params, { days: 14, limit: 3 }) + return [{ replier_name: 'Bob', original_name: 'Alice', reply_count: 4 }] as T[] + }, + }) + + assert.match(def.execution.query, /reply_to_message_id/) + assert.match(def.execution.query, /ORDER BY reply_count DESC/) + + const result = await tool.handler({ days: 14, limit: 3 }, context) + + assert.match(result.content, /Bob/) + assert.match(result.content, /Alice/) + assert.match(result.content, /4/) + }) +}) diff --git a/packages/tools/src/definitions/chat-overview.ts b/packages/tools/src/definitions/chat-overview.ts new file mode 100644 index 000000000..221fe7aa9 --- /dev/null +++ b/packages/tools/src/definitions/chat-overview.ts @@ -0,0 +1,58 @@ +/** + * 聊天概览工具 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { isChineseLocale } from '../utils/format' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + top_n: { type: 'number', description: '返回活跃度最高的前 N 个成员,默认 10' }, + }, +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale } = context + const topN = (params.top_n as number) || 10 + + const result = await context.dataProvider!.getChatOverview(topN) + if (!result) { + const msg = isChineseLocale(locale) ? '无法获取聊天概览' : 'Unable to get chat overview' + return { content: msg, data: { error: msg } } + } + + const msgSuffix = isChineseLocale(locale) ? '条' : '' + const lines: string[] = [ + `name: ${result.name}`, + `platform: ${result.platform}`, + `type: ${result.type}`, + `totalMessages: ${result.totalMessages}`, + `totalMembers: ${result.totalMembers}`, + ] + + if (result.firstMessageTs != null && result.lastMessageTs != null) { + const start = new Date(result.firstMessageTs * 1000).toLocaleDateString() + const end = new Date(result.lastMessageTs * 1000).toLocaleDateString() + lines.push(`timeRange: ${start} ~ ${end}`) + } + + if (result.topMembers.length > 0) { + lines.push(`topMembers:`) + for (let i = 0; i < result.topMembers.length; i++) { + const m = result.topMembers[i] + const pct = result.totalMessages > 0 ? ((m.count / result.totalMessages) * 100).toFixed(1) : '0' + lines.push(`${i + 1}. ${m.name} ${m.count}${msgSuffix}(${pct}%)`) + } + } + + return { content: lines.join('\n'), data: result } +} + +export const chatOverviewTool: ToolDefinition = { + name: 'get_chat_overview', + description: '获取聊天概览信息,包括聊天名称、平台、总消息数、总成员数、时间范围和活跃成员排行', + inputSchema, + handler, + category: 'core', +} diff --git a/packages/tools/src/definitions/deep-search-messages.ts b/packages/tools/src/definitions/deep-search-messages.ts new file mode 100644 index 000000000..bea029542 --- /dev/null +++ b/packages/tools/src/definitions/deep-search-messages.ts @@ -0,0 +1,64 @@ +/** + * 深度搜索消息工具 + * + * LIKE 子串匹配,速度较慢但不会遗漏。与 search-messages 结构相同,底层使用不同搜索策略。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { parseExtendedTimeParams } from '../utils/time-params' +import { formatTimeRange } from '../utils/format' +import { timeParamProperties } from '../utils/schemas' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + keywords: { type: 'array', items: { type: 'string' }, description: '搜索关键词列表' }, + sender_id: { type: 'number', description: '按发送者 ID 过滤(通过 get_members 获取)' }, + limit: { type: 'number', description: '返回的最大消息条数' }, + ...timeParamProperties, + }, + required: ['keywords'], +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale, timeFilter: contextTimeFilter, maxMessagesLimit } = context + const keywords = params.keywords as string[] + const limit = Math.min(maxMessagesLimit || (params.limit as number) || 1000, 50000) + const effectiveTimeFilter = parseExtendedTimeParams(params as any, contextTimeFilter) + + const result = await context.dataProvider!.deepSearchMessages(keywords, { + timeFilter: effectiveTimeFilter, + limit, + senderId: params.sender_id as number | undefined, + }) + + const contextBefore = context.searchContextBefore ?? 2 + const contextAfter = context.searchContextAfter ?? 2 + let finalMessages = result.messages + + if ((contextBefore > 0 || contextAfter > 0) && result.messages.length > 0) { + const hitIds = result.messages.map((m) => m.id).filter((id): id is number => id != null) + if (hitIds.length > 0) { + finalMessages = await context.dataProvider!.getSearchMessageContext(hitIds, contextBefore, contextAfter) + } + } + + const data = { + total: result.total, + returned: finalMessages.length, + timeRange: formatTimeRange(effectiveTimeFilter, locale), + rawMessages: finalMessages, + } + + return { content: JSON.stringify(data), data, rawMessages: finalMessages } +} + +export const deepSearchMessagesTool: ToolDefinition = { + name: 'deep_search_messages', + description: + '深度搜索消息(LIKE 子串匹配,速度较慢但不会遗漏),适用于全文搜索精确匹配或 FTS 搜索无结果时的补充搜索。', + inputSchema, + handler, + category: 'core', + truncationStrategy: 'keep_first', +} diff --git a/packages/tools/src/definitions/get-conversation-between.ts b/packages/tools/src/definitions/get-conversation-between.ts new file mode 100644 index 000000000..bd4c332d3 --- /dev/null +++ b/packages/tools/src/definitions/get-conversation-between.ts @@ -0,0 +1,61 @@ +/** + * 两人对话查询工具 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { parseExtendedTimeParams } from '../utils/time-params' +import { formatTimeRange, t } from '../utils/format' +import { timeParamProperties } from '../utils/schemas' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + member_id_1: { type: 'number', description: '第一个成员的 ID(通过 get_members 获取)' }, + member_id_2: { type: 'number', description: '第二个成员的 ID(通过 get_members 获取)' }, + limit: { type: 'number', description: '返回的最大消息条数' }, + ...timeParamProperties, + }, + required: ['member_id_1', 'member_id_2'], +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale, timeFilter: contextTimeFilter, maxMessagesLimit } = context + const limit = maxMessagesLimit || (params.limit as number) || 100 + const effectiveTimeFilter = parseExtendedTimeParams(params as any, contextTimeFilter) + + const result = await context.dataProvider!.getConversationBetween( + params.member_id_1 as number, + params.member_id_2 as number, + effectiveTimeFilter, + limit + ) + + if (result.messages.length === 0) { + const data = { + error: t('noConversation', locale), + member1Id: params.member_id_1, + member2Id: params.member_id_2, + } + return { content: JSON.stringify(data), data } + } + + const data = { + total: result.total, + returned: result.messages.length, + member1: result.member1Name, + member2: result.member2Name, + timeRange: formatTimeRange(effectiveTimeFilter, locale), + rawMessages: result.messages, + } + + return { content: JSON.stringify(data), data, rawMessages: result.messages } +} + +export const getConversationBetweenTool: ToolDefinition = { + name: 'get_conversation_between', + description: '获取两个群成员之间的对话记录。适用于回答"A和B之间聊了什么"等问题。需要先通过 get_members 获取成员 ID。', + inputSchema, + handler, + category: 'analysis', + truncationStrategy: 'keep_last', +} diff --git a/packages/tools/src/definitions/get-member-name-history.ts b/packages/tools/src/definitions/get-member-name-history.ts new file mode 100644 index 000000000..3dbd4d055 --- /dev/null +++ b/packages/tools/src/definitions/get-member-name-history.ts @@ -0,0 +1,64 @@ +/** + * 获取成员昵称变更历史工具 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { isChineseLocale, t } from '../utils/format' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + member_id: { type: 'number', description: '成员 ID(通过 get_members 获取)' }, + }, + required: ['member_id'], +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale } = context + const memberId = params.member_id as number + + const members = await context.dataProvider!.getMembers() + const member = members.find((m) => m.id === memberId) + + if (!member) { + const data = { + error: t('memberNotFound', locale), + member_id: memberId, + } + return { content: JSON.stringify(data), data } + } + + const history = await context.dataProvider!.getMemberNameHistory(memberId) + + const localeStr = isChineseLocale(locale) ? 'zh-CN' : 'en-US' + const untilNow = t('untilNow', locale) + const formatHistory = (h: { name: string; startTs: number; endTs: number | null }) => { + const start = new Date(h.startTs * 1000).toLocaleDateString(localeStr) + const end = h.endTs ? new Date(h.endTs * 1000).toLocaleDateString(localeStr) : untilNow + return `${h.name} (${start} ~ ${end})` + } + + const accountNames = history.filter((h) => h.nameType === 'account_name').map(formatHistory) + const groupNicknames = history.filter((h) => h.nameType === 'group_nickname').map(formatHistory) + + const displayName = member.groupNickname || member.accountName || member.platformId + const aliasLabel = t('alias', locale) + const aliasStr = member.aliases.length > 0 ? `|${aliasLabel}:${member.aliases.join(',')}` : '' + const noChangeRecord = t('noChangeRecord', locale) + + const data = { + member: `${member.id}|${member.platformId}|${displayName}${aliasStr}`, + accountNameHistory: accountNames.length > 0 ? accountNames : noChangeRecord, + groupNicknameHistory: groupNicknames.length > 0 ? groupNicknames : noChangeRecord, + } + + return { content: JSON.stringify(data), data } +} + +export const getMemberNameHistoryTool: ToolDefinition = { + name: 'get_member_name_history', + description: '获取成员的昵称变更历史记录。适用于回答"某人以前叫什么名字"、"某人的昵称变化"等问题。', + inputSchema, + handler, + category: 'analysis', +} diff --git a/packages/tools/src/definitions/get-members.ts b/packages/tools/src/definitions/get-members.ts new file mode 100644 index 000000000..a66c836ff --- /dev/null +++ b/packages/tools/src/definitions/get-members.ts @@ -0,0 +1,57 @@ +/** + * 获取成员列表工具 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { isChineseLocale, t } from '../utils/format' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + search: { type: 'string', description: '按名称/别名/平台ID搜索过滤' }, + limit: { type: 'number', description: '最大返回数量' }, + }, +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale } = context + const members = await context.dataProvider!.getMembers() + + let filteredMembers = members + if (params.search) { + const keyword = (params.search as string).toLowerCase() + filteredMembers = members.filter((m) => { + if (m.groupNickname && m.groupNickname.toLowerCase().includes(keyword)) return true + if (m.accountName && m.accountName.toLowerCase().includes(keyword)) return true + if (m.platformId.includes(keyword)) return true + if (m.aliases.some((alias) => alias.toLowerCase().includes(keyword))) return true + return false + }) + } + + if (params.limit && (params.limit as number) > 0) { + filteredMembers = filteredMembers.slice(0, params.limit as number) + } + + const msgSuffix = isChineseLocale(locale) ? '条' : '' + const aliasLabel = t('alias', locale) + const data = { + totalMembers: members.length, + returnedMembers: filteredMembers.length, + members: filteredMembers.map((m) => { + const displayName = m.groupNickname || m.accountName || m.platformId + const aliasStr = m.aliases.length > 0 ? `|${aliasLabel}:${m.aliases.join(',')}` : '' + return `${m.id}|${m.platformId}|${displayName}|${m.messageCount}${msgSuffix}${aliasStr}` + }), + } + + return { content: JSON.stringify(data), data } +} + +export const getMembersTool: ToolDefinition = { + name: 'get_members', + description: '获取成员列表,包括成员的基本信息、别名和消息统计。适用于查询"有哪些人"、"某人的别名是什么"等问题。', + inputSchema, + handler, + category: 'core', +} diff --git a/packages/tools/src/definitions/get-message-context.ts b/packages/tools/src/definitions/get-message-context.ts new file mode 100644 index 000000000..096c29932 --- /dev/null +++ b/packages/tools/src/definitions/get-message-context.ts @@ -0,0 +1,51 @@ +/** + * 消息上下文工具 + * + * 根据消息 ID 获取前后的上下文消息。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { t } from '../utils/format' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + message_ids: { type: 'array', items: { type: 'number' }, description: '消息 ID 列表' }, + context_size: { type: 'number', description: '每条消息前后获取的上下文条数,默认 20' }, + }, + required: ['message_ids'], +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale } = context + const messageIds = params.message_ids as number[] + const contextSize = (params.context_size as number) || 20 + + const messages = await context.dataProvider!.getMessageContext(messageIds, contextSize) + + if (messages.length === 0) { + const data = { + error: t('noMessageContext', locale), + messageIds, + } + return { content: JSON.stringify(data), data } + } + + const data = { + totalMessages: messages.length, + contextSize, + requestedMessageIds: messageIds, + rawMessages: messages, + } + + return { content: JSON.stringify(data), data, rawMessages: messages } +} + +export const getMessageContextTool: ToolDefinition = { + name: 'get_message_context', + description: '根据消息 ID 获取前后的上下文消息。适用于需要查看某条消息前后聊天内容的场景。', + inputSchema, + handler, + category: 'core', + truncationStrategy: 'keep_last', +} diff --git a/packages/tools/src/definitions/get-segment-messages.ts b/packages/tools/src/definitions/get-segment-messages.ts new file mode 100644 index 000000000..79c20114d --- /dev/null +++ b/packages/tools/src/definitions/get-segment-messages.ts @@ -0,0 +1,61 @@ +/** + * 获取段落完整消息工具 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { isChineseLocale } from '../utils/format' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + segment_id: { type: 'number', description: '段落 ID(通过 get_segment_summaries 获取)' }, + limit: { type: 'number', description: '返回的最大消息条数' }, + }, + required: ['segment_id'], +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale, maxMessagesLimit } = context + const limit = maxMessagesLimit || (params.limit as number) || 1000 + + const result = await context.dataProvider!.getSegmentMessages(params.segment_id as number, limit) + + if (!result) { + const data = { + error: isChineseLocale(locale) ? '未找到指定的段落' : 'Segment not found', + segmentId: params.segment_id, + } + return { content: JSON.stringify(data), data } + } + + const localeStr = isChineseLocale(locale) ? 'zh-CN' : 'en-US' + const startTime = new Date(result.startTs * 1000).toLocaleString(localeStr) + const endTime = new Date(result.endTs * 1000).toLocaleString(localeStr) + const rawMessages = result.messages.map((m) => ({ + id: m.id, + senderName: m.senderName, + content: m.content, + timestamp: m.timestamp, + })) + + const data = { + segmentId: result.segmentId, + time: `${startTime} ~ ${endTime}`, + messageCount: result.messageCount, + returnedCount: result.returnedCount, + participants: result.participants, + rawMessages, + } + + return { content: JSON.stringify(data), data, rawMessages } +} + +export const getSegmentMessagesTool: ToolDefinition = { + name: 'get_segment_messages', + description: + '获取指定段落的完整消息列表。用于在 get_segment_summaries 找到相关段落摘要后,获取该段落的完整原文上下文。', + inputSchema, + handler, + category: 'core', + truncationStrategy: 'keep_last', +} diff --git a/packages/tools/src/definitions/get-segment-summaries.ts b/packages/tools/src/definitions/get-segment-summaries.ts new file mode 100644 index 000000000..2c5ab0bef --- /dev/null +++ b/packages/tools/src/definitions/get-segment-summaries.ts @@ -0,0 +1,78 @@ +/** + * 获取段落摘要列表工具 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { parseExtendedTimeParams } from '../utils/time-params' +import { formatTimeRange, isChineseLocale } from '../utils/format' +import { timeParamProperties } from '../utils/schemas' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + keywords: { type: 'array', items: { type: 'string' }, description: '按关键词过滤摘要内容' }, + limit: { type: 'number', description: '返回的最大段落数,默认 20' }, + ...timeParamProperties, + }, +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale, timeFilter: contextTimeFilter } = context + const limit = (params.limit as number) || 20 + const effectiveTimeFilter = parseExtendedTimeParams(params as any, contextTimeFilter) + + const segments = await context.dataProvider!.getSegmentSummaries({ + limit: limit * 2, + timeFilter: effectiveTimeFilter, + }) + + if (!segments || segments.length === 0) { + const data = { + message: isChineseLocale(locale) + ? '未找到带摘要的段落。可能还没有生成摘要,请在段落时间线中点击"批量生成"按钮。' + : 'No segments with summaries found. Summaries may not have been generated yet.', + } + return { content: JSON.stringify(data), data } + } + + let filteredSegments = segments + const keywords = params.keywords as string[] | undefined + if (keywords && keywords.length > 0) { + const lowerKeywords = keywords.map((k) => k.toLowerCase()) + filteredSegments = segments.filter((s) => + lowerKeywords.some((keyword) => s.summary?.toLowerCase().includes(keyword)) + ) + } + + filteredSegments = filteredSegments.filter((s) => s.summary) + const limitedSegments = filteredSegments.slice(0, limit) + + const localeStr = isChineseLocale(locale) ? 'zh-CN' : 'en-US' + + const data = { + total: filteredSegments.length, + returned: limitedSegments.length, + timeRange: formatTimeRange(effectiveTimeFilter, locale), + segments: limitedSegments.map((s) => { + const startTime = new Date(s.startTs * 1000).toLocaleString(localeStr) + const endTime = new Date(s.endTs * 1000).toLocaleString(localeStr) + return { + segmentId: s.id, + time: `${startTime} ~ ${endTime}`, + messageCount: s.messageCount, + participants: s.participants, + summary: s.summary, + } + }), + } + + return { content: JSON.stringify(data), data } +} + +export const getSegmentSummariesTool: ToolDefinition = { + name: 'get_segment_summaries', + description: '获取段落摘要列表,快速了解群聊历史讨论的主题。可以按关键词搜索讨论过的话题。', + inputSchema, + handler, + category: 'analysis', +} diff --git a/packages/tools/src/definitions/keyword-frequency.ts b/packages/tools/src/definitions/keyword-frequency.ts new file mode 100644 index 000000000..9f0ddf362 --- /dev/null +++ b/packages/tools/src/definitions/keyword-frequency.ts @@ -0,0 +1,82 @@ +/** + * 关键词词频分析工具 + * + * 通过 SQL 获取文本消息,使用 NLP 分词统计高频词。 + * NLP 分词能力通过 context.segmentText 回调注入。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { isChineseLocale } from '../utils/format' + +interface TextRow { + content: string +} + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + days: { type: 'number', description: '分析最近多少天的数据,默认 30' }, + top_n: { type: 'number', description: '返回前多少个高频词,默认 50' }, + }, +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale, segmentText } = context + const isZh = isChineseLocale(locale) + const days = (params.days as number) || 30 + const topN = (params.top_n as number) || 50 + + if (!segmentText) { + const text = isZh ? '当前环境不支持分词功能' : 'Text segmentation is not available in this environment' + return { content: text, data: null } + } + + const sql = ` + SELECT content FROM message + WHERE type = 0 AND content IS NOT NULL AND LENGTH(content) > 1 + AND ts > unixepoch('now', '-' || @days || ' days') + LIMIT 50000 + ` + const rows = await context.dataProvider!.executeParameterizedSql(sql, { days }) + if (!rows || rows.length === 0) { + const text = isZh ? '该时间范围内没有文本消息' : 'No text messages in this time range' + return { content: text, data: null } + } + + const texts = rows.map((r) => r.content) + const segLocale: string = locale?.startsWith('ja') ? 'ja-JP' : locale?.startsWith('zh') ? 'zh-CN' : 'en-US' + const freqResult = segmentText(texts, segLocale, { + minCount: 2, + topN, + posFilterMode: 'meaningful', + enableStopwords: true, + }) + + if (freqResult.words.size === 0) { + const text = isZh ? '分词后没有有意义的高频词' : 'No meaningful high-frequency words found after segmentation' + return { content: text, data: null } + } + + const ranking = [...freqResult.words.entries()].map(([word, count], i) => ({ + rank: i + 1, + word, + count, + })) + + const data = { + period: isZh ? `近${days}天` : `Last ${days} days`, + totalMessages: rows.length, + totalKeywords: ranking.length, + keywords: ranking.map((r) => `${r.rank}. ${r.word} (${r.count}${isZh ? '次' : ''})`), + } + + return { content: JSON.stringify(data), data } +} + +export const keywordFrequencyTool: ToolDefinition = { + name: 'keyword_frequency', + description: '统计群聊中的高频关键词,通过 NLP 分词分析消息内容。', + inputSchema, + handler, + category: 'analysis', +} diff --git a/packages/tools/src/definitions/member-stats.ts b/packages/tools/src/definitions/member-stats.ts new file mode 100644 index 000000000..59eef7231 --- /dev/null +++ b/packages/tools/src/definitions/member-stats.ts @@ -0,0 +1,45 @@ +/** + * 成员统计工具 + * + * 获取成员活跃度排行和统计信息。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + top: { + type: 'number', + description: '返回前 N 个活跃成员', + default: 20, + }, + }, +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const top = (params.top as number) || 20 + const members = await context.dataProvider!.getMemberStats({ timeFilter: context.timeFilter, top }) + + const data = { + total: members.length, + members: members.map((m) => ({ + name: m.name, + messageCount: m.messageCount, + percentage: m.percentage, + })), + } + + return { + content: JSON.stringify(data), + data, + } +} + +export const memberStatsTool: ToolDefinition = { + name: 'get_member_stats', + description: '获取成员活跃度排行,包括消息数量和占比', + inputSchema, + handler, + category: 'core', +} diff --git a/packages/tools/src/definitions/recent-messages.ts b/packages/tools/src/definitions/recent-messages.ts new file mode 100644 index 000000000..e76850036 --- /dev/null +++ b/packages/tools/src/definitions/recent-messages.ts @@ -0,0 +1,52 @@ +/** + * 最近消息工具 + * + * 获取指定时间段内的群聊消息,支持 start_time/end_time 参数。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { parseExtendedTimeParams } from '../utils/time-params' +import { formatTimeRange } from '../utils/format' +import { timeParamProperties } from '../utils/schemas' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + limit: { + type: 'number', + description: '返回的消息条数', + default: 100, + }, + ...timeParamProperties, + }, +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale, timeFilter: contextTimeFilter, maxMessagesLimit } = context + const limit = maxMessagesLimit || (params.limit as number) || 100 + const effectiveTimeFilter = parseExtendedTimeParams(params as any, contextTimeFilter) + + const result = await context.dataProvider!.getRecentMessages({ timeFilter: effectiveTimeFilter, limit }) + + const data = { + total: result.total, + returned: result.messages.length, + timeRange: formatTimeRange(effectiveTimeFilter, locale), + rawMessages: result.messages, + } + + return { + content: JSON.stringify(data), + data, + rawMessages: result.messages, + } +} + +export const recentMessagesTool: ToolDefinition = { + name: 'get_recent_messages', + description: '获取指定时间段内的群聊消息。适用于回答"最近大家聊了什么"等概览性问题。支持精确到分钟级别的时间查询。', + inputSchema, + handler, + category: 'core', + truncationStrategy: 'keep_last', +} diff --git a/packages/tools/src/definitions/render-chart.test.ts b/packages/tools/src/definitions/render-chart.test.ts new file mode 100644 index 000000000..89b7523f8 --- /dev/null +++ b/packages/tools/src/definitions/render-chart.test.ts @@ -0,0 +1,191 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { renderChartTool } from './render-chart' +import type { ToolDataProvider, ToolExecutionContext } from '../types' + +const barSpec = { + version: 1, + type: 'bar', + title: 'Messages by member', + encoding: { x: 'name', y: 'message_count' }, +} as const + +function createContext( + rows: Record[], + calls: Array<{ query: string; params: Record }> +): ToolExecutionContext { + const dataProvider = { + async executeParameterizedSql(query: string, params: Record) { + calls.push({ query, params }) + return rows + }, + } as Partial as ToolDataProvider + + return { + sessionId: 'session-1', + locale: 'en-US', + dataProvider, + } +} + +describe('renderChartTool', () => { + it('runs parameterized read-only SQL and returns a normalized chart payload', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext( + [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + ], + calls + ) + + const result = await renderChartTool.handler( + { + sql: 'SELECT name, message_count FROM member_stats WHERE days = @days', + params: { days: 7 }, + chartSpec: barSpec, + maxRows: 2, + }, + context + ) + + assert.equal(calls.length, 1) + assert.equal( + calls[0]?.query, + 'SELECT * FROM (\nSELECT name, message_count FROM member_stats WHERE days = @days\n) AS chart_query LIMIT 3' + ) + assert.deepEqual(calls[0]?.params, { days: 7 }) + assert.equal(result.chart?.spec.type, 'bar') + assert.deepEqual(result.chart?.data, { labels: ['Alice', 'Bob'], values: [4, 3] }) + assert.deepEqual(result.chart?.dataset.rows, [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + ]) + }) + + it('includes a compact data preview in the tool text for model reasoning', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext( + [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + ], + calls + ) + + const result = await renderChartTool.handler( + { + sql: 'SELECT name, message_count FROM member_stats', + chartSpec: barSpec, + }, + context + ) + + assert.match(result.content, /Generated chart "Messages by member"/) + assert.match(result.content, /Data preview/) + assert.match(result.content, /already uses all 2 rows/i) + assert.match(result.content, /Do not call render_chart again/i) + assert.match(result.content, /Alice/) + assert.match(result.content, /message_count/) + assert.match(result.content, /4/) + }) + + it('truncates rows after fetching one more than maxRows', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext( + [ + { name: 'Alice', message_count: 4 }, + { name: 'Bob', message_count: 3 }, + { name: 'Cara', message_count: 2 }, + ], + calls + ) + + const result = await renderChartTool.handler( + { + sql: 'WITH ranked AS (SELECT name, message_count FROM member_stats) SELECT name, message_count FROM ranked', + chartSpec: barSpec, + maxRows: 2, + }, + context + ) + + assert.equal( + calls[0]?.query, + 'SELECT * FROM (\nWITH ranked AS (SELECT name, message_count FROM member_stats) SELECT name, message_count FROM ranked\n) AS chart_query LIMIT 3' + ) + assert.equal(result.chart?.rowCount, 2) + assert.equal(result.chart?.truncated, true) + assert.deepEqual(result.chart?.data, { labels: ['Alice', 'Bob'], values: [4, 3] }) + }) + + it('rejects direct write statements before reaching the data provider', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext([], calls) + + await assert.rejects(async () => { + await renderChartTool.handler( + { + sql: 'DELETE FROM message', + chartSpec: barSpec, + }, + context + ) + }, /only accepts SELECT or WITH SELECT SQL/) + assert.equal(calls.length, 0) + }) + + it('rejects dividing ChatLab second timestamps by 1000', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext([], calls) + + await assert.rejects(async () => { + await renderChartTool.handler( + { + sql: "SELECT date(ts/1000, 'unixepoch') AS day, COUNT(*) AS message_count FROM message GROUP BY day", + chartSpec: barSpec, + }, + context + ) + }, /message\.ts is already a Unix timestamp in seconds/) + assert.equal(calls.length, 0) + }) + + it('enforces an outer row limit even when SQL already has a LIMIT', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext([{ name: 'Alice', message_count: 4 }], calls) + + await renderChartTool.handler( + { + sql: 'SELECT name, message_count FROM member_stats LIMIT 100000', + chartSpec: barSpec, + maxRows: 2, + }, + context + ) + + assert.equal( + calls[0]?.query, + 'SELECT * FROM (\nSELECT name, message_count FROM member_stats LIMIT 100000\n) AS chart_query LIMIT 3' + ) + }) + + it('does not let a trailing line comment swallow the enforced row limit', async () => { + const calls: Array<{ query: string; params: Record }> = [] + const context = createContext([{ name: 'Alice', message_count: 4 }], calls) + + await renderChartTool.handler( + { + sql: 'SELECT name, message_count FROM member_stats -- model note', + chartSpec: barSpec, + maxRows: 2, + }, + context + ) + + assert.equal( + calls[0]?.query, + 'SELECT * FROM (\nSELECT name, message_count FROM member_stats -- model note\n) AS chart_query LIMIT 3' + ) + }) +}) diff --git a/packages/tools/src/definitions/render-chart.ts b/packages/tools/src/definitions/render-chart.ts new file mode 100644 index 000000000..bb74d1320 --- /dev/null +++ b/packages/tools/src/definitions/render-chart.ts @@ -0,0 +1,166 @@ +/** + * Dynamic chart rendering tool. + * + * The model provides read-only SQL plus a ChartSpec. ChatLab executes and + * validates the result before producing a chart payload for the chat UI. + */ + +import { buildChartPayload } from '@openchatlab/core' +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' + +const DEFAULT_MAX_ROWS = 1000 +const DEFAULT_PREVIEW_ROWS = 20 +const RE_SECONDS_TIMESTAMP_DIVIDED_AS_MILLISECONDS = /\b(?:\w+\.)?ts\s*\/\s*1000\b/i + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + rows: { + type: 'array', + description: + 'Pre-fetched data rows to render directly, skipping SQL execution. Pass the data array from a high-level tool result (e.g. the `data` field from get_time_stats, or member activity rows). Prefer this over sql when data is already available. Mutually exclusive with sql.', + items: { type: 'object' }, + }, + sql: { + type: 'string', + description: + 'Read-only SELECT or WITH SELECT SQL used to produce chart rows. Use only when pre-fetched data is unavailable or custom aggregation is required.', + }, + params: { + type: 'object', + description: 'Named SQL parameters. Only used when sql is provided.', + additionalProperties: true, + default: {}, + }, + chartSpec: { + type: 'object', + description: + 'ChartSpec v1. Required fields: version, type, title, encoding. Supported types: bar, line, pie, heatmap.', + additionalProperties: true, + }, + maxRows: { + type: 'number', + description: 'Maximum rows to fetch before chart normalization.', + default: DEFAULT_MAX_ROWS, + minimum: 1, + maximum: 5000, + }, + }, + required: ['chartSpec'], +} + +function normalizeSql(sql: unknown, maxRows: number): string { + if (typeof sql !== 'string' || sql.trim().length === 0) { + throw new Error('sql must be a non-empty string') + } + + const trimmed = sql.trim().replace(/;+\s*$/, '') + if (trimmed.includes(';')) { + throw new Error('Only a single read-only SQL statement is allowed') + } + + const statementStart = trimmed.replace(/^(\s|--[^\n]*(\n|$)|\/\*[\s\S]*?\*\/)*/, '') + if (!/^(SELECT|WITH)\b/i.test(statementStart)) { + throw new Error('render_chart only accepts SELECT or WITH SELECT SQL') + } + + if (RE_SECONDS_TIMESTAMP_DIVIDED_AS_MILLISECONDS.test(trimmed)) { + throw new Error('message.ts is already a Unix timestamp in seconds; do not divide ts by 1000') + } + + return `SELECT * FROM (\n${trimmed}\n) AS chart_query LIMIT ${maxRows + 1}` +} + +function normalizeParams(raw: unknown): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {} + return raw as Record +} + +function normalizeRows(raw: unknown, maxRows: number): { rows: Record[]; truncated: boolean } { + if (!Array.isArray(raw)) throw new Error('rows must be an array') + const items = raw.filter( + (item): item is Record => typeof item === 'object' && item !== null && !Array.isArray(item) + ) + const truncated = items.length > maxRows + return { rows: truncated ? items.slice(0, maxRows) : items, truncated } +} + +function normalizeMaxRows(raw: unknown): number { + const value = typeof raw === 'number' ? raw : DEFAULT_MAX_ROWS + if (!Number.isFinite(value)) return DEFAULT_MAX_ROWS + return Math.min(5000, Math.max(1, Math.floor(value))) +} + +function summarizeChart(type: string, title: string, rowCount: number, truncated: boolean, locale?: string): string { + if (locale?.startsWith('zh')) { + return `已生成图表「${title}」(${type},${rowCount} 行数据${truncated ? ',已截断' : ''})。` + } + return `Generated chart "${title}" (${type}, ${rowCount} rows${truncated ? ', truncated' : ''}).` +} + +function summarizeChartForModel( + type: string, + title: string, + rows: Record[], + truncated: boolean, + locale?: string +): string { + const summary = summarizeChart(type, title, rows.length, truncated, locale) + const previewRows = rows.slice(0, DEFAULT_PREVIEW_ROWS) + const coverage = locale?.startsWith('zh') + ? `图表已使用${truncated ? '截断后的' : '全部'} ${rows.length} 行数据生成;下面只是数据预览,不要为了查看预览外的行重复调用 render_chart。` + : `The chart already uses ${truncated ? 'the truncated' : 'all'} ${rows.length} rows; the rows below are only a preview. Do not call render_chart again just to inspect rows outside the preview.` + if (previewRows.length === 0) return `${summary}\n${coverage}` + + const preview = JSON.stringify(previewRows) + if (locale?.startsWith('zh')) { + return `${summary}\n${coverage}\n数据预览(前 ${previewRows.length} 行,用于分析峰值、低谷和差异):${preview}` + } + return `${summary}\n${coverage}\nData preview (first ${previewRows.length} rows; use this to identify peaks, lows, and differences): ${preview}` +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + if (!context.dataProvider) throw new Error('render_chart requires a data provider') + + const hasSql = typeof params.sql === 'string' && params.sql.trim().length > 0 + const hasRows = Array.isArray(params.rows) + if (!hasSql && !hasRows) throw new Error('render_chart requires either sql or rows') + + const maxRows = normalizeMaxRows(params.maxRows) + let rows: Record[] + let truncated: boolean + + if (hasRows) { + const normalized = normalizeRows(params.rows, maxRows) + rows = normalized.rows + truncated = normalized.truncated + } else { + const sql = normalizeSql(params.sql, maxRows) + const sqlParams = normalizeParams(params.params) + const fetchedRows = await context.dataProvider.executeParameterizedSql>(sql, sqlParams) + truncated = fetchedRows.length > maxRows + rows = truncated ? fetchedRows.slice(0, maxRows) : fetchedRows + } + + const chart = buildChartPayload(rows, params.chartSpec, { truncated }) + + return { + content: summarizeChartForModel(chart.spec.type, chart.spec.title, rows, truncated, context.locale), + data: { + rowCount: rows.length, + truncated, + chartType: chart.spec.type, + title: chart.spec.title, + }, + chart, + } +} + +export const renderChartTool: ToolDefinition = { + name: 'render_chart', + description: + "Generate a native ChatLab chart from ChartSpec v1. Provide either `rows` (pre-fetched data array from a tool result such as get_time_stats or member_stats) or `sql` (read-only SELECT). Prefer `rows` when data is already available — pass the tool's data array directly. Use `sql` only for custom aggregations not covered by existing tools. Never output HTML, JavaScript, SVG, ECharts options, or rendering code.", + inputSchema, + handler, + category: 'analysis', +} diff --git a/packages/tools/src/definitions/response-time-analysis.ts b/packages/tools/src/definitions/response-time-analysis.ts new file mode 100644 index 000000000..b68603f24 --- /dev/null +++ b/packages/tools/src/definitions/response-time-analysis.ts @@ -0,0 +1,111 @@ +/** + * 响应时间分析工具 + * + * 基于参数化 SQL + JS 聚合的启发式回复延迟统计。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { isChineseLocale } from '../utils/format' + +interface MsgRow { + sender_id: number + name: string + ts: number +} + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + days: { type: 'number', description: '分析最近多少天的数据,默认 30' }, + top_n: { type: 'number', description: '返回前多少名,默认 10' }, + }, +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale } = context + const isZh = isChineseLocale(locale) + const days = (params.days as number) || 30 + const topN = (params.top_n as number) || 10 + + const sql = ` + SELECT msg.sender_id, COALESCE(m.group_nickname, m.account_name) AS name, msg.ts + FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE msg.type = 0 + AND msg.ts > unixepoch('now', '-' || @days || ' days') + ORDER BY msg.ts ASC + ` + const rows = await context.dataProvider!.executeParameterizedSql(sql, { days }) + if (!rows || rows.length < 2) { + const text = isZh + ? '该时间范围内消息不足,无法分析响应时间' + : 'Not enough messages in this time range to analyze response time' + return { content: text, data: null } + } + + const responseTimes = new Map() + + for (let i = 1; i < rows.length; i++) { + const prev = rows[i - 1] + const curr = rows[i] + if (curr.sender_id === prev.sender_id) continue + + const gap = curr.ts - prev.ts + if (gap < 5 || gap > 1800) continue + + if (!responseTimes.has(curr.sender_id)) { + responseTimes.set(curr.sender_id, { name: curr.name, times: [] }) + } + responseTimes.get(curr.sender_id)!.times.push(gap) + } + + const stats = [...responseTimes.entries()] + .map(([id, { name, times }]) => { + times.sort((a, b) => a - b) + const avg = Math.round(times.reduce((s, t) => s + t, 0) / times.length) + const median = times[Math.floor(times.length / 2)] + return { id, name, avgSeconds: avg, medianSeconds: median, responseCount: times.length } + }) + .filter((s) => s.responseCount >= 3) + .sort((a, b) => a.medianSeconds - b.medianSeconds) + .slice(0, topN) + + if (stats.length === 0) { + const text = isZh ? '没有足够的响应数据进行分析' : 'Not enough response data for analysis' + return { content: text, data: null } + } + + const formatTime = (s: number) => { + if (s < 60) return isZh ? `${s}秒` : `${s}s` + const m = Math.floor(s / 60) + const sec = s % 60 + return isZh ? `${m}分${sec}秒` : `${m}m${sec}s` + } + + const ranking = stats.map((s, i) => ({ + rank: i + 1, + name: s.name, + median: formatTime(s.medianSeconds), + avg: formatTime(s.avgSeconds), + count: s.responseCount, + })) + + const data = { + period: isZh ? `近${days}天` : `Last ${days} days`, + totalResponders: stats.length, + ranking: ranking.map( + (r) => + `${r.rank}. ${r.name} — ${isZh ? '中位数' : 'median'} ${r.median}, ${isZh ? '平均' : 'avg'} ${r.avg} (${r.count}${isZh ? '次' : ' responses'})` + ), + } + + return { content: JSON.stringify(data), data } +} + +export const responseTimeAnalysisTool: ToolDefinition = { + name: 'response_time_analysis', + description: '分析群成员的响应速度排行,基于回复间隔的中位数和平均值。', + inputSchema, + handler, + category: 'analysis', +} diff --git a/packages/tools/src/definitions/retrieve-chat-evidence.test.ts b/packages/tools/src/definitions/retrieve-chat-evidence.test.ts new file mode 100644 index 000000000..ce832244f --- /dev/null +++ b/packages/tools/src/definitions/retrieve-chat-evidence.test.ts @@ -0,0 +1,362 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { retrieveChatEvidenceTool } from './retrieve-chat-evidence' +import type { + ChatEvidencePayload, + RawMessage, + SearchMessagesResult, + SemanticSearchToolResult, + SemanticSearchToolService, + ToolDataProvider, + ToolExecutionContext, +} from '../types' + +function semanticResult(over: Partial = {}): SemanticSearchToolResult { + return { + available: true, + text: 'U1: 到了乐山大佛', + returned: 1, + hitCount: 1, + partial: false, + coverage: 1, + truncated: false, + timeRange: { earliest: '2024-05-01T00:00:00.000Z', latest: '2024-05-01T01:00:00.000Z' }, + sources: [ + { + startMessageId: 100, + endMessageId: 110, + score: 0.9, + chunkIds: ['c1'], + snippet: '到了乐山大佛,住了一晚', + startTime: '2024-05-01T00:00:00.000Z', + endTime: '2024-05-01T01:00:00.000Z', + }, + ], + ...over, + } +} + +function makeService(over: Partial = {}): SemanticSearchToolService { + return { + canSearch: () => true, + searchForTool: async () => semanticResult(), + ...over, + } +} + +/** 秒级 RawMessage,模拟聊天库返回 */ +function rawMsg(id: number, tsSeconds: number, content: string, senderName = '甲'): RawMessage { + return { id, senderName, content, timestamp: tsSeconds } +} + +function makeDataProvider(messages: RawMessage[], capture?: { args?: unknown[] }): ToolDataProvider { + return { + searchMessages: async (keywords, options): Promise => { + if (capture) capture.args = [keywords, options] + return { messages, total: messages.length } + }, + } as unknown as ToolDataProvider +} + +function makeContext(extra: Partial = {}): ToolExecutionContext { + return { sessionId: 's1', locale: 'zh-CN', ...extra } +} + +function getPayload(data: unknown): ChatEvidencePayload { + const evidence = (data as { evidence?: ChatEvidencePayload })?.evidence + assert.ok(evidence, 'data.evidence should exist') + return evidence +} + +describe('retrieveChatEvidenceTool schema', () => { + it('requires query and exposes evidence params', () => { + const props = retrieveChatEvidenceTool.inputSchema.properties + const keys = Object.keys(props).sort() + assert.deepEqual(retrieveChatEvidenceTool.inputSchema.required, ['query']) + for (const k of ['query', 'criteria', 'keywords', 'mode', 'max_results', 'start_time', 'end_time']) { + assert.ok(keys.includes(k), `schema should include ${k}`) + } + }) + + it('is registered as a core tool', () => { + assert.equal(retrieveChatEvidenceTool.name, 'retrieve_chat_evidence') + }) +}) + +describe('retrieveChatEvidenceTool mode resolution', () => { + it('auto resolves to hybrid and calls both semantic + keyword when available with keywords', async () => { + let semanticCalled = false + const capture: { args?: unknown[] } = {} + const service = makeService({ + searchForTool: async () => { + semanticCalled = true + return semanticResult() + }, + }) + const dp = makeDataProvider([rawMsg(200, 1714521600, '我们去乐山玩了')], capture) + const res = await retrieveChatEvidenceTool.handler( + { query: '去过乐山几次', criteria: '实际出行', keywords: ['乐山'] }, + makeContext({ semanticIndexService: service, dataProvider: dp }) + ) + assert.equal(semanticCalled, true) + assert.ok(capture.args, 'keyword search should be called') + const payload = getPayload(res.data) + assert.equal(payload.mode, 'hybrid') + }) + + it('semantic mode does not call keyword search', async () => { + const capture: { args?: unknown[] } = {} + const dp = makeDataProvider([rawMsg(1, 1714521600, 'x')], capture) + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', mode: 'semantic', keywords: ['乐山'] }, + makeContext({ semanticIndexService: makeService(), dataProvider: dp }) + ) + assert.equal(capture.args, undefined) + assert.equal(getPayload(res.data).mode, 'semantic') + }) + + it('keyword mode without keywords returns warning + empty', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', mode: 'keyword' }, + makeContext({ dataProvider: makeDataProvider([]) }) + ) + const payload = getPayload(res.data) + assert.equal(payload.mode, 'keyword') + assert.ok(payload.warnings?.includes('keywords_required_for_keyword_mode')) + assert.deepEqual(payload.groups, []) + }) + + it('auto resolves to semantic when index available but no keywords', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q' }, + makeContext({ semanticIndexService: makeService(), dataProvider: makeDataProvider([]) }) + ) + const payload = getPayload(res.data) + assert.equal(payload.mode, 'semantic') + assert.ok(payload.warnings?.includes('keywords_missing_for_hybrid')) + }) + + it('auto resolves to keyword + semantic_unavailable when index missing', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', keywords: ['乐山'] }, + makeContext({ dataProvider: makeDataProvider([rawMsg(1, 1714521600, '乐山')]) }) + ) + const payload = getPayload(res.data) + assert.equal(payload.mode, 'keyword') + assert.ok(payload.warnings?.includes('semantic_unavailable')) + }) +}) + +describe('retrieveChatEvidenceTool warnings', () => { + it('warns when criteria is missing', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', keywords: ['乐山'] }, + makeContext({ semanticIndexService: makeService(), dataProvider: makeDataProvider([]) }) + ) + assert.ok(getPayload(res.data).warnings?.includes('criteria_missing')) + }) + + it('warns keyword_unavailable when dataProvider missing but continues with semantic', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'hybrid', keywords: ['乐山'] }, + makeContext({ semanticIndexService: makeService() }) + ) + const payload = getPayload(res.data) + assert.ok(payload.warnings?.includes('keyword_unavailable')) + assert.ok(payload.groups.length > 0, 'semantic results should still produce groups') + }) + + it('warns semantic_unavailable but keyword continues', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'hybrid', keywords: ['乐山'] }, + makeContext({ dataProvider: makeDataProvider([rawMsg(1, 1714521600, '到了乐山')]) }) + ) + const payload = getPayload(res.data) + assert.ok(payload.warnings?.includes('semantic_unavailable')) + assert.ok(payload.groups.length > 0) + }) + + it('warns semantic_partial when semantic result is partial', async () => { + const service = makeService({ searchForTool: async () => semanticResult({ partial: true }) }) + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'semantic' }, + makeContext({ semanticIndexService: service }) + ) + assert.ok(getPayload(res.data).warnings?.includes('semantic_partial')) + }) +}) + +describe('retrieveChatEvidenceTool candidates & grouping', () => { + it('converts keyword RawMessage seconds timestamp to milliseconds', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'keyword', keywords: ['乐山'] }, + makeContext({ dataProvider: makeDataProvider([rawMsg(1, 1714521600, '到了乐山')]) }) + ) + const payload = getPayload(res.data) + const source = payload.groups[0].sources[0] + assert.equal(source.timestamp, 1714521600 * 1000) + }) + + it('strips per-line time prefixes from semantic snippets (display shows one group time)', async () => { + const service = makeService({ + searchForTool: async () => + semanticResult({ + sources: [ + { + startMessageId: 100, + endMessageId: 110, + score: 0.9, + chunkIds: ['c1'], + // 预处理管道渲染格式:每行「时间 发送者: 内容」 + snippet: '2024/5/1 08:00:00 甲: 到了乐山\n2024/5/1 09:00:00 乙: 住了一晚', + startTime: '2024-05-01T00:00:00.000Z', + endTime: '2024-05-01T01:00:00.000Z', + }, + ], + }), + }) + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'semantic' }, + makeContext({ semanticIndexService: service }) + ) + const snippet = getPayload(res.data).groups[0].sources[0].snippet + assert.equal(snippet.includes('2024/5/1'), false) + assert.equal(snippet.includes('08:00:00'), false) + assert.ok(snippet.includes('甲: 到了乐山')) + assert.ok(snippet.includes('乙: 住了一晚')) + }) + + it('uses full semantic evidence text when the preview omits the proof', async () => { + const previewOnly = '甲: 先整理路线和预算,讨论交通方式、请假安排、集合时间、装备清单、天气情况和备选方案。'.repeat( + 3 + ) + const fullEvidenceText = `${previewOnly}\n乙: 到了乐山大佛,住了一晚,第二天返程` + const service = makeService({ + searchForTool: async () => + semanticResult({ + text: `--- 2024-05-01T00:00:00.000Z ~ 2024-05-01T01:00:00.000Z\n${fullEvidenceText}`, + sources: [ + { + startMessageId: 100, + endMessageId: 110, + score: 0.9, + chunkIds: ['c1'], + snippet: `${previewOnly.slice(0, 160)}…`, + text: fullEvidenceText, + startTime: '2024-05-01T00:00:00.000Z', + endTime: '2024-05-01T01:00:00.000Z', + }, + ], + }), + }) + const res = await retrieveChatEvidenceTool.handler( + { query: '去过乐山几次', criteria: '计入实际到达/住宿/返程证据', mode: 'semantic' }, + makeContext({ semanticIndexService: service }) + ) + const source = getPayload(res.data).groups[0].sources[0] + assert.equal(getPayload(res.data).groups[0].status, 'included') + assert.ok(source.snippet.includes('到了乐山大佛')) + }) + + it('desensitizes keyword snippets and never persists raw secret content', async () => { + const desensitize = (messages: RawMessage[]): RawMessage[] => + messages.map((m) => ({ ...m, content: (m.content ?? '').replace('13800000000', '***') })) + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'keyword', keywords: ['乐山'] }, + makeContext({ + dataProvider: makeDataProvider([rawMsg(1, 1714521600, '到了乐山 13800000000')]), + desensitizeMessages: desensitize, + }) + ) + const payload = getPayload(res.data) + const blob = JSON.stringify(payload) + assert.equal(blob.includes('13800000000'), false) + assert.ok(blob.includes('***')) + }) + + it('dedupes keyword hit that falls inside a semantic range', async () => { + // semantic range [100,110]; keyword hit id=105 within → should not double-count + const dp = makeDataProvider([rawMsg(105, 1714521600, '到了乐山大佛')]) + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'hybrid', keywords: ['乐山'] }, + makeContext({ semanticIndexService: makeService(), dataProvider: dp }) + ) + const payload = getPayload(res.data) + const allSources = payload.groups.flatMap((g) => g.sources) + const ids = allSources.map((s) => s.messageId) + // only the semantic source (anchor 100) should remain, keyword 105 deduped into range + assert.ok(!ids.includes(105), 'keyword hit inside semantic range should be deduped') + }) + + it('splits candidates into separate groups when time gap exceeds threshold', async () => { + const base = 1714521600 + const messages = [ + rawMsg(1, base, '到了乐山'), + rawMsg(2, base + 30 * 60, '在乐山吃饭'), // +30min same group + rawMsg(3, base + 5 * 24 * 60 * 60, '又去乐山'), // +5 days new group + ] + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'keyword', keywords: ['乐山'] }, + makeContext({ dataProvider: makeDataProvider(messages) }) + ) + const payload = getPayload(res.data) + assert.equal(payload.groups.length, 2) + }) +}) + +describe('retrieveChatEvidenceTool output safety', () => { + it('never returns rawMessages and produces non-empty content', async () => { + const res = await retrieveChatEvidenceTool.handler( + { query: 'q', criteria: 'c', mode: 'keyword', keywords: ['乐山'] }, + makeContext({ dataProvider: makeDataProvider([rawMsg(1, 1714521600, '到了乐山')]) }) + ) + assert.equal(res.rawMessages, undefined) + assert.ok(typeof res.content === 'string' && res.content.length > 0) + }) + + it('applies time filter fallback on keyword path (seconds compare)', async () => { + const base = 1714521600 // 2024-05-01 + const messages = [rawMsg(1, base, '到了乐山'), rawMsg(2, base + 365 * 24 * 60 * 60, '一年后又去乐山')] + const res = await retrieveChatEvidenceTool.handler( + { + query: 'q', + criteria: 'c', + mode: 'keyword', + keywords: ['乐山'], + start_time: '2024-04-01', + end_time: '2024-06-01', + }, + makeContext({ dataProvider: makeDataProvider(messages) }) + ) + const payload = getPayload(res.data) + const ids = payload.groups.flatMap((g) => g.sources).map((s) => s.messageId) + assert.deepEqual(ids, [1], 'out-of-range message should be filtered out') + }) + + it('passes one-sided end_time to keyword search before local fallback filtering', async () => { + const capture: { args?: unknown[] } = {} + const endTs = Math.floor(Date.parse('2024-06-01') / 1000) + const res = await retrieveChatEvidenceTool.handler( + { + query: 'q', + criteria: 'c', + mode: 'keyword', + keywords: ['乐山'], + end_time: '2024-06-01', + }, + makeContext({ + dataProvider: makeDataProvider([rawMsg(1, 1714521600, '到了乐山')], capture), + }) + ) + + assert.ok(capture.args, 'keyword search should be called') + assert.deepEqual(capture.args[1], { + timeFilter: { endTs }, + limit: 80, + }) + const ids = getPayload(res.data) + .groups.flatMap((g) => g.sources) + .map((s) => s.messageId) + assert.deepEqual(ids, [1]) + }) +}) diff --git a/packages/tools/src/definitions/retrieve-chat-evidence.ts b/packages/tools/src/definitions/retrieve-chat-evidence.ts new file mode 100644 index 000000000..74121636d --- /dev/null +++ b/packages/tools/src/definitions/retrieve-chat-evidence.ts @@ -0,0 +1,463 @@ +/** + * 证据检索工具 retrieve_chat_evidence + * + * 面向“多少次 / 有没有 / 是否曾经 / 给证据链”这类历史事实判断问题: + * 同时(或按 mode)跑语义召回与关键词召回,合并去重并保守分组, + * 产出可持久化、已脱敏的结构化证据 payload(data.evidence)和给 LLM 的安全摘要文本。 + * + * 单位约定:聊天库 / RawMessage.timestamp 为秒;evidence payload 统一毫秒。 + * 关键词路径必须在工具层按时间范围兜底过滤(CLI provider 不保证时间过滤生效)。 + */ + +import type { + ChatEvidenceGroup, + ChatEvidencePayload, + ChatEvidenceSource, + EvidencePayloadStatus, + EvidenceRetrievalMode, + EvidenceStatus, + EvidenceTimeRangeMs, + EvidenceWarning, + JsonSchema, + RawMessage, + ToolDefinition, + ToolExecutionContext, + ToolResult, +} from '../types' +import { SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP } from '../types' +import { timeParamProperties } from '../utils/schemas' + +const EVIDENCE_GROUP_GAP_SECONDS = 2 * 60 * 60 +const EVIDENCE_GROUP_GAP_MS = EVIDENCE_GROUP_GAP_SECONDS * 1000 +const EVIDENCE_GROUP_MAX_SOURCES = 5 +const EVIDENCE_SNIPPET_MAX_CHARS = 160 +const KEYWORD_DEFAULT_LIMIT = 80 + +/** 计划/意向词:仅出现这些且无实际证据词,倾向判为“不计入” */ +const PLANNING_WORDS = ['准备', '打算', '攻略', '想去', '计划', '要不要去', '考虑去', '安排一下'] +/** 实际发生证据词:出现则倾向判为“计入” */ +const EVIDENCE_WORDS = ['到了', '到达', '出发', '返程', '回来', '入住', '住了', '门票', '合影', '打卡', '玩了'] + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + query: { + type: 'string', + description: '需要用聊天证据确认的问题/检索目标,例如“我们去乐山旅行过多少次”。', + }, + criteria: { + type: 'string', + description: + '判定标准:什么算计入、什么不计入。统计/判断类问题应填写,例如“计入:有实际出行/到达/住宿证据;不计入:仅计划、攻略、别人经历、泛聊”。', + }, + keywords: { + type: 'array', + items: { type: 'string' }, + description: '关键词列表,由你显式给出(工具不做同义词扩展)。hybrid/keyword 模式用于精确召回。', + }, + mode: { + type: 'string', + enum: ['auto', 'hybrid', 'semantic', 'keyword'], + description: 'auto(默认)/hybrid(语义+关键词)/semantic(仅语义)/keyword(仅关键词)。历史事实统计通常用 hybrid。', + }, + max_results: { + type: 'number', + description: `语义路径期望返回片段数,硬上限 ${SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP}。`, + minimum: 1, + maximum: SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP, + }, + ...timeParamProperties, + }, + required: ['query'], +} + +/** 内部统一候选 */ +interface EvidenceCandidate { + messageId: number + startMessageId?: number + endMessageId?: number + timestamp: number // 毫秒 + rangeStartMs: number // 毫秒,用于时间交集 + rangeEndMs: number // 毫秒 + snippet: string + senderName?: string + sourceKind: 'semantic' | 'keyword' + score?: number +} + +function isChinese(locale?: string): boolean { + return !locale || locale.toLowerCase().startsWith('zh') +} + +function truncateSnippet(text: string): string { + const t = text.trim() + return t.length > EVIDENCE_SNIPPET_MAX_CHARS ? `${t.slice(0, EVIDENCE_SNIPPET_MAX_CHARS)}…` : t +} + +/** + * 语义 snippet 由预处理管道渲染为多行「时间 发送者: 内容」,时间对证据块展示是噪声 + * (分组已给出总时间)。这里去掉每行行首的时间前缀,仅保留「发送者: 内容」。 + * 覆盖 zh-CN(`2025/3/3 07:25:04`)与 en-US(`3/3/2025, 7:25:04 AM`)两种 toLocaleString 输出。 + */ +const LINE_TIME_PREFIX = /^\s*\d{1,4}[/-]\d{1,2}[/-]\d{1,4},?\s+\d{1,2}:\d{2}(?::\d{2})?(?:\s?[APap][Mm])?\s+/ +function stripLineTimestamps(text: string): string { + return text + .split('\n') + .map((line) => line.replace(LINE_TIME_PREFIX, '')) + .join('\n') + .trim() +} + +function parseDateMs(s?: unknown): number | undefined { + if (typeof s !== 'string' || !s.trim()) return undefined + const d = new Date(s.trim().replace(' ', 'T')) + const t = d.getTime() + return Number.isNaN(t) ? undefined : t +} + +function parseIsoMs(s?: string): number | undefined { + if (!s) return undefined + const t = Date.parse(s) + return Number.isNaN(t) ? undefined : t +} + +/** 解析时间范围为毫秒(可单边);显式参数优先,其次继承 context.timeFilter(秒→毫秒) */ +function parseEvidenceTimeRange( + params: Record, + context: ToolExecutionContext +): EvidenceTimeRangeMs | undefined { + const startMs = parseDateMs(params.start_time) + const endMs = parseDateMs(params.end_time) + if (startMs != null || endMs != null) { + return { startTs: startMs, endTs: endMs } + } + if (context.timeFilter) { + return { startTs: context.timeFilter.startTs * 1000, endTs: context.timeFilter.endTs * 1000 } + } + return undefined +} + +function overlapsTimeRangeMs(startMs: number, endMs: number, filter?: EvidenceTimeRangeMs): boolean { + if (!filter) return true + if (filter.startTs != null && endMs < filter.startTs) return false + if (filter.endTs != null && startMs > filter.endTs) return false + return true +} + +function clampMaxResults(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined + return Math.max(1, Math.min(SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP, Math.floor(value))) +} + +function toKeywordTimeFilter(rangeMs: EvidenceTimeRangeMs | undefined): { startTs: number; endTs: number } | undefined { + if (!rangeMs) return undefined + const filter: { startTs?: number; endTs?: number } = {} + if (rangeMs.startTs != null) filter.startTs = Math.floor(rangeMs.startTs / 1000) + if (rangeMs.endTs != null) filter.endTs = Math.floor(rangeMs.endTs / 1000) + return Object.keys(filter).length > 0 ? (filter as { startTs: number; endTs: number }) : undefined +} + +interface ResolveResult { + mode: Exclude + warnings: Set +} + +function resolveMode( + requested: EvidenceRetrievalMode, + semanticAvailable: boolean, + hasKeywords: boolean +): ResolveResult { + const warnings = new Set() + if (requested !== 'auto') return { mode: requested, warnings } + + if (semanticAvailable && hasKeywords) return { mode: 'hybrid', warnings } + if (semanticAvailable && !hasKeywords) { + warnings.add('keywords_missing_for_hybrid') + return { mode: 'semantic', warnings } + } + warnings.add('semantic_unavailable') + if (!hasKeywords) warnings.add('keywords_required_for_keyword_mode') + return { mode: 'keyword', warnings } +} + +/** 关键词消息 → 候选(脱敏 + 时间兜底) */ +function keywordMessagesToCandidates( + messages: RawMessage[], + rangeMs: EvidenceTimeRangeMs | undefined, + desensitize: ToolExecutionContext['desensitizeMessages'] +): EvidenceCandidate[] { + const safe = desensitize ? desensitize(messages) : messages + const candidates: EvidenceCandidate[] = [] + for (const m of safe) { + if (m.id == null || !m.content) continue + const tsMs = m.timestamp * 1000 + if (!overlapsTimeRangeMs(tsMs, tsMs, rangeMs)) continue + candidates.push({ + messageId: m.id, + timestamp: tsMs, + rangeStartMs: tsMs, + rangeEndMs: tsMs, + snippet: truncateSnippet(m.content), + senderName: m.senderName, + sourceKind: 'keyword', + }) + } + return candidates +} + +/** 合并去重:关键词命中的时间戳落在语义片段时间范围内则丢弃;按 messageId 去重;按时间排序 */ +function mergeCandidates(semantic: EvidenceCandidate[], keyword: EvidenceCandidate[]): EvidenceCandidate[] { + const keptKeyword = keyword.filter( + (kc) => !semantic.some((sc) => kc.timestamp >= sc.rangeStartMs && kc.timestamp <= sc.rangeEndMs) + ) + + const byId = new Map() + for (const c of [...semantic, ...keptKeyword]) { + if (!byId.has(c.messageId)) byId.set(c.messageId, c) + } + return [...byId.values()].sort((a, b) => a.timestamp - b.timestamp) +} + +function classifyGroup(snippets: string[], hasCriteria: boolean, locale?: string): EvidenceStatus { + if (!hasCriteria) return 'uncertain' + if (!isChinese(locale)) return 'uncertain' + const joined = snippets.join('\n') + const hasEvidence = EVIDENCE_WORDS.some((w) => joined.includes(w)) + const hasPlanning = PLANNING_WORDS.some((w) => joined.includes(w)) + if (hasEvidence) return 'included' + if (hasPlanning) return 'excluded' + return 'uncertain' +} + +function formatDate(ms: number, locale?: string): string { + const d = new Date(ms) + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return isChinese(locale) ? `${y}年${m}月${day}日` : `${y}-${m}-${day}` +} + +function buildGroups(candidates: EvidenceCandidate[], hasCriteria: boolean, locale?: string): ChatEvidenceGroup[] { + const groups: ChatEvidenceGroup[] = [] + let current: ChatEvidenceGroup | null = null + let lastTs = 0 + + for (const c of candidates) { + const source: ChatEvidenceSource = { + messageId: c.messageId, + startMessageId: c.startMessageId, + endMessageId: c.endMessageId, + timestamp: c.timestamp, + senderName: c.senderName, + snippet: c.snippet, + sourceKind: c.sourceKind, + } + const continues = + current && c.timestamp - lastTs <= EVIDENCE_GROUP_GAP_MS && current.sources.length < EVIDENCE_GROUP_MAX_SOURCES + if (continues && current) { + current.sources.push(source) + current.timeRange = { startTs: current.timeRange!.startTs, endTs: c.timestamp } + } else { + current = { + id: `g${groups.length + 1}`, + status: 'uncertain', + title: '', + reason: '', + timeRange: { startTs: c.timestamp, endTs: c.timestamp }, + sources: [source], + } + groups.push(current) + } + lastTs = c.timestamp + } + + const cn = isChinese(locale) + for (const g of groups) { + const snippets = g.sources.map((s) => s.snippet) + g.status = classifyGroup(snippets, hasCriteria, locale) + const start = formatDate(g.timeRange!.startTs, locale) + const end = formatDate(g.timeRange!.endTs, locale) + g.title = start === end ? start : cn ? `${start} 至 ${end}` : `${start} – ${end}` + g.reason = cn ? GROUP_REASON_CN[g.status] : GROUP_REASON_EN[g.status] + } + return groups +} + +const GROUP_REASON_CN: Record = { + included: '含实际发生/到达等证据,建议计入', + excluded: '仅含计划/意向,未见实际发生证据,建议不计入', + uncertain: '证据不足,无法确认是否计入', +} +const GROUP_REASON_EN: Record = { + included: 'Contains concrete evidence of an actual event; suggest counting it.', + excluded: 'Only plans/intentions without evidence of occurrence; suggest excluding.', + uncertain: 'Insufficient evidence to confirm.', +} + +function buildContent(payload: ChatEvidencePayload, locale: string | undefined): string { + const cn = isChinese(locale) + const lines: string[] = [] + lines.push( + cn + ? `证据检索结果(mode=${payload.mode},status=${payload.status})` + : `Evidence (mode=${payload.mode}, status=${payload.status})` + ) + if (payload.warnings && payload.warnings.length > 0) { + lines.push((cn ? '告警: ' : 'Warnings: ') + payload.warnings.join(', ')) + } + if (payload.groups.length === 0) { + lines.push(cn ? '未找到可计入的明确证据。' : 'No conclusive evidence found.') + } + for (const g of payload.groups) { + lines.push(`\n[${g.status}] ${g.title} — ${g.reason}`) + for (const s of g.sources) { + const who = s.senderName ? `${s.senderName}: ` : '' + lines.push(` · ${who}${s.snippet}`) + } + } + lines.push( + cn + ? '\n请基于以上证据保守作答:只把 included 计入确定结论,excluded/uncertain 不计入;证据不足时回答”无法确认”。' + : '\nAnswer conservatively: count `included` groups toward the conclusion; for `uncertain` groups, judge based on the snippet content whether evidence of actual occurrence is present; do not count `excluded` groups.' + ) + return lines.join('\n') +} + +function computeStatus( + anyPathRan: boolean, + hasResults: boolean, + warnings: Set +): EvidencePayloadStatus { + if (!anyPathRan) return 'unavailable' + if (!hasResults) return 'empty' + const degraded = + warnings.has('semantic_partial') || warnings.has('semantic_unavailable') || warnings.has('keyword_unavailable') + return degraded ? 'partial' : 'complete' +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const locale = context.locale + const query = typeof params.query === 'string' ? params.query.trim() : '' + const criteria = typeof params.criteria === 'string' && params.criteria.trim() ? params.criteria.trim() : undefined + const keywords = Array.isArray(params.keywords) + ? (params.keywords.filter((k) => typeof k === 'string' && k.trim()) as string[]) + : [] + const requestedMode: EvidenceRetrievalMode = + params.mode === 'hybrid' || params.mode === 'semantic' || params.mode === 'keyword' || params.mode === 'auto' + ? params.mode + : 'auto' + + const service = context.semanticIndexService + const semanticAvailable = !!service && (await service.canSearch(context.sessionId)) + const rangeMs = parseEvidenceTimeRange(params, context) + + const { mode, warnings } = resolveMode(requestedMode, semanticAvailable, keywords.length > 0) + if (!criteria) warnings.add('criteria_missing') + + const runSemantic = mode === 'semantic' || mode === 'hybrid' + const runKeyword = mode === 'keyword' || mode === 'hybrid' + + let semanticRan = false + let keywordRan = false + const semanticCandidates: EvidenceCandidate[] = [] + let keywordCandidates: EvidenceCandidate[] = [] + + // 语义路径:service 内按 chunk 时间范围过滤;工具层再按 startTime/endTime 兜底过滤 + if (runSemantic) { + if (!service || !semanticAvailable) { + warnings.add('semantic_unavailable') + } else { + const result = await service.searchForTool(context.sessionId, query, { + maxResults: clampMaxResults(params.max_results), + preprocessConfig: context.preprocessConfig, + ownerPlatformId: context.ownerPlatformId, + locale, + maxResultTokens: context.maxToolResultTokens, + timeFilter: rangeMs, + }) + semanticRan = true + if (!result.available) { + warnings.add('semantic_unavailable') + } else { + if (result.partial) warnings.add('semantic_partial') + for (const s of result.sources) { + const startMs = parseIsoMs(s.startTime) + const endMs = parseIsoMs(s.endTime) ?? startMs + if (rangeMs && startMs != null && endMs != null && !overlapsTimeRangeMs(startMs, endMs, rangeMs)) continue + const evidenceText = stripLineTimestamps(s.text || s.snippet) + semanticCandidates.push({ + messageId: s.startMessageId, + startMessageId: s.startMessageId, + endMessageId: s.endMessageId, + timestamp: startMs ?? 0, + rangeStartMs: startMs ?? 0, + rangeEndMs: endMs ?? startMs ?? 0, + snippet: evidenceText, + sourceKind: 'semantic', + score: s.score, + }) + } + } + } + } + + // 关键词路径 + if (runKeyword) { + if (keywords.length === 0) { + warnings.add(mode === 'keyword' ? 'keywords_required_for_keyword_mode' : 'keywords_missing_for_hybrid') + } else if (!context.dataProvider) { + warnings.add('keyword_unavailable') + } else { + const secFilter = toKeywordTimeFilter(rangeMs) + const searchResult = await context.dataProvider.searchMessages(keywords, { + timeFilter: secFilter, + limit: context.maxMessagesLimit || KEYWORD_DEFAULT_LIMIT, + }) + keywordRan = true + keywordCandidates = keywordMessagesToCandidates(searchResult.messages, rangeMs, context.desensitizeMessages) + } + } + + const merged = mergeCandidates(semanticCandidates, keywordCandidates) + const groups = buildGroups(merged, !!criteria, locale) + + const status = computeStatus(semanticRan || keywordRan, groups.length > 0, warnings) + + const payload: ChatEvidencePayload = { + version: 1, + query, + criteria, + mode, + status, + warnings: warnings.size > 0 ? [...warnings] : undefined, + groups, + } + if (rangeMs) { + payload.appliedTimeFilter = { + startTs: rangeMs.startTs, + endTs: rangeMs.endTs, + } + } + + const cn = isChinese(locale) + payload.summary = + groups.length === 0 + ? cn + ? '未找到明确可计入的证据。' + : 'No conclusive evidence found.' + : cn + ? `共 ${groups.length} 组候选证据。` + : `${groups.length} candidate evidence group(s).` + + return { content: buildContent(payload, locale), data: { evidence: payload } } +} + +export const retrieveChatEvidenceTool: ToolDefinition = { + name: 'retrieve_chat_evidence', + description: + 'Retrieve and assemble conservative, de-identified evidence from the CURRENT conversation for fact/occurrence/count questions (e.g. "how many times did we...", "did we ever...", "prove from chat history..."). Runs semantic + keyword retrieval, groups results, and returns a structured evidence payload. Prefer this over search_messages/semantic_search_current_chat for evidence-chain or counting questions.', + inputSchema, + handler, + category: 'core', + truncationStrategy: 'keep_first', +} diff --git a/packages/tools/src/definitions/search-messages.ts b/packages/tools/src/definitions/search-messages.ts new file mode 100644 index 000000000..1411ec57f --- /dev/null +++ b/packages/tools/src/definitions/search-messages.ts @@ -0,0 +1,64 @@ +/** + * 关键词搜索消息工具 + * + * 支持多关键词、发送者过滤、时间范围、上下文扩展。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { parseExtendedTimeParams } from '../utils/time-params' +import { formatTimeRange } from '../utils/format' +import { timeParamProperties } from '../utils/schemas' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + keywords: { type: 'array', items: { type: 'string' }, description: '搜索关键词列表' }, + sender_id: { type: 'number', description: '按发送者 ID 过滤(通过 get_members 获取)' }, + limit: { type: 'number', description: '返回的最大消息条数' }, + ...timeParamProperties, + }, + required: ['keywords'], +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const { locale, timeFilter: contextTimeFilter, maxMessagesLimit } = context + const keywords = params.keywords as string[] + const limit = Math.min(maxMessagesLimit || (params.limit as number) || 1000, 50000) + const effectiveTimeFilter = parseExtendedTimeParams(params as any, contextTimeFilter) + + const result = await context.dataProvider!.searchMessages(keywords, { + timeFilter: effectiveTimeFilter, + limit, + senderId: params.sender_id as number | undefined, + }) + + const contextBefore = context.searchContextBefore ?? 2 + const contextAfter = context.searchContextAfter ?? 2 + let finalMessages = result.messages + + if ((contextBefore > 0 || contextAfter > 0) && result.messages.length > 0) { + const hitIds = result.messages.map((m) => m.id).filter((id): id is number => id != null) + if (hitIds.length > 0) { + finalMessages = await context.dataProvider!.getSearchMessageContext(hitIds, contextBefore, contextAfter) + } + } + + const data = { + total: result.total, + returned: finalMessages.length, + timeRange: formatTimeRange(effectiveTimeFilter, locale), + rawMessages: finalMessages, + } + + return { content: JSON.stringify(data), data, rawMessages: finalMessages } +} + +export const searchMessagesTool: ToolDefinition = { + name: 'search_messages', + description: + '根据关键词搜索群聊记录。适用于用户想要查找特定话题、关键词相关的聊天内容。可以指定时间范围和发送者来筛选消息。', + inputSchema, + handler, + category: 'core', + truncationStrategy: 'keep_first', +} diff --git a/packages/tools/src/definitions/search.ts b/packages/tools/src/definitions/search.ts new file mode 100644 index 000000000..b885c6440 --- /dev/null +++ b/packages/tools/src/definitions/search.ts @@ -0,0 +1,59 @@ +/** + * 消息搜索工具 + * + * 在聊天记录中按关键词搜索消息。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + keyword: { + type: 'string', + description: '搜索关键词', + }, + limit: { + type: 'number', + description: '返回的最大消息条数', + default: 50, + }, + }, + required: ['keyword'], +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const keyword = params.keyword as string + const limit = (params.limit as number) || 50 + + const result = await context.dataProvider!.searchMessages([keyword], { + timeFilter: context.timeFilter, + limit, + }) + + const data = { + total: result.total, + returned: result.messages.length, + hasMore: result.messages.length < result.total, + messages: result.messages.map((m) => ({ + sender: m.senderName, + content: m.content, + time: new Date(m.timestamp * 1000).toISOString(), + })), + } + + return { + content: JSON.stringify(data), + data, + rawMessages: result.messages, + } +} + +export const searchTool: ToolDefinition = { + name: 'search_keyword', + description: '在聊天记录中搜索关键词,返回匹配的消息列表(发送者、内容、时间)', + inputSchema, + handler, + category: 'core', + truncationStrategy: 'keep_first', +} diff --git a/packages/tools/src/definitions/semantic-search-current-chat.test.ts b/packages/tools/src/definitions/semantic-search-current-chat.test.ts new file mode 100644 index 000000000..43ca60f5c --- /dev/null +++ b/packages/tools/src/definitions/semantic-search-current-chat.test.ts @@ -0,0 +1,136 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { semanticSearchCurrentChatTool } from './semantic-search-current-chat' +import type { SemanticSearchToolResult, SemanticSearchToolService, ToolExecutionContext } from '../types' +import { SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP } from '../types' + +function makeResult(over: Partial = {}): SemanticSearchToolResult { + return { + available: true, + text: 'U1: hello\nU2: world', + returned: 1, + hitCount: 3, + partial: false, + coverage: 1, + truncated: false, + timeRange: { earliest: '2024-01-01T00:00:00.000Z', latest: '2024-01-02T00:00:00.000Z' }, + sources: [ + { + startMessageId: 10, + endMessageId: 12, + score: 0.8, + chunkIds: ['c1'], + snippet: 'U1: hello', + startTime: '2024-01-01T00:00:00.000Z', + endTime: '2024-01-01T00:01:00.000Z', + }, + ], + ...over, + } +} + +function makeContext( + service: SemanticSearchToolService | undefined, + extra: Partial = {} +): ToolExecutionContext { + return { sessionId: 's1', locale: 'zh-CN', semanticIndexService: service, ...extra } +} + +describe('semanticSearchCurrentChatTool', () => { + it('is restricted to query + max_results only', () => { + const props = semanticSearchCurrentChatTool.inputSchema.properties + assert.deepEqual(Object.keys(props).sort(), ['max_results', 'query']) + assert.deepEqual(semanticSearchCurrentChatTool.inputSchema.required, ['query']) + }) + + it('returns an unavailable message when service is not injected', async () => { + const result = await semanticSearchCurrentChatTool.handler({ query: 'x' }, makeContext(undefined)) + assert.match(result.content, /not available/i) + assert.equal(result.rawMessages, undefined) + }) + + it('returns a hint for empty query without calling the service', async () => { + let called = false + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async () => { + called = true + return makeResult() + }, + } + const result = await semanticSearchCurrentChatTool.handler({ query: ' ' }, makeContext(service)) + assert.equal(called, false) + assert.match(result.content, /query/i) + }) + + it('clamps max_results to the hard cap and forwards safe options', async () => { + let receivedMax: number | undefined + let receivedOpts: Record | undefined + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async (_s, _q, opts) => { + receivedMax = opts?.maxResults + receivedOpts = opts as Record + return makeResult() + }, + } + await semanticSearchCurrentChatTool.handler( + { query: 'hello', max_results: 999 }, + makeContext(service, { + preprocessConfig: { desensitize: true }, + ownerPlatformId: 'owner', + maxToolResultTokens: 2000, + }) + ) + assert.equal(receivedMax, SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP) + assert.deepEqual(receivedOpts?.preprocessConfig, { desensitize: true }) + assert.equal(receivedOpts?.ownerPlatformId, 'owner') + assert.equal(receivedOpts?.maxResultTokens, 2000) + }) + + it('passes undefined maxResults when LLM does not specify (service uses configured default)', async () => { + let receivedMax: number | undefined = 1 + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async (_s, _q, opts) => { + receivedMax = opts?.maxResults + return makeResult() + }, + } + await semanticSearchCurrentChatTool.handler({ query: 'hello' }, makeContext(service)) + assert.equal(receivedMax, undefined) + }) + + it('returns safe text + metadata, never rawMessages', async () => { + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async () => makeResult(), + } + const result = await semanticSearchCurrentChatTool.handler({ query: 'hello' }, makeContext(service)) + assert.match(result.content, /U1: hello/) + assert.equal(result.rawMessages, undefined) + const data = result.data as Record + assert.equal(data.returned, 1) + assert.equal(data.hitCount, 3) + assert.ok(data.sources) + assert.equal(JSON.stringify(data).includes('rawMessages'), false) + }) + + it('reports no-results without throwing', async () => { + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async () => makeResult({ returned: 0, sources: [], text: '', hitCount: 0 }), + } + const result = await semanticSearchCurrentChatTool.handler({ query: 'hello' }, makeContext(service)) + assert.match(result.content, /no relevant/i) + }) + + it('appends a truncation notice when truncated', async () => { + const service: SemanticSearchToolService = { + canSearch: () => true, + searchForTool: async () => makeResult({ truncated: true }), + } + const result = await semanticSearchCurrentChatTool.handler({ query: 'hello' }, makeContext(service)) + assert.match(result.content, /result limit/i) + }) +}) diff --git a/packages/tools/src/definitions/semantic-search-current-chat.ts b/packages/tools/src/definitions/semantic-search-current-chat.ts new file mode 100644 index 000000000..53df48449 --- /dev/null +++ b/packages/tools/src/definitions/semantic-search-current-chat.ts @@ -0,0 +1,95 @@ +/** + * 当前对话语义检索工具 + * + * 由 LLM 按需调用:当问题需要当前聊天历史中的具体事实/人物/地点/事件/过往提及时调用。 + * 仅检索当前会话,结果由 SemanticIndexService 经 applyPreprocessingPipeline 脱敏后返回, + * 工具层不接触原始消息,details 不夹带 rawMessages。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP } from '../types' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + query: { + type: 'string', + description: + '语义检索查询,应改写成适合检索当前聊天历史的自然语言描述(围绕需要确认的事实/人物/地点/事件/过往提及)。', + }, + max_results: { + type: 'number', + description: `期望返回的相关片段数,可选。不填使用用户配置的默认值;可按问题复杂度提高,硬上限 ${SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP}。`, + minimum: 1, + maximum: SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP, + }, + }, + required: ['query'], +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const service = context.semanticIndexService + if (!service) { + return { content: 'Semantic search is not available for this conversation.' } + } + + const query = typeof params.query === 'string' ? params.query.trim() : '' + if (!query) { + return { content: 'Please provide a non-empty query describing what to look up in this conversation history.' } + } + + const maxResults = + typeof params.max_results === 'number' && Number.isFinite(params.max_results) + ? Math.max(1, Math.min(SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP, Math.floor(params.max_results))) + : undefined + + const result = await service.searchForTool(context.sessionId, query, { + maxResults, + preprocessConfig: context.preprocessConfig, + ownerPlatformId: context.ownerPlatformId, + locale: context.locale, + maxResultTokens: context.maxToolResultTokens, + timeFilter: context.timeFilter + ? { startTs: context.timeFilter.startTs * 1000, endTs: context.timeFilter.endTs * 1000 } + : undefined, + }) + + if (!result.available) { + return { content: `No semantic index available for this conversation (${result.reason ?? 'unavailable'}).` } + } + + const data = { + query, + returned: result.returned, + hitCount: result.hitCount, + partial: result.partial, + coverage: result.coverage, + truncated: result.truncated, + timeRange: result.timeRange, + sources: result.sources, + } + + if (result.returned === 0) { + return { content: 'No relevant excerpts found in this conversation history.', data } + } + + let content = result.text + if (result.partial) { + content = `Note: index is incomplete, evidence may be partial.\n\n${content}` + } + if (result.truncated) { + content = `${content}\n\nReached the result limit; there may be more matching history. Use a narrower query to continue searching.` + } + + return { content, data } +} + +export const semanticSearchCurrentChatTool: ToolDefinition = { + name: 'semantic_search_current_chat', + description: + 'Semantically search the CURRENT conversation history for relevant excerpts. Use when the question needs concrete facts, people, places, events, or past mentions from this chat. Do NOT use for greetings, writing, or explaining general concepts.', + inputSchema, + handler, + category: 'core', + truncationStrategy: 'keep_first', +} diff --git a/packages/tools/src/definitions/session-info.ts b/packages/tools/src/definitions/session-info.ts new file mode 100644 index 000000000..754c4d0d0 --- /dev/null +++ b/packages/tools/src/definitions/session-info.ts @@ -0,0 +1,44 @@ +/** + * 会话信息工具 + * + * 获取指定会话的详细信息(概览统计)。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { getSessionMeta, getSessionOverview, getMembers } from '@openchatlab/core' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + include_members: { + type: 'boolean', + description: '是否包含成员列表(默认 false)', + default: false, + }, + }, +} + +function handler(params: Record, context: ToolExecutionContext): ToolResult { + const db = context.db! + const meta = getSessionMeta(db) + const overview = getSessionOverview(db) + const includeMembers = params.include_members as boolean + + const data: Record = { ...meta, ...overview } + + if (includeMembers) { + data.members = getMembers(db) + } + + return { + content: JSON.stringify(data), + data, + } +} + +export const sessionInfoTool: ToolDefinition = { + name: 'get_session_info', + description: '获取当前会话的详细信息,包括名称、平台、消息总数、成员数、时间范围等', + inputSchema, + handler, +} diff --git a/packages/tools/src/definitions/sessions.ts b/packages/tools/src/definitions/sessions.ts new file mode 100644 index 000000000..a17e58f16 --- /dev/null +++ b/packages/tools/src/definitions/sessions.ts @@ -0,0 +1,54 @@ +/** + * 会话列表工具 + * + * MCP 场景下列出所有可用的聊天会话。 + * 这是 MCP 场景下的完整聊天记录发现工具,不是 segment 搜索工具。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' +import { getSessionMeta, getSessionOverview } from '@openchatlab/core' +import type { DatabaseAdapter } from '@openchatlab/core' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + keyword: { + type: 'string', + description: '按名称筛选会话(可选)', + }, + }, +} + +export interface SessionListContext extends ToolExecutionContext { + listSessionIds: () => string[] + openDb: (sessionId: string) => DatabaseAdapter | null +} + +function handler(_params: Record, context: SessionListContext): ToolResult { + const keyword = (_params.keyword as string | undefined)?.toLowerCase() + const sessionIds = context.listSessionIds() + + const sessions = sessionIds + .map((id) => { + const db = context.openDb(id) + if (!db) return null + const meta = getSessionMeta(db) + if (!meta) return null + if (keyword && !meta.name.toLowerCase().includes(keyword)) return null + const overview = getSessionOverview(db) + return { id, ...meta, ...overview } + }) + .filter(Boolean) + + return { + content: JSON.stringify({ total: sessions.length, sessions }), + data: sessions, + } +} + +export const sessionsListTool: ToolDefinition = { + name: 'list_sessions', + description: '列出所有可用的聊天会话,返回会话名称、平台、消息数等基本信息', + inputSchema, + handler: handler as ToolDefinition['handler'], +} diff --git a/packages/tools/src/definitions/sql-query.ts b/packages/tools/src/definitions/sql-query.ts new file mode 100644 index 000000000..eaa15c8f7 --- /dev/null +++ b/packages/tools/src/definitions/sql-query.ts @@ -0,0 +1,62 @@ +/** + * SQL 查询工具 + * + * 对聊天数据库执行只读 SQL 查询。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + sql: { + type: 'string', + description: '要执行的 SELECT SQL 查询语句', + }, + }, + required: ['sql'], +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const sql = params.sql as string + + try { + const result = await context.dataProvider!.executeSql(sql) + return { + content: JSON.stringify(result), + data: result, + } + } catch (err) { + return { + content: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }), + } + } +} + +const schemaInputSchema: JsonSchema = { + type: 'object', + properties: {}, +} + +async function schemaHandler(_params: Record, context: ToolExecutionContext): Promise { + const schema = await context.dataProvider!.getSchema() + return { + content: JSON.stringify({ tables: schema }), + data: schema, + } +} + +export const sqlQueryTool: ToolDefinition = { + name: 'execute_sql', + description: '对聊天数据库执行只读 SELECT 查询。使用前可先调用 get_schema 查看表结构。', + inputSchema, + handler, + category: 'analysis', +} + +export const schemaTool: ToolDefinition = { + name: 'get_schema', + description: '查看聊天数据库的表结构(所有表的 CREATE TABLE 语句)', + inputSchema: schemaInputSchema, + handler: schemaHandler, +} diff --git a/packages/tools/src/definitions/time-stats.ts b/packages/tools/src/definitions/time-stats.ts new file mode 100644 index 000000000..1184ee611 --- /dev/null +++ b/packages/tools/src/definitions/time-stats.ts @@ -0,0 +1,37 @@ +/** + * 时间统计工具 + * + * 获取聊天活跃时段分布(小时、星期、每日趋势)。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, JsonSchema } from '../types' + +const inputSchema: JsonSchema = { + type: 'object', + properties: { + type: { + type: 'string', + description: '统计类型:hourly(按小时)、weekday(按星期)、daily(按天)', + enum: ['hourly', 'weekday', 'daily'], + default: 'hourly', + }, + }, +} + +async function handler(params: Record, context: ToolExecutionContext): Promise { + const type = (params.type as 'hourly' | 'weekday' | 'daily') || 'hourly' + const data = await context.dataProvider!.getTimeStats(type, { timeFilter: context.timeFilter }) + + return { + content: JSON.stringify({ type, data }), + data, + } +} + +export const timeStatsTool: ToolDefinition = { + name: 'get_time_stats', + description: '获取聊天活跃时段分布(按小时/星期/每日趋势)', + inputSchema, + handler, + category: 'core', +} diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts new file mode 100644 index 000000000..4a22b591c --- /dev/null +++ b/packages/tools/src/index.ts @@ -0,0 +1,85 @@ +/** + * @openchatlab/tools + * + * ChatLab AI 工具链。 + * 提供平台无关的工具定义和 handler,服务于 MCP Server、HTTP API 和 Electron Agent。 + */ + +// === Registry === +export { + MCP_TOOL_REGISTRY, + AGENT_TOOL_REGISTRY, + SEMANTIC_SEARCH_TOOL_NAME, + RETRIEVE_CHAT_EVIDENCE_TOOL_NAME, + getToolByName, +} from './registry' + +// === Providers === +export { CoreDataProvider } from './providers/core-data-provider' + +// === Tool Definitions === +export { memberStatsTool } from './definitions/member-stats' +export { timeStatsTool } from './definitions/time-stats' + +export { recentMessagesTool } from './definitions/recent-messages' +export { sqlQueryTool, schemaTool } from './definitions/sql-query' +export { chatOverviewTool } from './definitions/chat-overview' +export { searchMessagesTool } from './definitions/search-messages' +export { deepSearchMessagesTool } from './definitions/deep-search-messages' +export { getMessageContextTool } from './definitions/get-message-context' +export { getSegmentMessagesTool } from './definitions/get-segment-messages' +export { getMembersTool } from './definitions/get-members' +export { getMemberNameHistoryTool } from './definitions/get-member-name-history' +export { getConversationBetweenTool } from './definitions/get-conversation-between' +export { getSegmentSummariesTool } from './definitions/get-segment-summaries' +export { responseTimeAnalysisTool } from './definitions/response-time-analysis' +export { keywordFrequencyTool } from './definitions/keyword-frequency' +export { renderChartTool } from './definitions/render-chart' +export { semanticSearchCurrentChatTool } from './definitions/semantic-search-current-chat' +export { retrieveChatEvidenceTool } from './definitions/retrieve-chat-evidence' + +// === SQL Tools === +export { SQL_TOOL_DEFS, createSqlToolDefinition, createAllSqlToolDefinitions } from './sql' + +// === Utils === +export { isChineseLocale, t, formatTimeRange, formatMessageCompact } from './utils/format' +export { parseExtendedTimeParams } from './utils/time-params' + +// === Types === +export type { + ToolDefinition, + ToolExecutionContext, + ToolResult, + JsonSchema, + RawMessage, + ToolDataProvider, + SearchMessagesResult, + MemberStatItem, + SchemaTableInfo, + ToolTimeRange, + ToolCategory, + TruncationStrategy, + ChatOverviewResult, + MemberInfo, + NameHistoryItem, + SegmentMessagesResult, + ConversationResult, + SegmentSummaryItem, + SqlToolDef, + SqlToolExecution, + SegmentResult, + SemanticSearchToolService, + SemanticSearchToolResult, + SemanticSearchToolSource, + SemanticSearchToolOptions, + ChatEvidencePayload, + ChatEvidenceGroup, + ChatEvidenceSource, + EvidenceStatus, + EvidencePayloadStatus, + EvidenceRetrievalMode, + EvidenceWarning, + EvidenceTimeRangeMs, +} from './types' + +export { SEMANTIC_SEARCH_DEFAULT_MAX_RESULTS, SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP } from './types' diff --git a/packages/tools/src/providers/core-data-provider.test.ts b/packages/tools/src/providers/core-data-provider.test.ts new file mode 100644 index 000000000..78a5045d1 --- /dev/null +++ b/packages/tools/src/providers/core-data-provider.test.ts @@ -0,0 +1,128 @@ +/** + * CoreDataProvider 关键词搜索回归测试。 + * + * 重点锁定历史 bug:多关键词被 join(' ') 拼成单子串,导致 CLI/MCP/Web 下 + * search_messages / deep_search_messages 在多关键词时永远返回 0。 + * 这里用真实内存 SQLite 验证多关键词 OR 命中、时间/发送者过滤与系统消息排除。 + * + * Run: npx tsx --test packages/tools/src/providers/core-data-provider.test.ts + */ + +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import Database from 'better-sqlite3' +import { CoreDataProvider } from './core-data-provider' +import type { DatabaseAdapter, PreparedStatement, RunResult } from '@openchatlab/core' +import { openTestSqliteDatabase } from '../../../../tests/helpers/sqlite.mts' + +class Stmt implements PreparedStatement { + readonly?: boolean + constructor(private stmt: Database.Statement) { + this.readonly = stmt.readonly + } + get(...p: unknown[]) { + return this.stmt.get(...p) as Record | undefined + } + all(...p: unknown[]) { + return this.stmt.all(...p) as Record[] + } + run(...p: unknown[]): RunResult { + const r = this.stmt.run(...p) + return { changes: r.changes, lastInsertRowid: r.lastInsertRowid } + } +} + +class Adapter implements DatabaseAdapter { + constructor(private db: Database.Database) {} + exec(sql: string) { + this.db.exec(sql) + } + prepare(sql: string) { + return new Stmt(this.db.prepare(sql)) + } + transaction(fn: () => T): T { + return this.db.transaction(fn)() + } + pragma(p: string) { + return this.db.pragma(p) + } + close() { + this.db.close() + } +} + +const T0 = 1700000000 + +describe('CoreDataProvider keyword search', () => { + let raw: Database.Database + let provider: CoreDataProvider + + beforeEach(() => { + raw = openTestSqliteDatabase() + raw.exec(` + CREATE TABLE member ( + id INTEGER PRIMARY KEY, platform_id TEXT, account_name TEXT, + group_nickname TEXT, aliases TEXT, avatar TEXT + ); + CREATE TABLE message ( + id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, + content TEXT, reply_to_message_id TEXT, platform_message_id TEXT + ); + INSERT INTO member (id, platform_id, account_name) VALUES + (1, 'u1', '小红'), (2, 'u2', '地瓜'), (99, 'sys', '系统消息'); + `) + const ins = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)') + ins.run(1, 1, T0 + 1, 0, '今晚来打麻将吗') + ins.run(2, 2, T0 + 2, 0, '我更想玩扑克') + ins.run(3, 1, T0 + 3, 0, '不如吃个火锅') + ins.run(4, 2, T0 + 100, 0, '周末再约麻将') + // 系统消息含关键词,应被排除 + ins.run(5, 99, T0 + 4, 0, '系统播报:麻将房已创建') + provider = new CoreDataProvider(new Adapter(raw)) + }) + + afterEach(() => { + try { + raw.close() + } catch { + /* already closed */ + } + }) + + it('multi-keyword search uses OR (regression: previously joined into one substring → 0)', async () => { + const r = await provider.searchMessages(['麻将', '扑克']) + // 命中 id 1/2/4(系统消息 id5 排除),而不是查找字面串 "麻将 扑克" + assert.equal(r.total, 3) + const ids = r.messages.map((m) => m.id as number).sort((a, b) => a - b) + assert.deepEqual(ids, [1, 2, 4]) + }) + + it('single keyword still works', async () => { + const r = await provider.searchMessages(['麻将']) + assert.equal(r.total, 2) + }) + + it('excludes system messages', async () => { + const r = await provider.searchMessages(['麻将']) + assert.ok(!r.messages.some((m) => m.id === 5)) + }) + + it('honors timeFilter', async () => { + const r = await provider.searchMessages(['麻将'], { + timeFilter: { startTs: T0 + 50, endTs: T0 + 200 }, + }) + assert.equal(r.total, 1) + assert.equal(r.messages[0].id, 4) + }) + + it('honors senderId', async () => { + const r = await provider.searchMessages(['麻将'], { senderId: 1 }) + assert.equal(r.total, 1) + assert.equal(r.messages[0].id, 1) + }) + + it('deepSearchMessages also supports multi-keyword OR', async () => { + const r = await provider.deepSearchMessages(['麻将', '扑克']) + assert.equal(r.total, 3) + }) +}) diff --git a/packages/tools/src/providers/core-data-provider.ts b/packages/tools/src/providers/core-data-provider.ts new file mode 100644 index 000000000..51413ec2f --- /dev/null +++ b/packages/tools/src/providers/core-data-provider.ts @@ -0,0 +1,172 @@ +/** + * CoreDataProvider + * + * ToolDataProvider implementation backed by @openchatlab/core query functions. + * Used by Server / MCP, accessing SQLite through DatabaseAdapter. + */ + +import type { DatabaseAdapter } from '@openchatlab/core' +import { + searchMessagesByKeywords, + getRecentMessages as coreGetRecentMessages, + getMemberActivity, + getHourlyActivity, + getWeekdayActivity, + getDailyActivity, + executeReadonlySql, + getDatabaseSchema, + getMessageContext as coreGetMessageContext, + getSearchMessageContext as coreGetSearchMessageContext, + getConversationBetween as coreGetConversationBetween, + getMemberNameHistory as coreGetMemberNameHistory, + getMembersWithAliases, + executeParameterizedSql as coreExecuteParameterizedSql, + getChatOverview as coreGetChatOverview, + getSegmentMessages as coreGetSegmentMessages, + getSegmentSummaries as coreGetSegmentSummaries, +} from '@openchatlab/core' +import type { + ToolDataProvider, + SearchMessagesResult, + MemberStatItem, + SchemaTableInfo, + ToolTimeRange, + ChatOverviewResult, + MemberInfo, + NameHistoryItem, + SegmentMessagesResult, + ConversationResult, + SegmentSummaryItem, + RawMessage, +} from '../types' + +export class CoreDataProvider implements ToolDataProvider { + constructor(private db: DatabaseAdapter) {} + + async searchMessages( + keywords: string[], + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } + ): Promise { + const result = searchMessagesByKeywords(this.db, keywords, { + startTs: options?.timeFilter?.startTs, + endTs: options?.timeFilter?.endTs, + senderId: options?.senderId, + limit: options?.limit ?? 50, + }) + return { + messages: result.messages.map((m) => ({ + id: m.id, + senderId: m.senderId, + senderName: m.senderName, + senderPlatformId: m.senderPlatformId, + content: m.content, + timestamp: m.timestamp, + })), + total: result.total ?? result.messages.length, + } + } + + async deepSearchMessages( + keywords: string[], + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } + ): Promise { + return this.searchMessages(keywords, options) + } + + async getSearchMessageContext( + messageIds: number[], + contextBefore: number, + contextAfter: number + ): Promise { + return coreGetSearchMessageContext(this.db, messageIds, contextBefore, contextAfter) + } + + async getRecentMessages(options?: { timeFilter?: ToolTimeRange; limit?: number }): Promise { + const messages = coreGetRecentMessages(this.db, { limit: options?.limit ?? 50 }) + return { + messages: messages.map((m) => ({ + id: m.id, + senderId: m.senderId, + senderName: m.senderName, + senderPlatformId: m.senderPlatformId, + content: m.content, + timestamp: m.timestamp, + })), + total: messages.length, + } + } + + async getMessageContext(messageIds: number[], contextSize: number): Promise { + return coreGetMessageContext(this.db, messageIds, contextSize) + } + + async getChatOverview(topN?: number): Promise { + return coreGetChatOverview(this.db, topN) + } + + async getMembers(): Promise { + return getMembersWithAliases(this.db) + } + + async getMemberStats(options?: { timeFilter?: ToolTimeRange; top?: number }): Promise { + const top = options?.top ?? 20 + const members = getMemberActivity(this.db, options?.timeFilter) + return members.slice(0, top).map((m) => ({ + name: m.name, + messageCount: m.messageCount, + percentage: m.percentage, + })) + } + + async getMemberNameHistory(memberId: number): Promise { + return coreGetMemberNameHistory(this.db, memberId) + } + + async getTimeStats( + type: 'hourly' | 'weekday' | 'daily', + options?: { timeFilter?: ToolTimeRange } + ): Promise { + const filter = options?.timeFilter + switch (type) { + case 'weekday': + return getWeekdayActivity(this.db, filter) + case 'daily': + return getDailyActivity(this.db, filter) + case 'hourly': + default: + return getHourlyActivity(this.db, filter) + } + } + + async getSegmentMessages(segmentId: number, limit?: number): Promise { + return coreGetSegmentMessages(this.db, segmentId, limit) + } + + async getSegmentSummaries(options?: { limit?: number; timeFilter?: ToolTimeRange }): Promise { + return coreGetSegmentSummaries(this.db, options) + } + + async getConversationBetween( + memberId1: number, + memberId2: number, + timeFilter?: ToolTimeRange, + limit?: number + ): Promise { + return coreGetConversationBetween(this.db, memberId1, memberId2, timeFilter, limit) + } + + async executeSql(sql: string): Promise { + return executeReadonlySql(this.db, sql) + } + + async executeParameterizedSql>( + query: string, + params: Record + ): Promise { + return coreExecuteParameterizedSql(this.db, query, params) + } + + async getSchema(): Promise { + return getDatabaseSchema(this.db) + } +} diff --git a/packages/tools/src/registry.test.ts b/packages/tools/src/registry.test.ts new file mode 100644 index 000000000..de3dcefeb --- /dev/null +++ b/packages/tools/src/registry.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + AGENT_TOOL_REGISTRY, + MCP_TOOL_REGISTRY, + SEMANTIC_SEARCH_TOOL_NAME, + RETRIEVE_CHAT_EVIDENCE_TOOL_NAME, + getToolByName, +} from './registry' + +describe('semantic_search_current_chat registry placement', () => { + it('is registered in AGENT registry only, not MCP (privacy: no external semantic access in Phase 1)', () => { + const inAgent = AGENT_TOOL_REGISTRY.some((t) => t.name === SEMANTIC_SEARCH_TOOL_NAME) + const inMcp = MCP_TOOL_REGISTRY.some((t) => t.name === SEMANTIC_SEARCH_TOOL_NAME) + assert.equal(inAgent, true) + assert.equal(inMcp, false) + }) + + it('is resolvable by name and constrains inputs to query + max_results', () => { + const tool = getToolByName(SEMANTIC_SEARCH_TOOL_NAME) + assert.ok(tool) + assert.deepEqual(Object.keys(tool!.inputSchema.properties).sort(), ['max_results', 'query']) + }) +}) + +describe('retrieve_chat_evidence registry placement', () => { + it('is registered in AGENT registry only, not MCP', () => { + const inAgent = AGENT_TOOL_REGISTRY.some((t) => t.name === RETRIEVE_CHAT_EVIDENCE_TOOL_NAME) + const inMcp = MCP_TOOL_REGISTRY.some((t) => t.name === RETRIEVE_CHAT_EVIDENCE_TOOL_NAME) + assert.equal(inAgent, true) + assert.equal(inMcp, false) + }) + + it('is resolvable by name and requires query', () => { + const tool = getToolByName(RETRIEVE_CHAT_EVIDENCE_TOOL_NAME) + assert.ok(tool) + assert.deepEqual(tool!.inputSchema.required, ['query']) + }) +}) diff --git a/packages/tools/src/registry.ts b/packages/tools/src/registry.ts new file mode 100644 index 000000000..53197647c --- /dev/null +++ b/packages/tools/src/registry.ts @@ -0,0 +1,104 @@ +/** + * Tool registries + * + * AGENT_TOOL_REGISTRY: full toolset for Server Agent / Electron Agent + * (core + analysis + raw SQL + declarative SQL tools). + * MCP_TOOL_REGISTRY: slim toolset for MCP Server — core + analysis + raw SQL + * plus MCP-specific session-discovery tools. Omits declarative SQL tools + * to keep tool-schema token cost low for external AI agents. + */ + +import type { ToolDefinition } from './types' + +import { sqlQueryTool, schemaTool } from './definitions/sql-query' +import { renderChartTool } from './definitions/render-chart' +import { sessionInfoTool } from './definitions/session-info' +import { sessionsListTool } from './definitions/sessions' +import { chatOverviewTool } from './definitions/chat-overview' +import { searchMessagesTool } from './definitions/search-messages' +import { deepSearchMessagesTool } from './definitions/deep-search-messages' +import { getMessageContextTool } from './definitions/get-message-context' +import { getSegmentMessagesTool } from './definitions/get-segment-messages' +import { getMembersTool } from './definitions/get-members' +import { memberStatsTool } from './definitions/member-stats' +import { timeStatsTool } from './definitions/time-stats' +import { recentMessagesTool } from './definitions/recent-messages' +import { getMemberNameHistoryTool } from './definitions/get-member-name-history' +import { getConversationBetweenTool } from './definitions/get-conversation-between' +import { getSegmentSummariesTool } from './definitions/get-segment-summaries' +import { responseTimeAnalysisTool } from './definitions/response-time-analysis' +import { keywordFrequencyTool } from './definitions/keyword-frequency' +import { semanticSearchCurrentChatTool } from './definitions/semantic-search-current-chat' +import { retrieveChatEvidenceTool } from './definitions/retrieve-chat-evidence' +import { SQL_TOOL_DEFS, createAllSqlToolDefinitions } from './sql' + +/** + * Core + analysis + raw SQL — shared between Agent and MCP. + * New non-SQL tools added here will automatically appear in both registries. + */ +const SHARED_TOOLS: ToolDefinition[] = [ + // Core + chatOverviewTool, + searchMessagesTool, + deepSearchMessagesTool, + recentMessagesTool, + getMessageContextTool, + getSegmentMessagesTool, + getMembersTool, + schemaTool, + + // Analysis + memberStatsTool, + timeStatsTool, + getMemberNameHistoryTool, + getConversationBetweenTool, + getSegmentSummariesTool, + responseTimeAnalysisTool, + keywordFrequencyTool, + renderChartTool, + + // Raw SQL + sqlQueryTool, +] + +/** + * Agent full toolset (Server Agent / Electron Agent). + * Includes declarative SQL convenience tools on top of the shared set. + * + * semantic_search_current_chat 仅在 AGENT registry,不进 MCP(语义片段外部访问的隐私/权限后续单独设计)。 + * runner 会按当前会话是否可检索动态过滤,未启用/无 chunk/需重建时不暴露给 LLM。 + */ +export const AGENT_TOOL_REGISTRY: ToolDefinition[] = [ + ...SHARED_TOOLS, + semanticSearchCurrentChatTool, + retrieveChatEvidenceTool, + ...createAllSqlToolDefinitions(SQL_TOOL_DEFS), +] + +/** 语义检索工具名(runner 动态过滤用) */ +export const SEMANTIC_SEARCH_TOOL_NAME = semanticSearchCurrentChatTool.name + +/** 证据检索工具名(planner / runner 判断用) */ +export const RETRIEVE_CHAT_EVIDENCE_TOOL_NAME = retrieveChatEvidenceTool.name + +/** + * MCP Server toolset — slim registry optimised for external AI agents. + * + * Includes MCP-specific session-discovery tools + shared core/analysis tools. + * Omits declarative SQL tools to reduce tool-schema token overhead (~40% saving). + * LLMs can use execute_sql + get_schema for any custom query. + */ +export const MCP_TOOL_REGISTRY: ToolDefinition[] = [ + // MCP-specific: session discovery & schema + sessionsListTool, + sessionInfoTool, + // Shared core + analysis + raw SQL + ...SHARED_TOOLS, +] + +/** + * 按名称查找工具(在所有注册表中查找) + */ +export function getToolByName(name: string): ToolDefinition | undefined { + return AGENT_TOOL_REGISTRY.find((t) => t.name === name) || MCP_TOOL_REGISTRY.find((t) => t.name === name) +} diff --git a/packages/tools/src/sql/definitions.ts b/packages/tools/src/sql/definitions.ts new file mode 100644 index 000000000..26bc941ae --- /dev/null +++ b/packages/tools/src/sql/definitions.ts @@ -0,0 +1,241 @@ +/** + * 内置 SQL 分析工具定义集合 + * + * 声明式 SQL 工具:每个定义在 LLM 眼中是一个 Function Calling 工具, + * 执行时通过参数化 SQL 查询数据库,将结果格式化为文本返回给 LLM。 + */ + +import type { SqlToolDef } from '../types' + +export const SQL_TOOL_DEFS: SqlToolDef[] = [ + // ==================== 通用分析 ==================== + { + name: 'message_type_breakdown', + description: '按消息类型统计近 N 天的消息分布(文本、图片、语音、表情等各有多少条)。适用于了解沟通方式偏好。', + parameters: { + type: 'object', + properties: { + days: { type: 'number', description: '统计最近多少天的数据' }, + }, + required: ['days'], + }, + execution: { + type: 'sqlite', + query: + "SELECT CASE type WHEN 0 THEN '文本' WHEN 1 THEN '图片' WHEN 2 THEN '语音' WHEN 3 THEN '视频' WHEN 4 THEN '文件' WHEN 5 THEN '表情' WHEN 7 THEN '链接' WHEN 20 THEN '红包' WHEN 22 THEN '拍一拍' WHEN 80 THEN '系统消息' WHEN 81 THEN '撤回' ELSE '其他' END AS type_name, COUNT(*) AS msg_count, ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER(), 1) AS percentage FROM message WHERE ts > unixepoch('now', '-' || @days || ' days') GROUP BY type ORDER BY msg_count DESC", + rowTemplate: '{type_name}:{msg_count} 条(占 {percentage}%)', + summaryTemplate: '消息类型分布(共 {rowCount} 种类型):', + fallback: '该时间范围内没有消息记录', + }, + }, + { + name: 'peak_chat_hours_by_member', + description: + '分析指定成员在近 N 天内每小时的发言量分布,找出其最活跃的时段。需要先通过 get_members 获取 member_id。', + parameters: { + type: 'object', + properties: { + member_id: { type: 'number', description: '成员 ID(通过 get_members 获取)' }, + days: { type: 'number', description: '统计最近多少天的数据', default: 30 }, + }, + required: ['member_id'], + }, + execution: { + type: 'sqlite', + query: + "SELECT CAST(strftime('%H', ts, 'unixepoch', 'localtime') AS INTEGER) AS hour, COUNT(*) AS msg_count FROM message WHERE sender_id = @member_id AND ts > unixepoch('now', '-' || @days || ' days') GROUP BY hour ORDER BY msg_count DESC", + rowTemplate: '{hour}:00 — {msg_count} 条消息', + summaryTemplate: '该成员各时段发言量(共 {rowCount} 个活跃时段):', + fallback: '该成员在指定时间范围内没有发言记录', + }, + }, + + // ==================== 社群分析 ==================== + { + name: 'member_activity_trend', + description: + '查看指定成员近 N 天的每日发言数量变化趋势。适用于观察某人是否变得更活跃或更沉默。需要先通过 get_members 获取 member_id。', + parameters: { + type: 'object', + properties: { + member_id: { type: 'number', description: '成员 ID(通过 get_members 获取)' }, + days: { type: 'number', description: '查看最近多少天的趋势' }, + }, + required: ['member_id', 'days'], + }, + execution: { + type: 'sqlite', + query: + "SELECT date(ts, 'unixepoch', 'localtime') AS day, COUNT(*) AS msg_count FROM message WHERE sender_id = @member_id AND ts > unixepoch('now', '-' || @days || ' days') GROUP BY day ORDER BY day", + rowTemplate: '{day}:{msg_count} 条', + summaryTemplate: '该成员近 {rowCount} 天有发言记录:', + fallback: '该成员在指定时间范围内没有发言记录', + }, + }, + { + name: 'silent_members', + description: '检测超过 N 天未发言的「沉默成员」。适用于社群运营中发现流失风险用户。', + parameters: { + type: 'object', + properties: { + days: { type: 'number', description: '多少天未发言算沉默', default: 7 }, + }, + required: ['days'], + }, + execution: { + type: 'sqlite', + query: + "SELECT m.id AS member_id, COALESCE(m.group_nickname, m.account_name, m.platform_id) AS name, MAX(msg.ts) AS last_msg_ts, CAST((unixepoch('now') - MAX(msg.ts)) / 86400 AS INTEGER) AS silent_days FROM member m JOIN message msg ON msg.sender_id = m.id GROUP BY m.id HAVING silent_days >= @days ORDER BY silent_days DESC LIMIT 30", + rowTemplate: '{name} — 已沉默 {silent_days} 天', + summaryTemplate: '共发现 {rowCount} 位沉默成员:', + fallback: '没有发现超过指定天数未发言的成员,社群活跃度良好!', + }, + }, + { + name: 'reply_interaction_ranking', + description: '分析群内的回复互动关系排行,找出谁回复谁最多。适用于发现社群中的核心互动关系和意见领袖。', + parameters: { + type: 'object', + properties: { + days: { type: 'number', description: '统计最近多少天的数据' }, + limit: { type: 'number', description: '返回前多少对互动关系', default: 10 }, + }, + required: ['days'], + }, + execution: { + type: 'sqlite', + query: + "SELECT COALESCE(replier.group_nickname, replier.account_name) AS replier_name, COALESCE(original.group_nickname, original.account_name) AS original_name, COUNT(*) AS reply_count FROM message reply_msg JOIN message orig_msg ON reply_msg.reply_to_message_id = CAST(orig_msg.id AS TEXT) JOIN member replier ON reply_msg.sender_id = replier.id JOIN member original ON orig_msg.sender_id = original.id WHERE reply_msg.reply_to_message_id IS NOT NULL AND reply_msg.ts > unixepoch('now', '-' || @days || ' days') GROUP BY reply_msg.sender_id, orig_msg.sender_id ORDER BY reply_count DESC LIMIT @limit", + rowTemplate: '{replier_name} → {original_name}:{reply_count} 次回复', + summaryTemplate: '回复互动 Top {rowCount}:', + fallback: '该时间范围内没有回复互动记录', + }, + }, + + // ==================== 情感分析 ==================== + { + name: 'mutual_interaction_pairs', + description: + '找出互动最频繁的成员对,基于双向消息时间接近度(一方发言后 5 分钟内另一方也发言即视为一次互动)。适用于发现关系亲密的好友组合。', + parameters: { + type: 'object', + properties: { + days: { type: 'number', description: '统计最近多少天的数据' }, + limit: { type: 'number', description: '返回前多少对', default: 10 }, + }, + required: ['days'], + }, + execution: { + type: 'sqlite', + query: + "WITH ordered_messages AS (SELECT id, sender_id, ts, LAG(sender_id) OVER (ORDER BY ts, id) AS prev_sender_id, LAG(ts) OVER (ORDER BY ts, id) AS prev_ts FROM message WHERE type = 0 AND ts > unixepoch('now', '-' || @days || ' days')), pairs AS (SELECT CASE WHEN prev_sender_id < sender_id THEN prev_sender_id ELSE sender_id END AS member_a_id, CASE WHEN prev_sender_id < sender_id THEN sender_id ELSE prev_sender_id END AS member_b_id FROM ordered_messages WHERE prev_sender_id IS NOT NULL AND prev_sender_id != sender_id AND prev_ts IS NOT NULL AND ts <= prev_ts + 300) SELECT COALESCE(m1.group_nickname, m1.account_name) AS member_a, COALESCE(m2.group_nickname, m2.account_name) AS member_b, COUNT(*) AS interaction_count FROM pairs JOIN member m1 ON pairs.member_a_id = m1.id JOIN member m2 ON pairs.member_b_id = m2.id GROUP BY member_a_id, member_b_id ORDER BY interaction_count DESC LIMIT @limit", + rowTemplate: '{member_a} ↔ {member_b}:{interaction_count} 次互动', + summaryTemplate: '互动最频繁的 {rowCount} 对好友:', + fallback: '该时间范围内没有检测到明显的互动关系', + }, + }, + { + name: 'member_message_length_stats', + description: '统计各成员的平均消息长度(仅文本消息),长消息通常意味着更用心的交流。适用于发现深度交流者。', + parameters: { + type: 'object', + properties: { + days: { type: 'number', description: '统计最近多少天的数据' }, + top_n: { type: 'number', description: '返回前多少名', default: 10 }, + }, + required: ['days'], + }, + execution: { + type: 'sqlite', + query: + "SELECT COALESCE(m.group_nickname, m.account_name) AS name, COUNT(*) AS msg_count, ROUND(AVG(LENGTH(msg.content)), 1) AS avg_length, MAX(LENGTH(msg.content)) AS max_length FROM message msg JOIN member m ON msg.sender_id = m.id WHERE msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 0 AND msg.ts > unixepoch('now', '-' || @days || ' days') GROUP BY msg.sender_id HAVING msg_count >= 5 ORDER BY avg_length DESC LIMIT @top_n", + rowTemplate: '{name} — 平均 {avg_length} 字/条(共 {msg_count} 条,最长 {max_length} 字)', + summaryTemplate: '消息长度 Top {rowCount}(更长 = 更用心):', + fallback: '该时间范围内没有足够的文本消息数据', + }, + }, + + // ==================== 活跃度趋势 ==================== + { + name: 'daily_active_members', + description: + '统计每日独立发言人数(DAU)和消息量,用于观察群活力变化趋势。适用于"群活跃度趋势怎么样"、"最近有多少人在说话"。', + parameters: { + type: 'object', + properties: { + days: { type: 'number', description: '统计最近多少天的数据', default: 30 }, + }, + required: ['days'], + }, + execution: { + type: 'sqlite', + query: + "SELECT date(ts, 'unixepoch', 'localtime') AS day, COUNT(DISTINCT sender_id) AS active_members, COUNT(*) AS msg_count FROM message WHERE ts > unixepoch('now', '-' || @days || ' days') GROUP BY day ORDER BY day", + rowTemplate: '{day}:{active_members} 人活跃,{msg_count} 条消息', + summaryTemplate: '近 {rowCount} 天的每日活跃人数趋势:', + fallback: '该时间范围内没有消息记录', + }, + }, + { + name: 'conversation_initiator_stats', + description: '统计每个成员发起会话(作为会话首条消息的发送者)的次数,找出谁最常开启话题。', + parameters: { + type: 'object', + properties: { + days: { type: 'number', description: '统计最近多少天的数据', default: 30 }, + limit: { type: 'number', description: '返回前多少名', default: 10 }, + }, + required: ['days'], + }, + execution: { + type: 'sqlite', + query: + "SELECT COALESCE(m.group_nickname, m.account_name) AS name, COUNT(*) AS initiated_count FROM segment cs JOIN message_context mc ON mc.segment_id = cs.id JOIN message msg ON msg.id = mc.message_id JOIN member m ON msg.sender_id = m.id WHERE msg.ts = cs.start_ts AND cs.start_ts > unixepoch('now', '-' || @days || ' days') GROUP BY msg.sender_id ORDER BY initiated_count DESC LIMIT @limit", + rowTemplate: '{name}:发起 {initiated_count} 次话题', + summaryTemplate: '话题发起者 Top {rowCount}:', + fallback: '该时间范围内没有会话记录,可能需要先生成会话索引', + }, + }, + { + name: 'activity_heatmap', + description: '返回 星期×小时 的消息数矩阵,适合生成活跃度热力图。weekday: 0=周日, 1=周一, ..., 6=周六。', + parameters: { + type: 'object', + properties: { + days: { type: 'number', description: '统计最近多少天的数据', default: 30 }, + }, + required: ['days'], + }, + execution: { + type: 'sqlite', + query: + "SELECT CAST(strftime('%w', ts, 'unixepoch', 'localtime') AS INTEGER) AS weekday, CAST(strftime('%H', ts, 'unixepoch', 'localtime') AS INTEGER) AS hour, COUNT(*) AS msg_count FROM message WHERE ts > unixepoch('now', '-' || @days || ' days') GROUP BY weekday, hour ORDER BY weekday, hour", + rowTemplate: '星期{weekday} {hour}:00 — {msg_count} 条', + summaryTemplate: '活跃度热力图数据(共 {rowCount} 个时段有消息):', + fallback: '该时间范围内没有消息记录', + }, + }, + + // ==================== 客服分析 ==================== + { + name: 'unanswered_messages', + description: + '查找近 N 天内未被回复的消息,这些可能是未解决的客户问题。仅统计文本消息且内容超过 10 字的(过滤简短寒暄)。', + parameters: { + type: 'object', + properties: { + days: { type: 'number', description: '查找最近多少天的数据' }, + limit: { type: 'number', description: '最多返回多少条', default: 20 }, + }, + required: ['days'], + }, + execution: { + type: 'sqlite', + query: + "SELECT COALESCE(m.group_nickname, m.account_name) AS sender_name, datetime(msg.ts, 'unixepoch', 'localtime') AS send_time, SUBSTR(msg.content, 1, 100) AS content_preview FROM message msg JOIN member m ON msg.sender_id = m.id WHERE msg.type = 0 AND msg.content IS NOT NULL AND LENGTH(msg.content) > 10 AND msg.ts > unixepoch('now', '-' || @days || ' days') AND NOT EXISTS (SELECT 1 FROM message reply WHERE reply.reply_to_message_id = CAST(msg.id AS TEXT)) AND NOT EXISTS (SELECT 1 FROM message next WHERE next.sender_id != msg.sender_id AND next.ts > msg.ts AND next.ts <= msg.ts + 1800) ORDER BY msg.ts DESC LIMIT @limit", + rowTemplate: '[{send_time}] {sender_name}:{content_preview}', + summaryTemplate: '共发现 {rowCount} 条可能未被回复的消息:', + fallback: '该时间范围内所有消息都已得到回复,服务质量很好!', + }, + }, +] diff --git a/packages/tools/src/sql/executor.ts b/packages/tools/src/sql/executor.ts new file mode 100644 index 000000000..62916d2dd --- /dev/null +++ b/packages/tools/src/sql/executor.ts @@ -0,0 +1,71 @@ +/** + * 声明式 SQL 工具执行器 + * + * 将 SqlToolDef 转换为 ToolDefinition,通过 dataProvider.executeParameterizedSql 执行。 + */ + +import type { ToolDefinition, ToolExecutionContext, ToolResult, SqlToolDef } from '../types' + +function formatRow(template: string, row: Record): string { + return template.replace(/\{(\w+)\}/g, (_, col) => { + const val = row[col] + return val !== null && val !== undefined ? String(val) : '' + }) +} + +function resolveTemplate( + toolName: string, + key: string, + fallback: string, + translateFn?: (key: string) => string | undefined +): string { + if (!translateFn) return fallback + const i18nKey = `ai.tools.${toolName}.${key}` + return translateFn(i18nKey) ?? fallback +} + +export function createSqlToolDefinition(def: SqlToolDef): ToolDefinition { + return { + name: def.name, + description: def.description, + inputSchema: def.parameters, + category: 'analysis', + handler: async (params: Record, context: ToolExecutionContext): Promise => { + const rows = await context.dataProvider!.executeParameterizedSql(def.execution.query, params) + + const fallback = resolveTemplate(def.name, 'fallback', def.execution.fallback, context.translateTemplate) + + if (!rows || rows.length === 0) { + return { + content: fallback, + data: { rows: [], rowCount: 0 }, + } + } + + const rowTemplate = resolveTemplate(def.name, 'rowTemplate', def.execution.rowTemplate, context.translateTemplate) + const summaryTemplate = def.execution.summaryTemplate + ? resolveTemplate(def.name, 'summaryTemplate', def.execution.summaryTemplate, context.translateTemplate) + : undefined + + const lines: string[] = [] + + if (summaryTemplate) { + lines.push(summaryTemplate.replace(/\{rowCount\}/g, String(rows.length))) + lines.push('') + } + + for (const row of rows) { + lines.push(formatRow(rowTemplate, row as Record)) + } + + return { + content: lines.join('\n'), + data: { rows, rowCount: rows.length }, + } + }, + } +} + +export function createAllSqlToolDefinitions(defs: SqlToolDef[]): ToolDefinition[] { + return defs.map(createSqlToolDefinition) +} diff --git a/packages/tools/src/sql/index.ts b/packages/tools/src/sql/index.ts new file mode 100644 index 000000000..b375bc384 --- /dev/null +++ b/packages/tools/src/sql/index.ts @@ -0,0 +1,6 @@ +/** + * SQL 工具模块 + */ + +export { SQL_TOOL_DEFS } from './definitions' +export { createSqlToolDefinition, createAllSqlToolDefinitions } from './executor' diff --git a/packages/tools/src/sql/types.ts b/packages/tools/src/sql/types.ts new file mode 100644 index 000000000..d0411bcc6 --- /dev/null +++ b/packages/tools/src/sql/types.ts @@ -0,0 +1,5 @@ +/** + * 声明式 SQL 工具类型定义 + */ + +export { type SqlToolDef, type SqlToolExecution, type JsonSchema } from '../types' diff --git a/packages/tools/src/types.ts b/packages/tools/src/types.ts new file mode 100644 index 000000000..ce3ab0b09 --- /dev/null +++ b/packages/tools/src/types.ts @@ -0,0 +1,370 @@ +/** + * 工具系统类型定义 + * + * 平台无关的工具注册表类型,同时服务于 MCP Server、HTTP API 和 Electron Agent。 + */ + +import type { ChartPayload, DatabaseAdapter, EvidenceTimeRangeMs } from '@openchatlab/core' + +export type { + EvidenceRetrievalMode, + EvidenceStatus, + EvidencePayloadStatus, + EvidenceWarning, + EvidenceTimeRangeMs, + ChatEvidenceSource, + ChatEvidenceGroup, + ChatEvidencePayload, +} from '@openchatlab/core' + +// ==================== Schema ==================== + +/** + * JSON Schema 参数定义(兼容 MCP tool input schema) + */ +export interface JsonSchema { + type: 'object' + properties: Record< + string, + { + type: string + description?: string + items?: { type: string } + properties?: Record + additionalProperties?: boolean | Record + default?: unknown + enum?: unknown[] + minimum?: number + maximum?: number + } + > + required?: string[] +} + +// ==================== Time Filter ==================== + +export interface ToolTimeRange { + startTs: number + endTs: number +} + +// ==================== Data Provider ==================== + +export interface SearchMessagesResult { + messages: RawMessage[] + total: number +} + +export interface MemberStatItem { + name: string + messageCount: number + percentage: number +} + +export interface SchemaTableInfo { + name: string + sql: string +} + +export interface ChatOverviewResult { + name: string + platform: string + type: string + totalMessages: number + totalMembers: number + firstMessageTs: number | null + lastMessageTs: number | null + topMembers: Array<{ id: number; name: string; count: number }> +} + +export interface MemberInfo { + id: number + platformId: string + accountName: string | null + groupNickname: string | null + aliases: string[] + messageCount: number +} + +export interface NameHistoryItem { + nameType: string + name: string + startTs: number + endTs: number | null +} + +export interface SegmentMessagesResult { + segmentId: number + startTs: number + endTs: number + messageCount: number + returnedCount: number + participants: string[] + messages: Array<{ + id: number + senderName: string + content: string | null + timestamp: number + }> +} + +export interface ConversationResult { + messages: RawMessage[] + total: number + member1Name: string + member2Name: string +} + +export interface SegmentSummaryItem { + id: number + startTs: number + endTs: number + messageCount: number + participants: string[] + summary: string | null +} + +/** + * 声明式 SQL 工具执行配置 + */ +export interface SqlToolExecution { + type: 'sqlite' + query: string + rowTemplate: string + summaryTemplate?: string + fallback: string +} + +/** + * 声明式 SQL 工具定义 + */ +export interface SqlToolDef { + name: string + description: string + parameters: JsonSchema + execution: SqlToolExecution +} + +/** + * 工具数据查询抽象接口 + * + * Server 和 Electron 各自实现: + * - CoreDataProvider: 通过 @openchatlab/core 查询函数 + DatabaseAdapter + * - WorkerDataProvider: 通过 workerManager IPC 调用 Worker 线程 + */ +export interface ToolDataProvider { + // === 基础查询 === + searchMessages( + keywords: string[], + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } + ): Promise + + deepSearchMessages( + keywords: string[], + options?: { timeFilter?: ToolTimeRange; limit?: number; senderId?: number } + ): Promise + + getSearchMessageContext(messageIds: number[], contextBefore: number, contextAfter: number): Promise + + getRecentMessages(options?: { timeFilter?: ToolTimeRange; limit?: number }): Promise + + getMessageContext(messageIds: number[], contextSize: number): Promise + + // === 聊天概览 === + getChatOverview(topN?: number): Promise + + // === 成员相关 === + getMembers(): Promise + + getMemberStats(options?: { timeFilter?: ToolTimeRange; top?: number }): Promise + + getMemberNameHistory(memberId: number): Promise + + // === 时间统计 === + getTimeStats(type: 'hourly' | 'weekday' | 'daily', options?: { timeFilter?: ToolTimeRange }): Promise + + // === 段落相关 === + getSegmentMessages(segmentId: number, limit?: number): Promise + + getSegmentSummaries(options?: { limit?: number; timeFilter?: ToolTimeRange }): Promise + + // === 对话查询 === + getConversationBetween( + memberId1: number, + memberId2: number, + timeFilter?: ToolTimeRange, + limit?: number + ): Promise + + // === SQL === + executeSql(sql: string): Promise + + executeParameterizedSql>(query: string, params: Record): Promise + + getSchema(): Promise +} + +// ==================== Semantic Search Tool ==================== + +/** 单次语义检索默认返回片段数(用户可配置范围 5-15) */ +export const SEMANTIC_SEARCH_DEFAULT_MAX_RESULTS = 10 +/** 单次语义检索片段数硬上限(LLM 不可超过;须与 node-runtime SEARCH_MAX_RESULTS_HARD_CAP 一致) */ +export const SEMANTIC_SEARCH_MAX_RESULTS_HARD_CAP = 20 + +/** 工具返回的安全来源条目(已脱敏,不含原始消息) */ +export interface SemanticSearchToolSource { + startMessageId: number + endMessageId: number + score: number + chunkIds: string[] + /** 已脱敏的片段预览 */ + snippet: string + /** 已脱敏的完整证据块文本,用于证据检索分类与引用 */ + text?: string + /** ISO 时间 */ + startTime?: string + endTime?: string +} + +/** 语义检索工具结果:text 为面向 LLM 的安全证据文本,其余为安全 metadata */ +export interface SemanticSearchToolResult { + available: boolean + reason?: string + /** 已清洗/脱敏/匿名化/截断的证据文本 */ + text: string + returned: number + hitCount: number + partial: boolean + coverage: number + truncated: boolean + timeRange?: { earliest: string; latest: string } + sources: SemanticSearchToolSource[] +} + +export interface SemanticSearchToolOptions { + /** 期望返回片段数;未指定时由 service 用配置默认值 */ + maxResults?: number + /** 预处理配置(脱敏/匿名化/清洗规则) */ + preprocessConfig?: Record + /** 当前用户平台 id,用于昵称匿名化 owner 识别 */ + ownerPlatformId?: string + locale?: string + /** 证据文本 token 预算 */ + maxResultTokens?: number + /** 毫秒级时间范围过滤(可单边);仅保留与 chunk 时间范围有交集的语义候选 */ + timeFilter?: EvidenceTimeRangeMs +} + +/** + * 语义检索窄接口 + * + * 由 node-runtime 的 SemanticIndexService 实现并经 adapter 注入。 + * 工具层只消费已脱敏的安全结果,脱敏在 service 内通过 applyPreprocessingPipeline 完成。 + */ +export interface SemanticSearchToolService { + /** 当前会话是否可检索(已启用 + 有 chunk + 模型一致) */ + canSearch(sessionId: string): boolean | Promise + searchForTool( + sessionId: string, + query: string, + options?: SemanticSearchToolOptions + ): Promise +} + +// ==================== Tool Context ==================== + +/** + * NLP 分词结果 + */ +export interface SegmentResult { + words: Map + uniqueWords: number + totalWords: number +} + +/** + * 工具执行上下文 + */ +export interface ToolExecutionContext { + sessionId: string + locale?: string + timeFilter?: ToolTimeRange + abortSignal?: AbortSignal + /** 抽象查询接口 */ + dataProvider?: ToolDataProvider + /** 语义检索窄接口(仅当前会话可检索时由 adapter 注入) */ + semanticIndexService?: SemanticSearchToolService + /** 预处理配置(脱敏/匿名化/清洗),供需要自处理的工具(如语义检索)使用 */ + preprocessConfig?: Record + /** 当前用户平台 id,用于昵称匿名化 owner 识别 */ + ownerPlatformId?: string + /** @deprecated 逐步迁移到 dataProvider 后移除;Electron 端不提供此字段 */ + db?: DatabaseAdapter + /** 搜索结果上下文:向前取多少条 */ + searchContextBefore?: number + /** 搜索结果上下文:向后取多少条 */ + searchContextAfter?: number + /** 消息条数上限 */ + maxMessagesLimit?: number + /** 工具结果 token 预算(语义检索证据文本截断用) */ + maxToolResultTokens?: number + /** NLP 分词回调(由平台注入 batchSegmentWithFrequency 实现) */ + segmentText?: (texts: string[], locale: string, options: Record) => SegmentResult + /** i18n 模板翻译回调(用于 SQL 工具模板国际化) */ + translateTemplate?: (key: string) => string | undefined + /** + * 消息脱敏回调:对一组原始消息执行清洗 + 脱敏,返回安全消息(含 id/发送者/时间/脱敏后内容)。 + * + * 供需要在工具内部生成可持久化安全 snippet 的工具使用(如证据检索的关键词路径)。 + * 由平台 adapter 注入 applyPreprocessingPipeline 等价实现;清洗可能删除/合并消息, + * 返回条数可能少于输入。未注入时调用方应回退到不脱敏或跳过相关能力。 + */ + desensitizeMessages?: (messages: RawMessage[], options?: { anonymizeNames?: boolean }) => RawMessage[] +} + +// ==================== Raw Message ==================== + +/** + * 可预处理的原始消息(与 @openchatlab/node-runtime PreprocessableMessage 兼容) + */ +export interface RawMessage { + id?: number + senderId?: number + senderName: string + senderPlatformId?: string + content: string | null + timestamp: number +} + +// ==================== Tool Result ==================== + +/** + * 工具执行结果 + */ +export interface ToolResult { + content: string + data?: unknown + chart?: ChartPayload + charts?: ChartPayload[] + /** 消息类工具可透传原始消息数据,供预处理管道消费 */ + rawMessages?: RawMessage[] +} + +// ==================== Tool Definition ==================== + +export type ToolCategory = 'core' | 'analysis' +export type TruncationStrategy = 'keep_first' | 'keep_last' + +/** + * 平台无关的工具定义 + * + * handler 支持同步和异步返回,兼容 Server(同步 DB)和 Electron(异步 Worker)。 + */ +export interface ToolDefinition { + name: string + description: string + inputSchema: JsonSchema + handler: (params: Record, context: ToolExecutionContext) => ToolResult | Promise + category?: ToolCategory + truncationStrategy?: TruncationStrategy +} diff --git a/packages/tools/src/utils/format.ts b/packages/tools/src/utils/format.ts new file mode 100644 index 000000000..ebf4a5c9a --- /dev/null +++ b/packages/tools/src/utils/format.ts @@ -0,0 +1,61 @@ +/** + * 工具结果格式化 & i18n 辅助(平台无关) + * + * 从 @openchatlab/node-runtime 复制核心子集,避免引入 node-runtime 重依赖。 + */ + +export function isChineseLocale(locale?: string): boolean { + return locale?.startsWith('zh') ?? false +} + +export const i18nTexts = { + allTime: { zh: '全部时间', en: 'All time' }, + noContent: { zh: '[无内容]', en: '[No content]' }, + memberNotFound: { zh: '未找到该成员', en: 'Member not found' }, + untilNow: { zh: '至今', en: 'Present' }, + noChangeRecord: { zh: '无变更记录', en: 'No change record' }, + noConversation: { zh: '未找到这两人之间的对话', en: 'No conversation found between these two members' }, + noMessageContext: { zh: '未找到指定的消息或上下文', en: 'Message or context not found' }, + messages: { zh: '条', en: '' }, + alias: { zh: '别名', en: 'Alias' }, +} + +type TextEntryKey = keyof typeof i18nTexts + +export function t(key: TextEntryKey, locale?: string): string { + const text = i18nTexts[key] + if (typeof text === 'object' && 'zh' in text && 'en' in text) { + return isChineseLocale(locale) ? text.zh : text.en + } + return '' +} + +export function formatTimeRange( + timeFilter?: { startTs: number; endTs: number }, + locale?: string +): string | { start: string; end: string } { + if (!timeFilter) return t('allTime', locale) + const localeStr = isChineseLocale(locale) ? 'zh-CN' : 'en-US' + return { + start: new Date(timeFilter.startTs * 1000).toLocaleString(localeStr), + end: new Date(timeFilter.endTs * 1000).toLocaleString(localeStr), + } +} + +export function formatMessageCompact( + msg: { + id?: number + senderName: string + content: string | null + timestamp: number + }, + locale?: string +): string { + const localeStr = isChineseLocale(locale) ? 'zh-CN' : 'en-US' + const time = new Date(msg.timestamp * 1000).toLocaleString(localeStr) + let content = msg.content || t('noContent', locale) + if (content.length > 200) { + content = content.slice(0, 200) + '...' + } + return `${time} ${msg.senderName}: ${content}` +} diff --git a/packages/tools/src/utils/schemas.ts b/packages/tools/src/utils/schemas.ts new file mode 100644 index 000000000..40870a6ad --- /dev/null +++ b/packages/tools/src/utils/schemas.ts @@ -0,0 +1,14 @@ +/** + * 共享 JSON Schema 片段 + */ + +export const timeParamProperties = { + start_time: { + type: 'string' as const, + description: '起始时间, 格式: YYYY-MM-DD HH:mm', + }, + end_time: { + type: 'string' as const, + description: '结束时间, 格式: YYYY-MM-DD HH:mm', + }, +} diff --git a/packages/tools/src/utils/time-params.ts b/packages/tools/src/utils/time-params.ts new file mode 100644 index 000000000..2e0394f9d --- /dev/null +++ b/packages/tools/src/utils/time-params.ts @@ -0,0 +1,49 @@ +/** + * 时间参数解析工具 + * + * 从 Electron 工具提取的共享实用函数,处理 start_time/end_time 字符串参数。 + */ + +import type { ToolTimeRange } from '../types' + +export interface ExtendedTimeParams { + start_time?: string + end_time?: string +} + +/** + * 解析时间参数,返回时间过滤器 + * 优先级: start_time/end_time > contextToolTimeRange + */ +export function parseExtendedTimeParams( + params: ExtendedTimeParams, + contextToolTimeRange?: ToolTimeRange +): ToolTimeRange | undefined { + if (params.start_time || params.end_time) { + let startTs: number | undefined + let endTs: number | undefined + + if (params.start_time) { + const startDate = new Date(params.start_time.replace(' ', 'T')) + if (!isNaN(startDate.getTime())) { + startTs = Math.floor(startDate.getTime() / 1000) + } + } + + if (params.end_time) { + const endDate = new Date(params.end_time.replace(' ', 'T')) + if (!isNaN(endDate.getTime())) { + endTs = Math.floor(endDate.getTime() / 1000) + } + } + + if (startTs !== undefined || endTs !== undefined) { + return { + startTs: startTs ?? 0, + endTs: endTs ?? Math.floor(Date.now() / 1000), + } + } + } + + return contextToolTimeRange +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d1afeff5..6bc83796d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,136 +8,481 @@ importers: .: dependencies: - '@aptabase/electron': - specifier: ^0.3.1 - version: 0.3.1(electron@35.7.5) - '@electron-toolkit/preload': - specifier: ^3.0.1 - version: 3.0.2(electron@35.7.5) - '@electron-toolkit/utils': - specifier: ^4.0.0 - version: 4.0.0(electron@35.7.5) - '@types/markdown-it': - specifier: ^14.1.2 - version: 14.1.2 + '@tanstack/vue-virtual': + specifier: ^3.13.18 + version: 3.13.18(vue@3.5.27(typescript@5.9.3)) '@zumer/snapdom': specifier: ^2.0.1 - version: 2.0.1 - better-sqlite3: - specifier: ^12.4.6 - version: 12.5.0 - electron-updater: - specifier: ^6.6.2 - version: 6.7.3 + version: 2.0.2 + echarts: + specifier: ^6.0.0 + version: 6.0.0 + echarts-wordcloud: + specifier: ^2.1.0 + version: 2.1.0(echarts@6.0.0) markdown-it: specifier: ^14.1.0 version: 14.1.0 - stream-json: - specifier: ^1.9.1 - version: 1.9.1 + pixi-viewport: + specifier: ^6.0.3 + version: 6.0.3(pixi.js@8.19.0) + pixi.js: + specifier: ^8.19.0 + version: 8.19.0 + three: + specifier: ^0.185.0 + version: 0.185.0 vue-i18n: specifier: ^11.2.8 - version: 11.2.8(vue@3.5.26(typescript@5.9.3)) + version: 11.2.8(vue@3.5.27(typescript@5.9.3)) devDependencies: - '@electron-toolkit/eslint-config': - specifier: ^1.0.2 - version: 1.0.2(eslint@9.39.2(jiti@2.6.1)) - '@electron-toolkit/eslint-config-ts': - specifier: ^2.0.0 - version: 2.0.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@electron-toolkit/tsconfig': specifier: ^1.0.1 - version: 1.0.1(@types/node@25.0.3) - '@electron/rebuild': - specifier: ^4.0.1 - version: 4.0.2 - '@intlify/unplugin-vue-i18n': - specifier: ^11.0.3 - version: 11.0.3(@vue/compiler-dom@3.5.26)(eslint@9.39.2(jiti@2.6.1))(rollup@4.55.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3)) + version: 1.0.1(@types/node@25.2.2) '@nuxt/ui': specifier: ^4.2.1 - version: 4.3.0(@babel/parser@7.28.5)(@floating-ui/dom@1.7.4)(@tiptap/extension-drag-handle@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/extension-collaboration@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)))(@tiptap/extensions@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))(axios@1.13.2)(embla-carousel@8.6.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3)) - '@rushstack/eslint-patch': - specifier: ^1.15.0 - version: 1.15.0 + version: 4.4.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(axios@1.13.5)(embla-carousel@8.6.0)(focus-trap@7.8.0)(tailwindcss@4.1.18)(typescript@5.9.3)(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.29)(zod@3.25.76) '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.1.18(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) - '@types/better-sqlite3': - specifier: ^7.6.13 - version: 7.6.13 - '@types/stream-json': - specifier: ^1.7.8 - version: 1.7.8 + version: 4.1.18(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 '@vitejs/plugin-vue': - specifier: ^5.2.3 - version: 5.2.4(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) + specifier: ^6.0.0 + version: 6.0.7(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) '@vue/eslint-config-prettier': specifier: ^10.2.0 - version: 10.2.0(eslint@9.39.2(jiti@2.6.1))(prettier@3.7.4) + version: 10.2.0(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) + '@vue/eslint-config-typescript': + specifier: ^14.6.0 + version: 14.6.0(eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@vueuse/core': specifier: ^13.9.0 - version: 13.9.0(vue@3.5.26(typescript@5.9.3)) - axios: - specifier: ^1.13.2 - version: 1.13.2 - chart.js: - specifier: ^4.5.1 - version: 4.5.1 - cross-env: - specifier: ^7.0.3 - version: 7.0.3 + version: 13.9.0(vue@3.5.27(typescript@5.9.3)) dayjs: specifier: ^1.11.19 version: 1.11.19 - electron: - specifier: ^35.0.0 - version: 35.7.5 - electron-builder: - specifier: ^26.0.12 - version: 26.4.0(electron-builder-squirrel-windows@26.4.0) - electron-vite: - specifier: ^3.0.0 - version: 3.1.0(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) eslint: specifier: ^9.39.1 version: 9.39.2(jiti@2.6.1) eslint-plugin-vue: specifier: ^9.33.0 version: 9.33.0(eslint@9.39.2(jiti@2.6.1)) - mitt: - specifier: ^3.0.1 - version: 3.0.1 pinia: specifier: ^3.0.4 - version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)) + version: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)) pinia-plugin-persistedstate: specifier: ^4.7.1 - version: 4.7.1(@nuxt/kit@4.2.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))) + version: 4.7.1(@nuxt/kit@4.3.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))) prettier: specifier: ^3.5.3 - version: 3.7.4 + version: 3.8.1 tailwindcss: specifier: ^4.0.0 version: 4.1.18 + typescript: + specifier: ^5.8.3 + version: 5.9.3 vite: - specifier: ^6.3.5 - version: 6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + specifier: ^7.0.0 + version: 7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) vue: specifier: ^3.5.25 - version: 3.5.26(typescript@5.9.3) - vue-chartjs: - specifier: ^5.3.3 - version: 5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.9.3)) + version: 3.5.27(typescript@5.9.3) vue-router: specifier: ^4.6.3 - version: 4.6.4(vue@3.5.26(typescript@5.9.3)) + version: 4.6.4(vue@3.5.27(typescript@5.9.3)) + vue-tsc: + specifier: ^3.1.1 + version: 3.2.6(typescript@5.9.3) + + apps/cli: + dependencies: + '@earendil-works/pi-agent-core': + specifier: 0.74.2 + version: 0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-ai': + specifier: 0.74.2 + version: 0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@fastify/multipart': + specifier: ^10.0.0 + version: 10.0.0 + '@fastify/static': + specifier: ^9.1.3 + version: 9.1.3 + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.29.0(zod@3.25.76) + better-sqlite3: + specifier: ^12.4.6 + version: 12.6.2 + commander: + specifier: ^13.1.0 + version: 13.1.0 + fastify: + specifier: ^5.8.4 + version: 5.8.4 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + js-tiktoken: + specifier: ^1.0.21 + version: 1.0.21 + smol-toml: + specifier: ^1.3.1 + version: 1.6.1 + stream-json: + specifier: ^1.9.1 + version: 1.9.1 + zod: + specifier: ^3.24.4 + version: 3.25.76 + optionalDependencies: + '@node-rs/jieba': + specifier: ^2.0.1 + version: 2.0.1 + devDependencies: + '@openchatlab/config': + specifier: workspace:* + version: link:../../packages/config + '@openchatlab/core': + specifier: workspace:* + version: link:../../packages/core + '@openchatlab/http-routes': + specifier: workspace:* + version: link:../../packages/http-routes + '@openchatlab/node-runtime': + specifier: workspace:* + version: link:../../packages/node-runtime + '@openchatlab/parser': + specifier: workspace:* + version: link:../../packages/parser + '@openchatlab/shared-types': + specifier: workspace:* + version: link:../../packages/shared-types + '@openchatlab/sync': + specifier: workspace:* + version: link:../../packages/sync + '@openchatlab/tools': + specifier: workspace:* + version: link:../../packages/tools + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + chatlab-mcp: + specifier: workspace:* + version: link:../../packages/mcp-server + tsup: + specifier: ^8.5.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + + apps/desktop: + dependencies: + '@electron-toolkit/preload': + specifier: ^3.0.1 + version: 3.0.2(electron@35.7.5) + '@electron-toolkit/utils': + specifier: ^4.0.0 + version: 4.0.0(electron@35.7.5) + '@fastify/multipart': + specifier: ^10.0.0 + version: 10.0.0 + '@huggingface/transformers': + specifier: ^4.2.0 + version: 4.2.0 + '@node-rs/jieba': + specifier: ^2.0.1 + version: 2.0.1 + better-sqlite3: + specifier: ^12.4.6 + version: 12.6.2 + electron-updater: + specifier: ^6.6.2 + version: 6.7.3 + i18next: + specifier: ^25.8.5 + version: 25.8.5(typescript@5.9.3) + onnxruntime-common: + specifier: 1.24.3 + version: 1.24.3 + onnxruntime-node: + specifier: 1.24.3 + version: 1.24.3 + sharp: + specifier: 0.34.5 + version: 0.34.5 + sqlite-vec: + specifier: ^0.1.9 + version: 0.1.9 + undici: + specifier: ^6.25.0 + version: 6.25.0 + devDependencies: + '@electron-toolkit/tsconfig': + specifier: ^1.0.1 + version: 1.0.1(@types/node@25.2.2) + '@electron/rebuild': + specifier: ^4.0.4 + version: 4.0.4 + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + electron: + specifier: 35.7.5 + version: 35.7.5 + electron-builder: + specifier: ^26.0.12 + version: 26.7.0(electron-builder-squirrel-windows@26.7.0) + electron-vite: + specifier: ^5.0.0 + version: 5.0.0(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + + docs: + devDependencies: + vitepress: + specifier: ^1.6.4 + version: 1.6.4(@algolia/client-search@5.52.1)(@types/node@25.2.2)(axios@1.13.5)(fuse.js@7.1.0)(lightningcss@1.31.1)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3) + vue: + specifier: ^3.5.25 + version: 3.5.27(typescript@5.9.3) + + packages/config: + dependencies: + smol-toml: + specifier: ^1.3.1 + version: 1.6.1 + zod: + specifier: ^3.24.4 + version: 3.25.76 + + packages/core: {} + + packages/http-routes: + dependencies: + '@openchatlab/config': + specifier: workspace:* + version: link:../config + '@openchatlab/core': + specifier: workspace:* + version: link:../core + '@openchatlab/node-runtime': + specifier: workspace:* + version: link:../node-runtime + '@openchatlab/shared-types': + specifier: workspace:* + version: link:../shared-types + '@openchatlab/tools': + specifier: workspace:* + version: link:../tools + fastify: + specifier: ^5.8.4 + version: 5.8.4 + + packages/mcp-server: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.29.0(zod@3.25.76) + better-sqlite3: + specifier: ^12.4.6 + version: 12.6.2 + smol-toml: + specifier: ^1.3.1 + version: 1.6.1 + zod: + specifier: ^3.24.4 + version: 3.25.76 + optionalDependencies: + '@node-rs/jieba': + specifier: ^2.0.1 + version: 2.0.1 + devDependencies: + '@openchatlab/config': + specifier: workspace:* + version: link:../config + '@openchatlab/core': + specifier: workspace:* + version: link:../core + '@openchatlab/node-runtime': + specifier: workspace:* + version: link:../node-runtime + '@openchatlab/tools': + specifier: workspace:* + version: link:../tools + tsup: + specifier: ^8.5.0 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + + packages/node-runtime: + dependencies: + '@earendil-works/pi-agent-core': + specifier: 0.74.2 + version: 0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-ai': + specifier: 0.74.2 + version: 0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@huggingface/transformers': + specifier: ^4.2.0 + version: 4.2.0 + '@openchatlab/core': + specifier: workspace:* + version: link:../core + better-sqlite3: + specifier: ^12.4.6 + version: 12.6.2 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + js-tiktoken: + specifier: ^1.0.21 + version: 1.0.21 + onnxruntime-common: + specifier: 1.24.3 + version: 1.24.3 + onnxruntime-node: + specifier: 1.24.3 + version: 1.24.3 + sharp: + specifier: 0.34.5 + version: 0.34.5 + sqlite-vec: + specifier: ^0.1.9 + version: 0.1.9 + stream-chain: + specifier: ^2.2.5 + version: 2.2.5 + stream-json: + specifier: ^1.9.1 + version: 1.9.1 + undici: + specifier: ^6.25.0 + version: 6.25.0 + yauzl: + specifier: ^3.4.0 + version: 3.4.0 + optionalDependencies: + '@node-rs/jieba': + specifier: ^2.0.1 + version: 2.0.1 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/yauzl': + specifier: ^3.4.0 + version: 3.4.0 + + packages/parser: + dependencies: + '@openchatlab/shared-types': + specifier: workspace:* + version: link:../shared-types + stream-json: + specifier: ^1.9.1 + version: 1.9.1 + devDependencies: + '@types/stream-json': + specifier: ^1.7.8 + version: 1.7.8 + + packages/shared-types: {} + + packages/sync: {} + + packages/tools: + dependencies: + '@openchatlab/core': + specifier: workspace:* + version: link:../core + '@openchatlab/shared-types': + specifier: workspace:* + version: link:../shared-types packages: 7zip-bin@5.2.0: resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + '@algolia/abtesting@1.18.1': + resolution: {integrity: sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.52.1': + resolution: {integrity: sha512-HmXOGBOAOJPounpBzBpuY0zDYeiCpxgHnQmuA7JO6ScukcBdGp3/XM9zJk5pJx/xNGD68mbPGXWpDxGtl6BwDQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.52.1': + resolution: {integrity: sha512-5oo4+I8iixie9vXhCyNFCzeIr8pqA3FQ//VsLHTDvZAV4ttYOPGvYHGQq5NSalrLx5Jc3dRro/5uDOlnUMcBJg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.52.1': + resolution: {integrity: sha512-qCDoZfx5MpX7XQzvQ3bC4tSEMkQWQMaF/ABtLuoze03Y/flR563CCSws02qIJ23oX7lxl92LsilZjINVyTdtLw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.52.1': + resolution: {integrity: sha512-hnGs0/lsFJ2PWDxNBz7pxreXo/Xz7gxYRcfePBUjsH26ad0kU/sgnVZd9LwWBpsQv65z2jlb5dkyaB9WE9M9FQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.52.1': + resolution: {integrity: sha512-2VxxNc/uBysyKvGeBdSM5n9eIDKH8kWD7wd9/yqbJAiVwU4Yv6tU1LSJusHKrXV/aCu1KW7t9Gug9QyeEmtn/Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.52.1': + resolution: {integrity: sha512-O6mPtsw3xEfNOe6gWFpYLeAZAIljNa4Hgna3bq15PwyN7nbjTY0wXJFRbzs/0YVf75Br+SbOQUmjKxXYjDiSiQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.52.1': + resolution: {integrity: sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.52.1': + resolution: {integrity: sha512-U9zZfc5xIu9wRxZkt+HceJUAD4VKHKbAyLSloJdEyMRmphXeibfrY9cxqIXBcmPeZzGhn3Imb35Dq8l19PkJhw==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.52.1': + resolution: {integrity: sha512-a3SGNceHmkQfq77iG8Ka+w1pvwfZa/0lzEIgse30fL0kD+yKnd/dg0dQvSfFPAEt2f21DMcGkDSSeJlO3KdQjQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.52.1': + resolution: {integrity: sha512-z98QEguCFDpxb4S/PyrUK1igqF8tPsdbqOUUO6ON91vJ58w+Gwa6ncrI0oNXSFcrkxA5EqPKPQ2A1PBCn08TYQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.52.1': + resolution: {integrity: sha512-CI7+/0I11QeZM59Uc8whd2or0kqzFVjpaPn9Qpwll/krHcBAxk24WkAQ6WX+IwDVMfpont4YGbKwAmCre3vE8Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.52.1': + resolution: {integrity: sha512-S6bDuw9byfOvm3T71cgdoZgrgnZq6hpdMLkx52Louh57nUAmvGQESz2aojOynQHjbTiV55smvAFbgn0qT4tJrg==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.52.1': + resolution: {integrity: sha512-tqZXM+54rWo4mk5jL5Z/flE11nPmNEdXwFBM5py9DkOmbjeCNemfVd45FyM97XdzfZ0dl9uOJC6PYn1FpkeyQg==} + engines: {node: '>= 14.0.0'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -145,47 +490,152 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@aptabase/electron@0.3.1': - resolution: {integrity: sha512-FECaGsjuoQu70F+M6V1evdgLP7yaq/sne9fC60AZZ6B9RsCDlxFBnJzdq3+xevHlUx6AB5o9ygUhQ+ONT9EqiA==} + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + hasBin: true peerDependencies: - electron: '>= 3.x' + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1053.0': + resolution: {integrity: sha512-I5dua8y1logE+Mx6r5kvI1tjM+XyC3H42KDCpEqmhrJfanor/x/AdOavyv3HnS4sBqUxx2IrjLP3ouEumjeTzA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.13': + resolution: {integrity: sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.39': + resolution: {integrity: sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.41': + resolution: {integrity: sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==} + engines: {node: '>=20.0.0'} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + '@aws-sdk/credential-provider-ini@3.972.43': + resolution: {integrity: sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.43': + resolution: {integrity: sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.44': + resolution: {integrity: sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.39': + resolution: {integrity: sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.43': + resolution: {integrity: sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.43': + resolution: {integrity: sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.17': + resolution: {integrity: sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.13': + resolution: {integrity: sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.21': + resolution: {integrity: sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.11': + resolution: {integrity: sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.28': + resolution: {integrity: sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1052.0': + resolution: {integrity: sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1053.0': + resolution: {integrity: sha512-laSwHLYMMrXQRl2mFDXszF43m/F4pKWyGr7hCLfJmV8rn8c6CnI/hp/bf/Gn7gLcjz0SY4evd7SBpqtnIhzA/A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.9': + resolution: {integrity: sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.4': + resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.25': + resolution: {integrity: sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + engines: {node: '>=18.0.0'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.5': - resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.5': - resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.5': - resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} '@babel/helper-string-parser@7.27.1': @@ -200,12 +650,12 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true @@ -215,16 +665,20 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.5': - resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} '@capsizecss/unpack@3.0.1': @@ -235,19 +689,37 @@ packages: resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} - '@electron-toolkit/eslint-config-ts@2.0.0': - resolution: {integrity: sha512-NGXadMyWH9+ZsgYe/u5E0mqK2qTDq01kKKnyo7oiq/7v/dWoMoPhqSkn69NZvt7WmnFNOm57l71fv6128mAx3Q==} + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} peerDependencies: - eslint: '>=8.56.0' - typescript: '*' + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' peerDependenciesMeta: - typescript: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: optional: true - '@electron-toolkit/eslint-config@1.0.2': - resolution: {integrity: sha512-GJVuMsxBHfVARfmUoSTCHT0e/QfWlVbXcGk3tgoku0ad6tLjydbv2LpvKi02+Sy2WiEz9L9SkGSw090ukT/F0A==} - peerDependencies: - eslint: '>= 8.0.0' + '@earendil-works/pi-agent-core@0.74.2': + resolution: {integrity: sha512-RPlB3bi2z+VQK0HRhBK73kXOQI1fFmRgWzzT6+ihB7JYfh29jb7eAfYvpx8rlf248gUAYaLQ8JYa+43l09rPmQ==} + engines: {node: '>=20.0.0'} + + '@earendil-works/pi-ai@0.74.2': + resolution: {integrity: sha512-ukQBHGDm20k9ZUS2cGjNN9vDJp/48r35xmvgSx3paCaC06r2N/PLuRZoJmwQ1ZM7f8T3072odv9YPWn+77w0LA==} + engines: {node: '>=20.0.0'} + hasBin: true '@electron-toolkit/preload@3.0.2': resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==} @@ -277,6 +749,10 @@ packages: resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} engines: {node: '>=12'} + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + '@electron/notarize@2.5.0': resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} engines: {node: '>= 10.0.0'} @@ -286,13 +762,8 @@ packages: engines: {node: '>=12.0.0'} hasBin: true - '@electron/rebuild@4.0.1': - resolution: {integrity: sha512-iMGXb6Ib7H/Q3v+BKZJoETgF9g6KMNZVbsO4b7Dmpgb5qTFqyFTzqW9F3TOSHdybv2vKYKzSS9OiZL+dcJb+1Q==} - engines: {node: '>=22.12.0'} - hasBin: true - - '@electron/rebuild@4.0.2': - resolution: {integrity: sha512-8iZWVPvOpCdIc5Pj5udQV3PeO7liJVC7BBUSizl1HCfP7ZxYc9Kqz0c3PDNj2HQ5cQfJ5JaBeJIYKPjAvLn2Rg==} + '@electron/rebuild@4.0.4': + resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==} engines: {node: '>=22.12.0'} hasBin: true @@ -305,162 +776,465 @@ packages: engines: {node: '>=14.14'} hasBin: true + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -499,17 +1273,78 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.3': - resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} - '@floating-ui/dom@1.7.4': - resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/multipart@10.0.0': + resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@floating-ui/vue@1.1.9': - resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==} + '@floating-ui/vue@1.1.10': + resolution: {integrity: sha512-vdf8f6rHnFPPLRsmL4p12wYl+Ux4mOJOkjzKEMYVnwdf7UFdvBtHlLvQyx8iKG5vhPRbDRgZxdtpmyigDPjzYg==} + + '@google/genai@1.42.0': + resolution: {integrity: sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@huggingface/jinja@0.5.9': + resolution: {integrity: sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==} + engines: {node: '>=18'} + + '@huggingface/tokenizers@0.1.3': + resolution: {integrity: sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==} + + '@huggingface/transformers@4.2.0': + resolution: {integrity: sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==} '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -527,38 +1362,166 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@iconify/collections@1.0.636': - resolution: {integrity: sha512-rfHaZuA60MeAbylEGv8lzSYN0x/LvYj7B8h7JYlhSWgozox7VkNO+S/PcLfVcseNSzOzj1GChfUw1xwsh+XFiw==} + '@iconify-json/simple-icons@1.2.83': + resolution: {integrity: sha512-6Pp9V++XisT9RKH7FB4RLPqUDzcmLtSma0ovOEIoEWGrXtHwBFsH7oN1z8vvCVCb95fb87QgR46/zRLyN9Y3kg==} + + '@iconify/collections@1.0.648': + resolution: {integrity: sha512-0365h8NN+PXjUXc32cmaQaulext6S6Jnx1+6XxU5ivzNiQWmMlh6W1BkF9wncH+e48yzL1Tqes2A17WwYieZtw==} '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@iconify/utils@3.1.0': - resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + + '@iconify/vue@5.0.0': + resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==} + peerDependencies: + vue: '>=3' + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] - '@iconify/vue@5.0.0': - resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==} - peerDependencies: - vue: '>=3' + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] - '@internationalized/date@3.10.1': - resolution: {integrity: sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==} + '@internationalized/date@3.11.0': + resolution: {integrity: sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==} '@internationalized/number@3.6.5': resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==} - '@intlify/bundle-utils@11.0.3': - resolution: {integrity: sha512-dURCDz1rQXwAb1+Hv4NDit6aZSRaAt4zUYBPEeaDCe3FSs8dMtdF6kEvgd9JwsYFSTAHcvbTs2CqwBjjt9Ltsw==} - engines: {node: '>= 20'} - peerDependencies: - petite-vue-i18n: '*' - vue-i18n: '*' - peerDependenciesMeta: - petite-vue-i18n: - optional: true - vue-i18n: - optional: true - '@intlify/core-base@11.2.8': resolution: {integrity: sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==} engines: {node: '>= 16'} @@ -571,45 +1534,6 @@ packages: resolution: {integrity: sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==} engines: {node: '>= 16'} - '@intlify/unplugin-vue-i18n@11.0.3': - resolution: {integrity: sha512-iQuik0nXfdVZ5ab+IEyBFEuvMQ213zfbUpBXaEdHPk8DV+qB2CT/SdFuDhfUDRRBZc/e0qoLlfmc9urhnRYVWw==} - engines: {node: '>= 20'} - peerDependencies: - petite-vue-i18n: '*' - vue: ^3.2.25 - vue-i18n: '*' - peerDependenciesMeta: - petite-vue-i18n: - optional: true - vue-i18n: - optional: true - - '@intlify/vue-i18n-extensions@8.0.0': - resolution: {integrity: sha512-w0+70CvTmuqbskWfzeYhn0IXxllr6mU+IeM2MU0M+j9OW64jkrvqY+pYFWrUnIIC9bEdij3NICruicwd5EgUuQ==} - engines: {node: '>= 18'} - peerDependencies: - '@intlify/shared': ^9.0.0 || ^10.0.0 || ^11.0.0 - '@vue/compiler-dom': ^3.0.0 - vue: ^3.0.0 - vue-i18n: ^9.0.0 || ^10.0.0 || ^11.0.0 - peerDependenciesMeta: - '@intlify/shared': - optional: true - '@vue/compiler-dom': - optional: true - vue: - optional: true - vue-i18n: - optional: true - - '@isaacs/balanced-match@4.0.1': - resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} - engines: {node: 20 || >=22} - - '@isaacs/brace-expansion@5.0.0': - resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} - engines: {node: 20 || >=22} - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -634,8 +1558,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@kurkle/color@0.3.4': - resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} @@ -645,6 +1570,112 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@mistralai/mistralai@2.2.1': + resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + + '@node-rs/jieba-android-arm-eabi@2.0.1': + resolution: {integrity: sha512-tavsIaxybnlA9tRbJ+oc3NW3zhx0d5rNiCGdpIdGWjflwS7HyeUTVAZmAFDlg58Mc6EjTdVKZH+RolBbAJtgcQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/jieba-android-arm64@2.0.1': + resolution: {integrity: sha512-AwdyqKvVNuSDnDq3anUfq+nJ5J/kzXjkfbr/1WY6TfaAlTNuuGVskuQv72/wIx/jn7NoXfm/UPuJrWYG16NC6w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/jieba-darwin-arm64@2.0.1': + resolution: {integrity: sha512-10+nwGQ6KzXXJlIL/sELA6Fi6m7eJ7xJksBiKuw1kxKUgaJwtVfAG0iqRF+NRQv0Sdq7r3k5ew9K9y0+IYaEcA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/jieba-darwin-x64@2.0.1': + resolution: {integrity: sha512-IJ5RK0X/uPQa1XRmTvwKSieya+w1IJeiKLw0EekoBFJKybXQdvo8/uqM/8z2eVJ8vQxW9X6K2vkVGFvYQa9dYA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/jieba-freebsd-x64@2.0.1': + resolution: {integrity: sha512-yg7vyhqzP2weJu5DJ3q9q4pb0b4GWWRwcv54zK7MSSA6KNJ/uQv2a4R9/qmptLU/fZv14gWuJBEMFdL7y1Dv2w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/jieba-linux-arm-gnueabihf@2.0.1': + resolution: {integrity: sha512-fxQYunS7w2tv8XV9GigkWJPzHnbcw6tjrUdDu5/qU0FdQVEzGuEYG85DjlNf8lZTDGSUKHBVyAQs7bBIvq8yqg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/jieba-linux-arm64-gnu@2.0.1': + resolution: {integrity: sha512-VnLU630hQIyO/fwyxh2vqZi72mO+hXkVUC3jVLPfOAlppinmsGX9N81tpTPUK3840hbV8WLtbYTWN1XodI38eg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/jieba-linux-arm64-musl@2.0.1': + resolution: {integrity: sha512-K4EDyNixSLVdTNYnHwD+7I/ytvzpo7tt+vdCLqwQViiek2PMpL/FFRvA39uU2tk99jXIxvkczdxARG20BRZppg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/jieba-linux-x64-gnu@2.0.1': + resolution: {integrity: sha512-sq3J6L2ANTE25I9eVFq/nb57OtXcvUIeUD1CTKJxwgTKIVmcB2LyOZpWf20AjHRUfbMER9Klqg5dgyyO+Six+w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/jieba-linux-x64-musl@2.0.1': + resolution: {integrity: sha512-0zfP9Qy68yEXrhBFknfhF6WUJDPU/8eRuyIrkMGdMjfRpxhpSbr2fMfnsqhOQLvhuK4w3iDFvTy4t5d0s6JKMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/jieba-wasm32-wasi@2.0.1': + resolution: {integrity: sha512-7I5rJya5rlQNJIhv8PvPzIVT1/gVc0vFzHmlfRGwCPGDJ3tHVxkSPW34dDx3OgDmbIeadNpmgIyC1RaS9djPJg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/jieba-win32-arm64-msvc@2.0.1': + resolution: {integrity: sha512-Aj/2EwYSaPgAbKnSl+vKM/2kOaZNMZWnShiZzbSNyzlLy3eIOyOYVLbYRDno4547KngRxer8uzROhIQIwXwkvw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/jieba-win32-ia32-msvc@2.0.1': + resolution: {integrity: sha512-tpJt3uuBlGrcOInQLTYvcgamQgfadl5cwExLYU+CX9rXKpXLDO31dIujUDBgNWoiQq3tOiU1/AKbT7ZdNd4lBQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/jieba-win32-x64-msvc@2.0.1': + resolution: {integrity: sha512-LDOyo2/2CO8UnpSGLJdgqtH8mOnsABPhNxkfIky7UT9cyLEzOaU44nbA5YzPGpBI3qzMbWcwJYQsjBcgK2VqAg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/jieba@2.0.1': + resolution: {integrity: sha512-tnfzXOMqzVQF2dSKMhPC9HrHzzWmN6KheL/zYtGenhOpq/bCKHJWVASSggEnHlkmHgXGeIJHR2N/IuPzewz1BQ==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -657,14 +1688,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@npmcli/agent@3.0.0': - resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/fs@4.0.0': - resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} - engines: {node: ^18.17.0 || >=20.5.0} - '@nuxt/devtools-kit@3.1.1': resolution: {integrity: sha512-sjiKFeDCOy1SyqezSgyV4rYNfQewC64k/GhOsuJgRF+wR2qr6KTVhO6u2B+csKs74KrMrnJprQBgud7ejvOXAQ==} peerDependencies: @@ -673,29 +1696,31 @@ packages: '@nuxt/fonts@0.12.1': resolution: {integrity: sha512-ALajI/HE+uqqL/PWkWwaSUm1IdpyGPbP3mYGy2U1l26/o4lUZBxjFaduMxaZ85jS5yQeJfCu2eEHANYFjAoujQ==} - '@nuxt/icon@2.2.0': - resolution: {integrity: sha512-B7Ly5g/nZxHqnjAsApW9zwDLtvaWOAJbNXY0TNIeAD8CZ25T+vYJs7++9o5P8E+pCSg3rwEyGsM4UPRYH3mk3Q==} + '@nuxt/icon@2.2.1': + resolution: {integrity: sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==} - '@nuxt/kit@3.20.2': - resolution: {integrity: sha512-laqfmMcWWNV1FsVmm1+RQUoGY8NIJvCRl0z0K8ikqPukoEry0LXMqlQ+xaf8xJRvoH2/78OhZmsEEsUBTXipcw==} + '@nuxt/kit@3.21.1': + resolution: {integrity: sha512-QORZRjcuTKgo++XP1Pc2c2gqwRydkaExrIRfRI9vFsPA3AzuHVn5Gfmbv1ic8y34e78mr5DMBvJlelUaeOuajg==} engines: {node: '>=18.12.0'} - '@nuxt/kit@4.2.2': - resolution: {integrity: sha512-ZAgYBrPz/yhVgDznBNdQj2vhmOp31haJbO0I0iah/P9atw+OHH7NJLUZ3PK+LOz/0fblKTN1XJVSi8YQ1TQ0KA==} + '@nuxt/kit@4.3.1': + resolution: {integrity: sha512-UjBFt72dnpc+83BV3OIbCT0YHLevJtgJCHpxMX0YRKWLDhhbcDdUse87GtsQBrjvOzK7WUNUYLDS/hQLYev5rA==} engines: {node: '>=18.12.0'} - '@nuxt/schema@4.2.2': - resolution: {integrity: sha512-lW/1MNpO01r5eR/VoeanQio8Lg4QpDklMOHa4mBHhhPNlBO1qiRtVYzjcnNdun3hujGauRaO9khGjv93Z5TZZA==} + '@nuxt/schema@4.3.1': + resolution: {integrity: sha512-S+wHJdYDuyk9I43Ej27y5BeWMZgi7R/UVql3b3qtT35d0fbpXW7fUenzhLRCCDC6O10sjguc6fcMcR9sMKvV8g==} engines: {node: ^14.18.0 || >=16.10.0} - '@nuxt/ui@4.3.0': - resolution: {integrity: sha512-zhOIba3roiqNwV/hXXkKBlv9RA01/Gd2Okydpgps2zM4KGx6RM+ED5JGUOSd41bmTeBRO7v7Lg4w3Vyj9hQPiA==} + '@nuxt/ui@4.4.0': + resolution: {integrity: sha512-c9n8PgYSpFpC3GSz0LtAzceo/jjNyaI1yFJbDPJop5OoeeWqKOC3filsQFNPxo+i3v81EiGkZq+bJ7pnHxAGkA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@inertiajs/vue3': ^2.0.7 '@nuxt/content': ^3.0.0 joi: ^18.0.0 superstruct: ^2.0.0 + tailwindcss: ^4.0.0 typescript: ^5.6.3 valibot: ^1.0.0 vue-router: ^4.5.0 @@ -722,6 +1747,12 @@ packages: '@nuxtjs/color-mode@3.5.2': resolution: {integrity: sha512-cC6RfgZh3guHBMLLjrBB2Uti5eUoGM9KyauOaYS9ETmxNWBMTvpgjvSiSJp1OFljIXPIqVTJ3xtJpSNZiO3ZaA==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pixi/colord@2.9.6': + resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -733,163 +1764,231 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/rollup-android-arm-eabi@4.55.1': - resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.55.1': - resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.55.1': - resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.55.1': - resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.55.1': - resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.55.1': - resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.55.1': - resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.55.1': - resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.55.1': - resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.55.1': - resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.55.1': - resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.55.1': - resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.55.1': - resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.55.1': - resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.55.1': - resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.55.1': - resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.55.1': - resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.55.1': - resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.55.1': - resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - libc: [musl] - '@rollup/rollup-openbsd-x64@4.55.1': - resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.55.1': - resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.55.1': - resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.55.1': - resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.55.1': - resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.55.1': - resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} cpu: [x64] os: [win32] - '@rushstack/eslint-patch@1.15.0': - resolution: {integrity: sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==} + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@smithy/core@3.24.4': + resolution: {integrity: sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.4': + resolution: {integrity: sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.4': + resolution: {integrity: sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.4': + resolution: {integrity: sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.4': + resolution: {integrity: sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.2': + resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -938,28 +2037,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -1001,8 +2096,8 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@tanstack/virtual-core@3.13.17': - resolution: {integrity: sha512-m5mRfGNcL5GUzluWNom0Rmg8P8Dg3h6PnJtJBmJcBiJvkV+vufmUfLnVzKSPGQtmvzMW/ZuUdvL+SyjIUvHV3A==} + '@tanstack/virtual-core@3.13.18': + resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} '@tanstack/vue-table@8.21.3': resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==} @@ -1010,220 +2105,220 @@ packages: peerDependencies: vue: '>=3.2' - '@tanstack/vue-virtual@3.13.17': - resolution: {integrity: sha512-w+Btl94IkuL7c2hSVSD0t8tXfhLRnKppOlGKlzBGjw0SrlIgKbiOJv/FcSTCO3SeyI9h0sx2gF/cO/PONtkidw==} + '@tanstack/vue-virtual@3.13.18': + resolution: {integrity: sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A==} peerDependencies: vue: ^2.7.0 || ^3.0.0 - '@tiptap/core@3.13.0': - resolution: {integrity: sha512-iUelgiTMgPVMpY5ZqASUpk8mC8HuR9FWKaDzK27w9oWip9tuB54Z8mePTxNcQaSPb6ErzEaC8x8egrRt7OsdGQ==} + '@tiptap/core@3.19.0': + resolution: {integrity: sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==} peerDependencies: - '@tiptap/pm': ^3.13.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/extension-blockquote@3.15.3': - resolution: {integrity: sha512-13x5UsQXtttFpoS/n1q173OeurNxppsdWgP3JfsshzyxIghhC141uL3H6SGYQLPU31AizgDs2OEzt6cSUevaZg==} + '@tiptap/extension-blockquote@3.19.0': + resolution: {integrity: sha512-y3UfqY9KD5XwWz3ndiiJ089Ij2QKeiXy/g1/tlAN/F1AaWsnkHEHMLxCP1BIqmMpwsX7rZjMLN7G5Lp7c9682A==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-bold@3.15.3': - resolution: {integrity: sha512-I8JYbkkUTNUXbHd/wCse2bR0QhQtJD7+0/lgrKOmGfv5ioLxcki079Nzuqqay3PjgYoJLIJQvm3RAGxT+4X91w==} + '@tiptap/extension-bold@3.19.0': + resolution: {integrity: sha512-UZgb1d0XK4J/JRIZ7jW+s4S6KjuEDT2z1PPM6ugcgofgJkWQvRZelCPbmtSFd3kwsD+zr9UPVgTh9YIuGQ8t+Q==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-bubble-menu@3.13.0': - resolution: {integrity: sha512-qZ3j2DBsqP9DjG2UlExQ+tHMRhAnWlCKNreKddKocb/nAFrPdBCtvkqIEu+68zPlbLD4ukpoyjUklRJg+NipFg==} + '@tiptap/extension-bubble-menu@3.19.0': + resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==} peerDependencies: - '@tiptap/core': ^3.13.0 - '@tiptap/pm': ^3.13.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/extension-bullet-list@3.15.3': - resolution: {integrity: sha512-MGwEkNT7ltst6XaWf0ObNgpKQ4PvuuV3igkBrdYnQS+qaAx9IF4isygVPqUc9DvjYC306jpyKsNqNrENIXcosA==} + '@tiptap/extension-bullet-list@3.19.0': + resolution: {integrity: sha512-F9uNnqd0xkJbMmRxVI5RuVxwB9JaCH/xtRqOUNQZnRBt7IdAElCY+Dvb4hMCtiNv+enGM/RFGJuFHR9TxmI7rw==} peerDependencies: - '@tiptap/extension-list': ^3.15.3 + '@tiptap/extension-list': ^3.19.0 - '@tiptap/extension-code-block@3.15.3': - resolution: {integrity: sha512-q1UB9icNfdJppTqMIUWfoRKkx5SSdWIpwZoL2NeOI5Ah3E20/dQKVttIgLhsE521chyvxCYCRaHD5tMNGKfhyw==} + '@tiptap/extension-code-block@3.19.0': + resolution: {integrity: sha512-b/2qR+tMn8MQb+eaFYgVk4qXnLNkkRYmwELQ8LEtEDQPxa5Vl7J3eu8+4OyoIFhZrNDZvvoEp80kHMCP8sI6rg==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/extension-code@3.15.3': - resolution: {integrity: sha512-x6LFt3Og6MFINYpsMzrJnz7vaT9Yk1t4oXkbJsJRSavdIWBEBcoRudKZ4sSe/AnsYlRJs8FY2uR76mt9e+7xAQ==} + '@tiptap/extension-code@3.19.0': + resolution: {integrity: sha512-2kqqQIXBXj2Or+4qeY3WoE7msK+XaHKL6EKOcKlOP2BW8eYqNTPzNSL+PfBDQ3snA7ljZQkTs/j4GYDj90vR1A==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-collaboration@3.15.3': - resolution: {integrity: sha512-AM/UkKkxnKA+NDJ1todoQoj8dMuOI1VcuoUyLVkGn1Jx7GjOng2IMouWkH1of8+dbq9qVWzmbN4VWelsz8vuvw==} + '@tiptap/extension-collaboration@3.19.0': + resolution: {integrity: sha512-Cb4RXo2C05w44OsT22weLYqf2mnyTacvtz3iWYswgq1slMOl4Gs5RQE+jHgyvjVbhj34yPS6ghoWBBrriX9a1w==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 - '@tiptap/y-tiptap': ^3.0.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@tiptap/y-tiptap': ^3.0.2 yjs: ^13 - '@tiptap/extension-document@3.15.3': - resolution: {integrity: sha512-AC72nI2gnogBuETCKbZekn+h6t5FGGcZG2abPGKbz/x9rwpb6qV2hcbAQ30t6M7H6cTOh2/Ut8bEV2MtMB15sw==} + '@tiptap/extension-document@3.19.0': + resolution: {integrity: sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-drag-handle-vue-3@3.13.0': - resolution: {integrity: sha512-kj0FpTEFo+KU7HUjrh245QY9HFhTL3y7PCuhNemRHcg9YdkFn07Up6LXthVxXGEFmnQfjR0L4aWFo7xPpUwj7g==} + '@tiptap/extension-drag-handle-vue-3@3.19.0': + resolution: {integrity: sha512-5PkH2XqsA+PwXXxJ40JwgDQWWBh+YdoBR9/summSyU1DCRN6xdc9SiYaq+mHBH1+XCsBQtobVPk6IJ39SxffrQ==} peerDependencies: - '@tiptap/extension-drag-handle': ^3.13.0 - '@tiptap/pm': ^3.13.0 - '@tiptap/vue-3': ^3.13.0 + '@tiptap/extension-drag-handle': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@tiptap/vue-3': ^3.19.0 vue: ^3.0.0 - '@tiptap/extension-drag-handle@3.15.3': - resolution: {integrity: sha512-Vka68xaFSFVeTRQtTxxChCgWcqcWr7SZEwC/RzPB4IE+d+Ev/ZfpVFNoYltdEFuhK/IBHWJxA6fKQFAOvPzlbw==} + '@tiptap/extension-drag-handle@3.19.0': + resolution: {integrity: sha512-bNvqwj5hmQyWBq8oyFUGa5HcK0edyMmFd7cgGiCRSR/H2DrT34zB286+C0dyksk1n5CjmO0wGxQ3qmjdfzzkQg==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/extension-collaboration': ^3.15.3 - '@tiptap/extension-node-range': ^3.15.3 - '@tiptap/pm': ^3.15.3 - '@tiptap/y-tiptap': ^3.0.0 - - '@tiptap/extension-dropcursor@3.15.3': - resolution: {integrity: sha512-jGI5XZpdo8GSYQFj7HY15/oEwC2m2TqZz0/Fln5qIhY32XlZhWrsMuMI6WbUJrTH16es7xO6jmRlDsc6g+vJWg==} + '@tiptap/core': ^3.19.0 + '@tiptap/extension-collaboration': ^3.19.0 + '@tiptap/extension-node-range': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@tiptap/y-tiptap': ^3.0.2 + + '@tiptap/extension-dropcursor@3.19.0': + resolution: {integrity: sha512-sf3dEZXiLvsGqVK2maUIzXY6qtYYCvBumag7+VPTMGQ0D4hiZ1X/4ukt4+6VXDg5R2WP1CoIt/QvUetUjWNhbQ==} peerDependencies: - '@tiptap/extensions': ^3.15.3 + '@tiptap/extensions': ^3.19.0 - '@tiptap/extension-floating-menu@3.13.0': - resolution: {integrity: sha512-OsezV2cMofZM4c13gvgi93IEYBUzZgnu8BXTYZQiQYekz4bX4uulBmLa1KOA9EN71FzS+SoLkXHU0YzlbLjlxA==} + '@tiptap/extension-floating-menu@3.19.0': + resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': ^3.13.0 - '@tiptap/pm': ^3.13.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/extension-gapcursor@3.15.3': - resolution: {integrity: sha512-Kaw0sNzP0bQI/xEAMSfIpja6xhsu9WqqAK/puzOIS1RKWO47Wps/tzqdSJ9gfslPIb5uY5mKCfy8UR8Xgiia8w==} + '@tiptap/extension-gapcursor@3.19.0': + resolution: {integrity: sha512-w7DACS4oSZaDWjz7gropZHPc9oXqC9yERZTcjWxyORuuIh1JFf0TRYspleK+OK28plK/IftojD/yUDn1MTRhvA==} peerDependencies: - '@tiptap/extensions': ^3.15.3 + '@tiptap/extensions': ^3.19.0 - '@tiptap/extension-hard-break@3.15.3': - resolution: {integrity: sha512-8HjxmeRbBiXW+7JKemAJtZtHlmXQ9iji398CPQ0yYde68WbIvUhHXjmbJE5pxFvvQTJ/zJv1aISeEOZN2bKBaw==} + '@tiptap/extension-hard-break@3.19.0': + resolution: {integrity: sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-heading@3.15.3': - resolution: {integrity: sha512-G1GG6iN1YXPS+75arDpo+bYRzhr3dNDw99c7D7na3aDawa9Qp7sZ/bVrzFUUcVEce0cD6h83yY7AooBxEc67hA==} + '@tiptap/extension-heading@3.19.0': + resolution: {integrity: sha512-uLpLlfyp086WYNOc0ekm1gIZNlEDfmzOhKzB0Hbyi6jDagTS+p9mxUNYeYOn9jPUxpFov43+Wm/4E24oY6B+TQ==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-horizontal-rule@3.13.0': - resolution: {integrity: sha512-ZUFyORtjj22ib8ykbxRhWFQOTZjNKqOsMQjaAGof30cuD2DN5J5pMz7Haj2fFRtLpugWYH+f0Mi+WumQXC3hCw==} + '@tiptap/extension-horizontal-rule@3.19.0': + resolution: {integrity: sha512-iqUHmgMGhMgYGwG6L/4JdelVQ5Mstb4qHcgTGd/4dkcUOepILvhdxajPle7OEdf9sRgjQO6uoAU5BVZVC26+ng==} peerDependencies: - '@tiptap/core': ^3.13.0 - '@tiptap/pm': ^3.13.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/extension-image@3.13.0': - resolution: {integrity: sha512-223uzLUkIa1rkK7aQK3AcIXe6LbCtmnpVb7sY5OEp+LpSaSPyXwyrZ4A0EO1o98qXG68/0B2OqMntFtA9c5Fbw==} + '@tiptap/extension-image@3.19.0': + resolution: {integrity: sha512-/rGl8nBziBPVJJ/9639eQWFDKcI3RQsDM3s+cqYQMFQfMqc7sQB9h4o4sHCBpmKxk3Y0FV/0NjnjLbBVm8OKdQ==} peerDependencies: - '@tiptap/core': ^3.13.0 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-italic@3.15.3': - resolution: {integrity: sha512-6XeuPjcWy7OBxpkgOV7bD6PATO5jhIxc8SEK4m8xn8nelGTBIbHGqK37evRv+QkC7E0MUryLtzwnmmiaxcKL0Q==} + '@tiptap/extension-italic@3.19.0': + resolution: {integrity: sha512-6GffxOnS/tWyCbDkirWNZITiXRta9wrCmrfa4rh+v32wfaOL1RRQNyqo9qN6Wjyl1R42Js+yXTzTTzZsOaLMYA==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-link@3.15.3': - resolution: {integrity: sha512-PdDXyBF9Wco9U1x6e+b7tKBWG+kqBDXDmaYXHkFm/gYuQCQafVJ5mdrDdKgkHDWVnJzMWZXBcZjT9r57qtlLWg==} + '@tiptap/extension-link@3.19.0': + resolution: {integrity: sha512-HEGDJnnCPfr7KWu7Dsq+eRRe/mBCsv6DuI+7fhOCLDJjjKzNgrX2abbo/zG3D/4lCVFaVb+qawgJubgqXR/Smw==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/extension-list-item@3.15.3': - resolution: {integrity: sha512-CCxL5ek1p0lO5e8aqhnPzIySldXRSigBFk2fP9OLgdl5qKFLs2MGc19jFlx5+/kjXnEsdQTFbGY1Sizzt0TVDw==} + '@tiptap/extension-list-item@3.19.0': + resolution: {integrity: sha512-VsSKuJz4/Tb6ZmFkXqWpDYkRzmaLTyE6dNSEpNmUpmZ32sMqo58mt11/huADNwfBFB0Ve7siH/VnFNIJYY3xvg==} peerDependencies: - '@tiptap/extension-list': ^3.15.3 + '@tiptap/extension-list': ^3.19.0 - '@tiptap/extension-list-keymap@3.15.3': - resolution: {integrity: sha512-UxqnTEEAKrL+wFQeSyC9z0mgyUUVRS2WTcVFoLZCE6/Xus9F53S4bl7VKFadjmqI4GpDk5Oe2IOUc72o129jWg==} + '@tiptap/extension-list-keymap@3.19.0': + resolution: {integrity: sha512-bxgmAgA3RzBGA0GyTwS2CC1c+QjkJJq9hC+S6PSOWELGRiTbwDN3MANksFXLjntkTa0N5fOnL27vBHtMStURqw==} peerDependencies: - '@tiptap/extension-list': ^3.15.3 + '@tiptap/extension-list': ^3.19.0 - '@tiptap/extension-list@3.15.3': - resolution: {integrity: sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==} + '@tiptap/extension-list@3.19.0': + resolution: {integrity: sha512-N6nKbFB2VwMsPlCw67RlAtYSK48TAsAUgjnD+vd3ieSlIufdQnLXDFUP6hFKx9mwoUVUgZGz02RA6bkxOdYyTw==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/extension-mention@3.13.0': - resolution: {integrity: sha512-JcZ9ItaaifurERewyydfj/s52MGcWsCxk5hYdkSohzwa8Ohw4yyghHWCuEl/kvLK+9KhjIDDr1jvAmfZ89I7Fg==} + '@tiptap/extension-mention@3.19.0': + resolution: {integrity: sha512-iBWX6mUouvDe9F75C2fJnFzvBFYVF8fcOa7UvzqWHRSCt8WxqSIp6C1B9Y0npP4TbIZySHzPV4NQQJhtmWwKww==} peerDependencies: - '@tiptap/core': ^3.13.0 - '@tiptap/pm': ^3.13.0 - '@tiptap/suggestion': ^3.13.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@tiptap/suggestion': ^3.19.0 - '@tiptap/extension-node-range@3.15.3': - resolution: {integrity: sha512-hQGGOjmcYPTQ5Htum8BQx91u+7AVTsaYRMJyGhUe176Dpfxow8srA/2cQjvraJIzsvRJvwdIuRj0G2X3Dtm3uQ==} + '@tiptap/extension-node-range@3.19.0': + resolution: {integrity: sha512-rIq1e+jTzdtHrGyWKZgRUJc8Phz5Crh1WqBL71QPJgLZqGbcCeGTHBFBOrU2AWwQNa8lYEbGD+FTFxVfvxegUA==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/extension-ordered-list@3.15.3': - resolution: {integrity: sha512-/8uhw528Iy0c9wF6tHCiIn0ToM0Ml6Ll2c/3iPRnKr4IjXwx2Lr994stUFihb+oqGZwV1J8CPcZJ4Ufpdqi4Dw==} + '@tiptap/extension-ordered-list@3.19.0': + resolution: {integrity: sha512-cxGsINquwHYE1kmhAcLNLHAofmoDEG6jbesR5ybl7tU5JwtKVO7S/xZatll2DU1dsDAXWPWEeeMl4e/9svYjCg==} peerDependencies: - '@tiptap/extension-list': ^3.15.3 + '@tiptap/extension-list': ^3.19.0 - '@tiptap/extension-paragraph@3.15.3': - resolution: {integrity: sha512-lc0Qu/1AgzcEfS67NJMj5tSHHhH6NtA6uUpvppEKGsvJwgE2wKG1onE4isrVXmcGRdxSMiCtyTDemPNMu6/ozQ==} + '@tiptap/extension-paragraph@3.19.0': + resolution: {integrity: sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-placeholder@3.13.0': - resolution: {integrity: sha512-Au4ktRBraQktX9gjSzGWyJV6kPof7+kOhzE8ej+rOMjIrHbx3DCHy1CJWftSO9BbqIyonjsFmm4nE+vjzZ3Z5Q==} + '@tiptap/extension-placeholder@3.19.0': + resolution: {integrity: sha512-i15OfgyI4IDCYAcYSKUMnuZkYuUInfanjf9zquH8J2BETiomf/jZldVCp/QycMJ8DOXZ38fXDc99wOygnSNySg==} peerDependencies: - '@tiptap/extensions': ^3.13.0 + '@tiptap/extensions': ^3.19.0 - '@tiptap/extension-strike@3.15.3': - resolution: {integrity: sha512-Y1P3eGNY7RxQs2BcR6NfLo9VfEOplXXHAqkOM88oowWWOE7dMNeFFZM9H8HNxoQgXJ7H0aWW9B7ZTWM9hWli2Q==} + '@tiptap/extension-strike@3.19.0': + resolution: {integrity: sha512-xYpabHsv7PccLUBQaP8AYiFCnYbx6P93RHPd0lgNwhdOjYFd931Zy38RyoxPHAgbYVmhf1iyx7lpuLtBnhS5dA==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-text@3.15.3': - resolution: {integrity: sha512-MhkBz8ZvrqOKtKNp+ZWISKkLUlTrDR7tbKZc2OnNcUTttL9dz0HwT+cg91GGz19fuo7ttDcfsPV6eVmflvGToA==} + '@tiptap/extension-text@3.19.0': + resolution: {integrity: sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extension-underline@3.15.3': - resolution: {integrity: sha512-r/IwcNN0W366jGu4Y0n2MiFq9jGa4aopOwtfWO4d+J0DyeS2m7Go3+KwoUqi0wQTiVU74yfi4DF6eRsMQ9/iHQ==} + '@tiptap/extension-underline@3.19.0': + resolution: {integrity: sha512-800MGEWfG49j10wQzAFiW/ele1HT04MamcL8iyuPNu7ZbjbGN2yknvdrJlRy7hZlzIrVkZMr/1tz62KN33VHIw==} peerDependencies: - '@tiptap/core': ^3.15.3 + '@tiptap/core': ^3.19.0 - '@tiptap/extensions@3.15.3': - resolution: {integrity: sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==} + '@tiptap/extensions@3.19.0': + resolution: {integrity: sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==} peerDependencies: - '@tiptap/core': ^3.15.3 - '@tiptap/pm': ^3.15.3 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/markdown@3.13.0': - resolution: {integrity: sha512-BI1GZxDFBrEeYbngbKh/si48tRSXO6HVGg7KzlfOwdncSD982/loG2KUnFIjoVGjmMzXNDWbI6O/eqfLVQPB5Q==} + '@tiptap/markdown@3.19.0': + resolution: {integrity: sha512-Pnfacq2FHky1rqwmGwEmUJxuZu8VZ8XjaJIqsQC34S3CQWiOU+PukC9In2odzcooiVncLWT9s97jKuYpbmF1tQ==} peerDependencies: - '@tiptap/core': ^3.13.0 - '@tiptap/pm': ^3.13.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/pm@3.13.0': - resolution: {integrity: sha512-WKR4ucALq+lwx0WJZW17CspeTpXorbIOpvKv5mulZica6QxqfMhn8n1IXCkDws/mCoLRx4Drk5d377tIjFNsvQ==} + '@tiptap/pm@3.19.0': + resolution: {integrity: sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==} - '@tiptap/starter-kit@3.13.0': - resolution: {integrity: sha512-Ojn6sRub04CRuyQ+9wqN62JUOMv+rG1vXhc2s6DCBCpu28lkCMMW+vTe7kXJcEdbot82+5swPbERw9vohswFzg==} + '@tiptap/starter-kit@3.19.0': + resolution: {integrity: sha512-dTCkHEz+Y8ADxX7h+xvl6caAj+3nII/wMB1rTQchSuNKqJTOrzyUsCWm094+IoZmLT738wANE0fRIgziNHs/ug==} - '@tiptap/suggestion@3.13.0': - resolution: {integrity: sha512-IXNvyLITpPiuXHn/q1ntztPYJZMFjPAokKj+OQz3MFNYlzAX3I409KD/EwwCubisRIAFiNX0ZjIIXxxZ3AhFTw==} + '@tiptap/suggestion@3.19.0': + resolution: {integrity: sha512-tUZwMRFqTVPIo566ZmHNRteyZxJy2EE4FA+S3IeIUOOvY6AW0h1imhbpBO7sXV8CeEQvpa+2DWwLvy7L3vmstA==} peerDependencies: - '@tiptap/core': ^3.13.0 - '@tiptap/pm': ^3.13.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 - '@tiptap/vue-3@3.13.0': - resolution: {integrity: sha512-vl9l2oEARKyUNpViqwSPCL0+dlyIomrPTdHOtDJb6ldo/umWKvjqgLhAtgA7MQ9NwVQa1k5rKICWU6ZH+jLBOw==} + '@tiptap/vue-3@3.19.0': + resolution: {integrity: sha512-H/w8k++Dv5ejacbPX6VEYqWpvcrAvU+iPggIU8XGZNOkCY9jKiHRXNXXEev/ScNhHm4E1itGJZFsgqJmtiCHLw==} peerDependencies: '@floating-ui/dom': ^1.0.0 - '@tiptap/core': ^3.13.0 - '@tiptap/pm': ^3.13.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 vue: ^3.0.0 - '@tiptap/y-tiptap@3.0.1': - resolution: {integrity: sha512-F3hj5X77ckmyIywbCQpKgyX3xKra2/acJPWaV5R9wqp0cUPBmm62FYbkQ6HaqxH1VhCkUhhAZcDSQjbjj7tnWw==} + '@tiptap/y-tiptap@3.0.2': + resolution: {integrity: sha512-flMn/YW6zTbc6cvDaUPh/NfLRTXDIqgpBUkYzM74KA1snqQwhOMjnRcnpu4hDFrTnPO6QGzr99vRyXEA7M44WA==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} peerDependencies: prosemirror-model: ^1.7.1 @@ -1232,6 +2327,9 @@ packages: y-protocols: ^1.0.1 yjs: ^13.5.38 + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} @@ -1241,14 +2339,20 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} - '@types/http-cache-semantics@4.0.4': - resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1262,17 +2366,20 @@ packages: '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@22.19.3': - resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} + '@types/node@22.19.10': + resolution: {integrity: sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==} - '@types/node@25.0.3': - resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/node@25.2.2': + resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} @@ -1280,12 +2387,18 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/stream-chain@2.1.0': resolution: {integrity: sha512-guDyAl6s/CAzXUOWpGK2bHvdiopLIwpGu8v10+lb9hnQOyo4oj/ZUQFOvqFjKGsE3wJP1fpIesCcMvbXuWsqOg==} '@types/stream-json@1.7.8': resolution: {integrity: sha512-MU1OB1eFLcYWd1LjwKXrxdoPtXSRzRmAnnxs4Js/ayB5O/NvHraWwuOaqMWIebpYwM6khFlsJOHEhI9xK/ab4Q==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} @@ -1298,96 +2411,73 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@7.18.0': - resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - '@typescript-eslint/parser': ^7.0.0 - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@types/yauzl@3.4.0': + resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==} - '@typescript-eslint/parser@7.18.0': - resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/eslint-plugin@8.55.0': + resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true + '@typescript-eslint/parser': ^8.55.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.52.0': - resolution: {integrity: sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==} + '@typescript-eslint/parser@8.55.0': + resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: + eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@7.18.0': - resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/project-service@8.55.0': + resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.52.0': - resolution: {integrity: sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==} + '@typescript-eslint/scope-manager@8.55.0': + resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.52.0': - resolution: {integrity: sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==} + '@typescript-eslint/tsconfig-utils@8.55.0': + resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@7.18.0': - resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/type-utils@8.55.0': + resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/types@7.18.0': - resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} - engines: {node: ^18.18.0 || >=20.0.0} + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.52.0': - resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==} + '@typescript-eslint/types@8.55.0': + resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@7.18.0': - resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/typescript-estree@8.52.0': - resolution: {integrity: sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==} + '@typescript-eslint/typescript-estree@8.55.0': + resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@7.18.0': - resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} - engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/utils@8.55.0': + resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.56.0 - - '@typescript-eslint/visitor-keys@7.18.0': - resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} - engines: {node: ^18.18.0 || >=20.0.0} + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.52.0': - resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==} + '@typescript-eslint/visitor-keys@8.55.0': + resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@unhead/vue@2.1.1': - resolution: {integrity: sha512-WYa8ORhfv7lWDSoNpkMKhbW1Dbsux/3HqMcVkZS3xZ2/c/VrcChLj+IMadpCd1WNR0srITfRJhBYZ1i9hON5Qw==} + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@unhead/vue@2.1.4': + resolution: {integrity: sha512-MFvywgkHMt/AqbhmKOqRuzvuHBTcmmmnUa7Wm/Sg11leXAeRShv2PcmY7IiYdeeJqBMCm1jwhcs6201jj6ggZg==} peerDependencies: vue: '>=3.5.18' @@ -1398,17 +2488,33 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 - '@vue/compiler-core@3.5.26': - resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==} + '@vitejs/plugin-vue@6.0.7': + resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} - '@vue/compiler-dom@3.5.26': - resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==} + '@vue/compiler-core@3.5.27': + resolution: {integrity: sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==} - '@vue/compiler-sfc@3.5.26': - resolution: {integrity: sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==} + '@vue/compiler-dom@3.5.27': + resolution: {integrity: sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==} - '@vue/compiler-ssr@3.5.26': - resolution: {integrity: sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==} + '@vue/compiler-sfc@3.5.27': + resolution: {integrity: sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==} + + '@vue/compiler-ssr@3.5.27': + resolution: {integrity: sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==} '@vue/devtools-api@6.6.4': resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} @@ -1428,22 +2534,36 @@ packages: eslint: '>= 8.21.0' prettier: '>= 3.0.0' - '@vue/reactivity@3.5.26': - resolution: {integrity: sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==} + '@vue/eslint-config-typescript@14.6.0': + resolution: {integrity: sha512-UpiRY/7go4Yps4mYCjkvlIbVWmn9YvPGQDxTAlcKLphyaD77LjIu3plH4Y9zNT0GB4f3K5tMmhhtRhPOgrQ/bQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 + eslint-plugin-vue: ^9.28.0 || ^10.0.0 + typescript: '>=4.8.4' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@3.2.6': + resolution: {integrity: sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==} - '@vue/runtime-core@3.5.26': - resolution: {integrity: sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==} + '@vue/reactivity@3.5.27': + resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==} - '@vue/runtime-dom@3.5.26': - resolution: {integrity: sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==} + '@vue/runtime-core@3.5.27': + resolution: {integrity: sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==} - '@vue/server-renderer@3.5.26': - resolution: {integrity: sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==} + '@vue/runtime-dom@3.5.27': + resolution: {integrity: sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==} + + '@vue/server-renderer@3.5.27': + resolution: {integrity: sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==} peerDependencies: - vue: 3.5.26 + vue: 3.5.27 - '@vue/shared@3.5.26': - resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + '@vue/shared@3.5.27': + resolution: {integrity: sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==} '@vueuse/core@10.11.1': resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} @@ -1456,13 +2576,13 @@ packages: peerDependencies: vue: ^3.5.0 - '@vueuse/core@14.1.0': - resolution: {integrity: sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==} + '@vueuse/core@14.2.0': + resolution: {integrity: sha512-tpjzVl7KCQNVd/qcaCE9XbejL38V6KJAEq/tVXj7mDPtl6JtzmUdnXelSS+ULRkkrDgzYVK7EerQJvd2jR794Q==} peerDependencies: vue: ^3.5.0 - '@vueuse/integrations@14.1.0': - resolution: {integrity: sha512-eNQPdisnO9SvdydTIXnTE7c29yOsJBD/xkwEyQLdhDC/LKbqrFpXHb3uS//7NcIrQO3fWVuvMGp8dbK6mNEMCA==} + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} peerDependencies: async-validator: ^4 axios: ^1 @@ -1475,6 +2595,47 @@ packages: nprogress: ^0.2 qrcode: ^1.5 sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/integrations@14.2.0': + resolution: {integrity: sha512-Yuo5XbIi6XkfSXOYKd5SBZwyBEyO3Hd41eeG2555hDbE0Maz/P0BfPJDYhgDXjS9xI0jkWUUp1Zh5lXHOgkwLw==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 || ^8 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 universal-cookie: ^7 || ^8 vue: ^3.5.0 peerDependenciesMeta: @@ -1512,8 +2673,8 @@ packages: '@vueuse/metadata@13.9.0': resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==} - '@vueuse/metadata@14.1.0': - resolution: {integrity: sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==} + '@vueuse/metadata@14.2.0': + resolution: {integrity: sha512-i3axTGjU8b13FtyR4Keeama+43iD+BwX9C2TmzBVKqjSHArF03hjkp2SBZ1m72Jk2UtrX0aYCugBq2R1fhkuAQ==} '@vueuse/shared@10.11.1': resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} @@ -1526,21 +2687,36 @@ packages: peerDependencies: vue: ^3.5.0 - '@vueuse/shared@14.1.0': - resolution: {integrity: sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==} + '@vueuse/shared@14.2.0': + resolution: {integrity: sha512-Z0bmluZTlAXgUcJ4uAFaML16JcD8V0QG00Db3quR642I99JXIDRa2MI2LGxiLVhcBjVnL1jOzIvT5TT2lqJlkA==} peerDependencies: vue: ^3.5.0 + '@webgpu/types@0.1.71': + resolution: {integrity: sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==} + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} - '@zumer/snapdom@2.0.1': - resolution: {integrity: sha512-78/qbYl2FTv4H6qaXcNfAujfIOSzdvs83NW63VbyC9QA3sqNPfPvhn4xYMO6Gy11hXwJUEhd0z65yKiNzDwy9w==} + '@zumer/snapdom@2.0.2': + resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==} - abbrev@3.0.1: - resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} - engines: {node: ^18.17.0 || >=20.5.0} + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1552,10 +2728,22 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.17: + resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} + engines: {node: '>=12.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@3.5.2: resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} peerDependencies: @@ -1564,6 +2752,16 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + algoliasearch@5.52.1: + resolution: {integrity: sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==} + engines: {node: '>= 14.0.0'} + + alien-signals@3.1.2: + resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1580,6 +2778,9 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1587,12 +2788,15 @@ packages: app-builder-bin@5.0.0-alpha.12: resolution: {integrity: sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==} - app-builder-lib@26.4.0: - resolution: {integrity: sha512-Uas6hNe99KzP3xPWxh5LGlH8kWIVjZixzmMJHNB9+6hPyDpjc7NQMkVgi16rQDdpCFy22ZU5sp8ow7tvjeMgYQ==} + app-builder-lib@26.7.0: + resolution: {integrity: sha512-/UgCD8VrO79Wv8aBNpjMfsS1pIUfIPURoRn0Ik6tMe5avdZF+vQgl/juJgipcMmH3YS0BD573lCdCHyoi84USg==} engines: {node: '>=14.0.0'} peerDependencies: - dmg-builder: 26.4.0 - electron-builder-squirrel-windows: 26.4.0 + dmg-builder: 26.7.0 + electron-builder-squirrel-windows: 26.7.0 + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1601,10 +2805,6 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -1627,23 +2827,37 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} - axios@1.13.2: - resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + axios@1.13.5: + resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.9.12: - resolution: {integrity: sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==} + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true - better-sqlite3@12.5.0: - resolution: {integrity: sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==} + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} @@ -1653,6 +2867,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -1660,12 +2878,19 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1681,6 +2906,9 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1691,8 +2919,18 @@ packages: resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} engines: {node: '>=12.0.0'} - builder-util@26.3.4: - resolution: {integrity: sha512-aRn88mYMktHxzdqDMF6Ayj0rKoX+ZogJ75Ck7RrIqbY/ad0HBvnS2xA4uHfzrGr5D2aLL3vU6OBEH4p0KMV2XQ==} + builder-util@26.4.1: + resolution: {integrity: sha512-FlgH43XZ50w3UtS1RVGDWOz8v9qMXPC7upMtKMtBEnYdt1OVoS61NYhKm/4x+cIaWqJTXua0+VVPI+fSPGXNIw==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} c12@3.3.3: resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} @@ -1706,10 +2944,6 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cacache@19.0.1: - resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} - engines: {node: ^18.17.0 || >=20.5.0} - cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -1722,20 +2956,29 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001762: - resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chart.js@4.5.1: - resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} - engines: {pnpm: '>=8'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} @@ -1748,10 +2991,6 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - chownr@2.0.0: - resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} - engines: {node: '>=10'} - chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -1763,16 +3002,15 @@ packages: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} + citty@0.2.0: + resolution: {integrity: sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==} cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} @@ -1785,10 +3023,6 @@ packages: clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - clone@2.1.2: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} @@ -1807,6 +3041,17 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + commander@5.1.0: resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} engines: {node: '>= 6'} @@ -1825,19 +3070,39 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - confbox@0.2.2: - resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -1845,6 +3110,10 @@ packages: core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + crc@3.8.0: resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} @@ -1854,11 +3123,6 @@ packages: cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} - cross-env@7.0.3: - resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} - engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} - hasBin: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1878,6 +3142,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} @@ -1901,9 +3169,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -1923,6 +3188,14 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -1933,18 +3206,17 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dfa@1.2.0: resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - dmg-builder@26.4.0: - resolution: {integrity: sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==} + dmg-builder@26.7.0: + resolution: {integrity: sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==} dmg-license@1.0.11: resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} @@ -1960,46 +3232,63 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + dotenv@17.2.4: + resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} engines: {node: '>=12'} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + echarts-wordcloud@2.1.0: + resolution: {integrity: sha512-Kt1JmbcROgb+3IMI48KZECK2AP5lG6bSsOEs+AsuwaWJxQom31RTNd6NFYI01E/YaI1PFZeueaupjlmzSQasjQ==} + peerDependencies: + echarts: ^5.0.1 + + echarts@6.0.0: + resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} hasBin: true - electron-builder-squirrel-windows@26.4.0: - resolution: {integrity: sha512-7dvalY38xBzWNaoOJ4sqy2aGIEpl2S1gLPkkB0MHu1Hu5xKQ82il1mKSFlXs6fLpXUso/NmyjdHGlSHDRoG8/w==} + electron-builder-squirrel-windows@26.7.0: + resolution: {integrity: sha512-3EqkQK+q0kGshdPSKEPb2p5F75TENMKu6Fe5aTdeaPfdzFK4Yjp5L0d6S7K8iyvqIsGQ/ei4bnpyX9wt+kVCKQ==} - electron-builder@26.4.0: - resolution: {integrity: sha512-FCUqvdq2AULL+Db2SUGgjOYTbrgkPxZtCjqIZGnjH9p29pTWyesQqBIfvQBKa6ewqde87aWl49n/WyI/NyUBog==} + electron-builder@26.7.0: + resolution: {integrity: sha512-LoXbCvSFxLesPneQ/fM7FB4OheIDA2tjqCdUkKlObV5ZKGhYgi5VHPHO/6UUOUodAlg7SrkPx7BZJPby+Vrtbg==} engines: {node: '>=14.0.0'} hasBin: true - electron-publish@26.3.4: - resolution: {integrity: sha512-5/ouDPb73SkKuay2EXisPG60LTFTMNHWo2WLrK5GDphnWK9UC+yzYrzVeydj078Yk4WUXi0+TaaZsNd6Zt5k/A==} + electron-publish@26.6.0: + resolution: {integrity: sha512-LsyHMMqbvJ2vsOvuWJ19OezgF2ANdCiHpIucDHNiLhuI+/F3eW98ouzWSRmXXi82ZOPZXC07jnIravY4YYwCLQ==} - electron-to-chromium@1.5.267: - resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} electron-updater@6.7.3: resolution: {integrity: sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==} - electron-vite@3.1.0: - resolution: {integrity: sha512-M7aAzaRvSl5VO+6KN4neJCYLHLpF/iWo5ztchI/+wMxIieDZQqpbCYfaEHHHPH6eupEzfvZdLYdPdmvGqoVe0Q==} - engines: {node: ^18.0.0 || >=20.0.0} + electron-vite@5.0.0: + resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@swc/core': ^1.0.0 - vite: ^4.0.0 || ^5.0.0 || ^6.0.0 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: '@swc/core': optional: true @@ -2057,28 +3346,32 @@ packages: embla-carousel@8.6.0: resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - encoding@0.1.13: - resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.18.4: - resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@7.0.0: - resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} env-paths@2.2.1: @@ -2110,15 +3403,28 @@ packages: es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2127,19 +3433,14 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - eslint-config-prettier@10.1.8: resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} hasBin: true peerDependencies: eslint: '>=7.0.0' - eslint-plugin-prettier@5.5.4: - resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: '@types/eslint': '>=8.0.0' @@ -2219,6 +3520,21 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -2230,9 +3546,26 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + express-rate-limit@8.5.1: + resolution: {integrity: sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} engines: {node: '>= 10.17.0'} @@ -2242,6 +3575,9 @@ packages: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2255,9 +3591,31 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} + fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.8.4: + resolution: {integrity: sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2273,6 +3631,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2287,17 +3649,34 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} + engines: {node: '>=20'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -2331,8 +3710,16 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - framer-motion@12.23.26: - resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + framer-motion@12.33.0: + resolution: {integrity: sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2345,6 +3732,10 @@ packages: react-dom: optional: true + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -2368,14 +3759,6 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} - fs-minipass@2.1.0: - resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} - engines: {node: '>= 8'} - - fs-minipass@3.0.3: - resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2391,6 +3774,14 @@ packages: resolution: {integrity: sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==} engines: {node: '>=10'} + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2415,6 +3806,12 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + gifuct-js@2.1.2: + resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -2430,13 +3827,18 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -2454,9 +3856,13 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} @@ -2469,11 +3875,19 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} - h3@1.15.4: - resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==} + h3@1.15.5: + resolution: {integrity: sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==} has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} @@ -2494,19 +3908,39 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hey-listen@1.0.8: resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.0.1: + resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==} + hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -2523,6 +3957,14 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + i18next@25.8.5: + resolution: {integrity: sha512-TApjhgqQU6P7BQlpCTv6zQuXrYAP9rjYWgx2Nm8dsq+Zg9yJlAz+iR16/w7uVtTlSoULbqPTfqYjMK/DAQI+Ng==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -2532,6 +3974,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2561,13 +4007,25 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2580,22 +4038,17 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -2611,9 +4064,16 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.1: - resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} - engines: {node: '>=16'} + isexe@3.1.4: + resolution: {integrity: sha512-jCErc4h4RnTPjFq53G4whhjAMbUAqinGrCrTT4dmMNyi4zTthK+wphqbRLJtL4BN/Mq7Zzltr0m/b1X0m7PGFQ==} + engines: {node: '>=20'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + ismobilejs@1.1.1: + resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} @@ -2630,12 +4090,29 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-binary-schema-parser@2.0.3: + resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + + js-tiktoken@1.0.21: + resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -2645,12 +4122,28 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2662,19 +4155,25 @@ packages: engines: {node: '>=6'} hasBin: true - jsonc-eslint-parser@2.4.2: - resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + klona@2.0.6: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} @@ -2694,63 +4193,116 @@ packages: engines: {node: '>=16'} hasBin: true + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.30.2: resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.30.2: resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.30.2: resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.30.2: resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.30.2: resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -2758,22 +4310,49 @@ packages: cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.30.2: resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.30.2: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + local-pkg@1.1.2: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} @@ -2792,12 +4371,11 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} lowercase-keys@2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} @@ -2806,6 +4384,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.5: + resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2819,17 +4401,16 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - make-fetch-happen@14.0.3: - resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} - engines: {node: ^18.17.0 || >=20.5.0} + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} markdown-it@14.1.0: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true - marked@15.0.12: - resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} - engines: {node: '>= 18'} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} hasBin: true matcher@3.0.0: @@ -2840,12 +4421,23 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -2853,6 +4445,21 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2861,18 +4468,27 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} hasBin: true - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} @@ -2886,9 +4502,9 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - minimatch@10.1.1: - resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} - engines: {node: 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2904,41 +4520,12 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass-collect@2.0.1: - resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass-fetch@4.0.1: - resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - minipass-flush@1.0.5: - resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} - engines: {node: '>= 8'} - - minipass-pipeline@1.2.4: - resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} - engines: {node: '>=8'} - - minipass-sized@1.0.3: - resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} - engines: {node: '>=8'} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - - minipass@5.0.0: - resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} - engines: {node: '>=8'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - minizlib@2.1.2: - resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} - engines: {node: '>= 8'} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} minizlib@3.1.0: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} @@ -2954,22 +4541,17 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - motion-dom@12.24.10: - resolution: {integrity: sha512-H3HStYaJ6wANoZVNT0ZmYZHGvrpvi9pKJRzsgNEHkdITR4Qd9FFu2e9sH4e2Phr4tKCmyyloex6SOSmv0Tlq+g==} + motion-dom@12.33.0: + resolution: {integrity: sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==} - motion-utils@12.24.10: - resolution: {integrity: sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==} + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - motion-v@1.7.6: - resolution: {integrity: sha512-ZNQPxJY8q8a76TfeRThbG1Lk++ELPGnKQ1Mzebx8sVU/uhePY5BwG+a5TIlSusT3+i1Uf0iJWmexN8jZi9yyVg==} + motion-v@1.10.2: + resolution: {integrity: sha512-K+Zus21KKgZP4CBY7CvU/B7UZCV9sZTHG0FgsAfGHlbZi+u8EolmZ2kvJe5zOG0RzCgdiVCobHBt54qch9rweg==} peerDependencies: '@vueuse/core': '>=10.0.0' vue: '>=3.0.0' @@ -2981,6 +4563,12 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2996,12 +4584,12 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - node-abi@3.85.0: - resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} - node-abi@4.24.0: - resolution: {integrity: sha512-u2EC1CeNe25uVtX3EZbdQ275c74zdZmmpzrHEQh2aIYqoVjlglfUpOX9YY85x1nlBydEKDVaSmMNhR7N82Qj8A==} + node-abi@4.26.0: + resolution: {integrity: sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw==} engines: {node: '>=22.12.0'} node-addon-api@1.7.2: @@ -3010,12 +4598,21 @@ packages: node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - node-gyp@11.5.0: - resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} - engines: {node: ^18.17.0 || >=20.5.0} + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-gyp@12.3.0: + resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true node-mock-http@1.0.4: @@ -3024,9 +4621,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nopt@8.1.0: - resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} - engines: {node: ^18.17.0 || >=20.5.0} + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true normalize-path@3.0.0: @@ -3044,40 +4641,79 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - nypm@0.6.2: - resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==} - engines: {node: ^14.16.0 || >=16.10.0} + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + onnxruntime-common@1.24.0-dev.20251116-b39e144322: + resolution: {integrity: sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==} + + onnxruntime-common@1.24.3: + resolution: {integrity: sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==} + + onnxruntime-node@1.24.3: + resolution: {integrity: sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==} + os: [win32, darwin, linux] + + onnxruntime-web@1.26.0-dev.20260416-b7804b056c: + resolution: {integrity: sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==} + + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} @@ -3093,9 +4729,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} - engines: {node: '>=18'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3110,10 +4746,27 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-svg-path@0.2.0: + resolution: {integrity: sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -3130,9 +4783,12 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -3150,8 +4806,8 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} - perfect-debounce@2.0.0: - resolution: {integrity: sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3187,16 +4843,63 @@ packages: typescript: optional: true + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pixi-viewport@6.0.3: + resolution: {integrity: sha512-2+qPJ0/n+8hQYhWvY+795+x9y3MiUrCOWacK0DY53whowWaGdx9iDocy7z1pBwjkZhC52YvrJQuZKK0sdVLtBw==} + peerDependencies: + pixi.js: '>=8' + + pixi.js@8.19.0: + resolution: {integrity: sha512-pq1O6emA/GFjjeF+8d3Pb5t7knD8FsnfWGqQcRjYjsqFZ7QdzG1XgjLDUu0DFJRbafjV5+g8iNLFBx0b9649lg==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} @@ -3210,9 +4913,13 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + preact@10.29.2: + resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: @@ -3223,14 +4930,20 @@ packages: resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} - prettier@3.7.4: - resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true - proc-log@5.0.0: - resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} - engines: {node: ^18.17.0 || >=20.5.0} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} @@ -3240,6 +4953,12 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prosemirror-changeset@2.3.1: resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} @@ -3264,8 +4983,8 @@ packages: prosemirror-keymap@1.2.3: resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} - prosemirror-markdown@1.13.2: - resolution: {integrity: sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==} + prosemirror-markdown@1.13.4: + resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} prosemirror-menu@1.2.5: resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} @@ -3292,11 +5011,19 @@ packages: prosemirror-state: ^1.4.2 prosemirror-view: ^1.33.8 - prosemirror-transform@1.10.5: - resolution: {integrity: sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==} + prosemirror-transform@1.11.0: + resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} + + prosemirror-view@1.41.6: + resolution: {integrity: sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==} + + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} - prosemirror-view@1.41.4: - resolution: {integrity: sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -3312,12 +5039,19 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -3325,9 +5059,20 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc9@3.0.0: + resolution: {integrity: sha512-MGOue0VqscKWQ104udASX/3GYDcKyPI4j4F8gu/jHHzglpmy9a/anZK3PNe8ug6aZFl+9GxLtdhe3kVZuMaQbA==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -3348,12 +5093,25 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true - reka-ui@2.6.1: - resolution: {integrity: sha512-XK7cJDQoNuGXfCNzBBo/81Yg/OgjPwvbabnlzXG2VsdSgNsT6iIkuPBPr+C0Shs+3bb0x0lbPvgQAhMSCKm5Ww==} + reka-ui@2.7.0: + resolution: {integrity: sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==} peerDependencies: vue: '>= 3.2.0' @@ -3361,6 +5119,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -3372,20 +5134,31 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - restructure@3.0.2: resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3398,36 +5171,63 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} - rollup@4.55.1: - resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex2@5.1.0: + resolution: {integrity: sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} sanitize-filename@1.6.3: resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} - sax@1.4.3: - resolution: {integrity: sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==} + sax@1.4.4: + resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} + engines: {node: '>=11.0.0'} scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -3439,15 +5239,33 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3456,6 +5274,25 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -3477,10 +5314,6 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - slice-ansi@3.0.0: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} @@ -3489,13 +5322,12 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -3508,21 +5340,63 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + speakingurl@14.0.1: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - ssri@12.0.0: - resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} - engines: {node: ^18.17.0 || >=20.5.0} + sqlite-vec-darwin-arm64@0.1.9: + resolution: {integrity: sha512-jSsZpE42OfBkGL/ItyJTVCUwl6o6Ka3U5rc4j+UBDIQzC1ulSSKMEhQLthsOnF/MdAf1MuAkYhkdKmmcjaIZQg==} + cpu: [arm64] + os: [darwin] + + sqlite-vec-darwin-x64@0.1.9: + resolution: {integrity: sha512-KDlVyqQT7pnOhU1ymB9gs7dMbSoVmKHitT+k1/xkjarcX8bBqPxWrGlK/R+C5WmWkfvWwyq5FfXfiBYCBs6PlA==} + cpu: [x64] + os: [darwin] + + sqlite-vec-linux-arm64@0.1.9: + resolution: {integrity: sha512-5wXVJ9c9kR4CHm/wVqXb/R+XUHTdpZ4nWbPHlS+gc9qQFVHs92Km4bPnCKX4rtcPMzvNis+SIzMJR1SCEwpuUw==} + cpu: [arm64] + os: [linux] + + sqlite-vec-linux-x64@0.1.9: + resolution: {integrity: sha512-w3tCH8xK2finW8fQJ/m8uqKodXUZ9KAuAar2UIhz4BHILfpE0WM/MTGCRfa7RjYbrYim5Luk3guvMOGI7T7JQA==} + cpu: [x64] + os: [linux] + + sqlite-vec-windows-x64@0.1.9: + resolution: {integrity: sha512-y3gEIyy/17bq2QFPQOWLE68TYWcRZkBQVA2XLrTPHNTOp55xJi/BBBmOm40tVMDMjtP+Elpk6UBUXdaq+46b0Q==} + cpu: [x64] + os: [win32] + + sqlite-vec@0.1.9: + resolution: {integrity: sha512-L7XJWRIBNvR9O5+vh1FQ+IGkh/3D2AzVksW5gdtk28m78Hy8skFD0pqReKH1Yp0/BUKRGcffgKvyO/EON5JXpA==} stat-mode@1.0.0: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -3543,6 +5417,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -3551,6 +5428,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -3566,6 +5447,14 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -3578,10 +5467,13 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - synckit@0.11.11: - resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -3609,13 +5501,10 @@ packages: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar@6.2.1: - resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} - engines: {node: '>=10'} - - tar@7.5.2: - resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} + tar@7.5.7: + resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} @@ -3624,15 +5513,36 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + three@0.185.0: + resolution: {integrity: sha512-+yRrcRO2iZa8uzvNNl0d7cL4huhgKgBvVJ0njcTe8xFqZ6DMAFZdCKDP91SEAuj25bNAj7k1QQdf+srZywVK6w==} + tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-lru@11.4.7: + resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} + engines: {node: '>=12'} + tiny-typed-emitter@2.1.0: resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -3652,18 +5562,30 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} - ts-api-utils@1.4.3: - resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} - engines: {node: '>=16'} - peerDependencies: - typescript: '>=4.2.0' + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} @@ -3671,9 +5593,39 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -3689,9 +5641,23 @@ packages: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + type-level-regexp@0.1.17: resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + typebox@1.1.38: + resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + + typescript-eslint@8.55.0: + resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3700,8 +5666,8 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - ufo@1.6.2: - resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==} + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -3715,8 +5681,12 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - unhead@2.1.1: - resolution: {integrity: sha512-NOt8n2KybAOxSLfNXegAVai4SGU8bPKqWnqCzNAvnRH2i8mW+0bbFjN/L75LBgCSTiOjJSpANe5w2V34Grr7Cw==} + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} + + unhead@2.1.4: + resolution: {integrity: sha512-+5091sJqtNNmgfQ07zJOgUnMIMKzVKAWjeMlSrTdSGPB6JSozhpjUKuMfWEoLxlMAfhIvgOU8Me0XJvmMA/0fA==} unicode-properties@1.4.1: resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} @@ -3731,13 +5701,20 @@ packages: resolution: {integrity: sha512-8rqAmtJV8o60x46kBAJKtHpJDJWkA2xcBqWKPI14MgUb05o1pnpnCnXSxedUXyeq7p8fR5g3pTo2BaswZ9lD9A==} engines: {node: '>=18.12.0'} - unique-filename@4.0.0: - resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} - engines: {node: ^18.17.0 || >=20.5.0} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - unique-slug@5.0.0: - resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} - engines: {node: ^18.17.0 || >=20.5.0} + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} @@ -3747,9 +5724,13 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unplugin-auto-import@20.3.0: - resolution: {integrity: sha512-RcSEQiVv7g0mLMMXibYVKk8mpteKxvyffGuDKqZZiFr7Oq3PB1HwgHdK5O7H4AzbhzHoVKG0NnMnsk/1HIVYzQ==} - engines: {node: '>=14'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin-auto-import@21.0.0: + resolution: {integrity: sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==} + engines: {node: '>=20.19.0'} peerDependencies: '@nuxt/kit': ^4.0.0 '@vueuse/core': '*' @@ -3763,16 +5744,13 @@ packages: resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} engines: {node: '>=20.19.0'} - unplugin-vue-components@30.0.0: - resolution: {integrity: sha512-4qVE/lwCgmdPTp6h0qsRN2u642tt4boBQtcpn4wQcWZAsr8TQwq+SPT3NDu/6kBFxzo/sSEK4ioXhOOBrXc3iw==} - engines: {node: '>=14'} + unplugin-vue-components@31.0.0: + resolution: {integrity: sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==} + engines: {node: '>=20.19.0'} peerDependencies: - '@babel/parser': ^7.15.8 '@nuxt/kit': ^3.2.2 || ^4.0.0 - vue: 2 || 3 + vue: ^3.0.0 peerDependenciesMeta: - '@babel/parser': - optional: true '@nuxt/kit': optional: true @@ -3780,8 +5758,8 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} - unstorage@1.17.3: - resolution: {integrity: sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q==} + unstorage@1.17.4: + resolution: {integrity: sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==} peerDependencies: '@azure/app-configuration': ^1.8.0 '@azure/cosmos': ^4.2.0 @@ -3789,14 +5767,14 @@ packages: '@azure/identity': ^4.6.0 '@azure/keyvault-secrets': ^4.9.0 '@azure/storage-blob': ^12.26.0 - '@capacitor/preferences': ^6.0.3 || ^7.0.0 + '@capacitor/preferences': ^6 || ^7 || ^8 '@deno/kv': '>=0.9.0' '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 '@planetscale/database': ^1.19.0 '@upstash/redis': ^1.34.3 '@vercel/blob': '>=0.27.1' '@vercel/functions': ^2.2.12 || ^3.0.0 - '@vercel/kv': ^1.0.1 + '@vercel/kv': ^1 || ^2 || ^3 aws4fetch: ^1.0.20 db0: '>=0.2.1' idb-keyval: ^6.2.1 @@ -3861,6 +5839,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vaul-vue@0.4.1: resolution: {integrity: sha512-A6jOWOZX5yvyo1qMn7IveoWN91mJI5L3BUKsIwkg6qrTGgHs1Sb1JF/vyLJgnbN1rH4OOOxFbtqL9A46bOyGUQ==} peerDependencies: @@ -3871,19 +5853,56 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@types/node': ^20.19.0 || >=22.12.0 jiti: '>=1.21.0' - less: '*' + less: ^4.0.0 lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 @@ -3911,14 +5930,23 @@ packages: yaml: optional: true - vue-chartjs@5.3.3: - resolution: {integrity: sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==} + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true peerDependencies: - chart.js: ^4.1.1 - vue: ^3.0.0-0 || ^2.7.0 + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true - vue-component-type-helpers@3.2.2: - resolution: {integrity: sha512-x8C2nx5XlUNM0WirgfTkHjJGO/ABBxlANZDtHw2HclHtQnn+RFPTnbjMJn8jHZW4TlUam0asHcA14lf1C6Jb+A==} + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-component-type-helpers@3.2.4: + resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -3931,6 +5959,12 @@ packages: '@vue/composition-api': optional: true + vue-eslint-parser@10.2.0: + resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + vue-eslint-parser@9.4.3: resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} engines: {node: ^14.17.0 || >=16.0.0} @@ -3948,8 +5982,14 @@ packages: peerDependencies: vue: ^3.5.0 - vue@3.5.26: - resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==} + vue-tsc@3.2.6: + resolution: {integrity: sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.27: + resolution: {integrity: sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -3959,8 +5999,9 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -3979,6 +6020,11 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3994,10 +6040,26 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -4022,10 +6084,6 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml-eslint-parser@1.3.2: - resolution: {integrity: sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg==} - engines: {node: ^14.17.0 || >=16.0.0} - yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -4042,6 +6100,10 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + yjs@13.6.29: resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -4050,10 +6112,136 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zrender@6.0.0: + resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: 7zip-bin@5.2.0: {} + '@algolia/abtesting@1.18.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + '@algolia/client-search': 5.52.1 + algoliasearch: 5.52.1 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)': + dependencies: + '@algolia/client-search': 5.52.1 + algoliasearch: 5.52.1 + + '@algolia/client-abtesting@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-analytics@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-common@5.52.1': {} + + '@algolia/client-insights@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-personalization@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-query-suggestions@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/client-search@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/ingestion@1.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/monitoring@1.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/recommend@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + '@algolia/requester-browser-xhr@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + + '@algolia/requester-fetch@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + + '@algolia/requester-node-http@5.52.1': + dependencies: + '@algolia/client-common': 5.52.1 + '@alloc/quick-lru@5.2.0': {} '@antfu/install-pkg@1.1.0': @@ -4061,29 +6249,256 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@aptabase/electron@0.3.1(electron@35.7.5)': + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: - electron: 35.7.5 + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + '@aws-sdk/util-locate-window': 3.965.4 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1053.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/credential-provider-node': 3.972.44 + '@aws-sdk/eventstream-handler-node': 3.972.17 + '@aws-sdk/middleware-eventstream': 3.972.13 + '@aws-sdk/middleware-websocket': 3.972.21 + '@aws-sdk/token-providers': 3.1053.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.13': + dependencies: + '@aws-sdk/types': 3.973.9 + '@aws-sdk/xml-builder': 3.972.25 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/core': 3.24.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.39': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.41': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/credential-provider-env': 3.972.39 + '@aws-sdk/credential-provider-http': 3.972.41 + '@aws-sdk/credential-provider-login': 3.972.43 + '@aws-sdk/credential-provider-process': 3.972.39 + '@aws-sdk/credential-provider-sso': 3.972.43 + '@aws-sdk/credential-provider-web-identity': 3.972.43 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.44': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.39 + '@aws-sdk/credential-provider-http': 3.972.41 + '@aws-sdk/credential-provider-ini': 3.972.43 + '@aws-sdk/credential-provider-process': 3.972.39 + '@aws-sdk/credential-provider-sso': 3.972.43 + '@aws-sdk/credential-provider-web-identity': 3.972.43 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.39': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/token-providers': 3.1052.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.17': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.13': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.21': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.11': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/signature-v4-multi-region': 3.996.28 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.28': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1052.0': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1053.0': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.9': + dependencies: + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.4': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.25': + dependencies: + '@nodable/entities': 2.1.0 + '@smithy/types': 4.14.2 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.3': {} - '@babel/code-frame@7.27.1': + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.28.5': {} + '@babel/compat-data@7.29.0': {} - '@babel/core@7.28.5': + '@babel/core@7.29.0': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -4093,17 +6508,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.28.5': + '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.27.2': + '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/compat-data': 7.28.5 + '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 browserslist: 4.28.1 lru-cache: 5.1.1 @@ -4111,23 +6526,23 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.27.1': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.27.1': {} + '@babel/helper-plugin-utils@7.28.6': {} '@babel/helper-string-parser@7.27.1': {} @@ -4135,39 +6550,41 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.4': + '@babel/helpers@7.28.6': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 - '@babel/parser@7.28.5': + '@babel/parser@7.29.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 - '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/template@7.27.2': + '@babel/runtime@7.28.6': {} + + '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 - '@babel/traverse@7.28.5': + '@babel/traverse@7.29.0': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.28.5': + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 @@ -4181,27 +6598,70 @@ snapshots: ajv: 6.12.6 ajv-keywords: 3.5.2(ajv@6.12.6) - '@electron-toolkit/eslint-config-ts@2.0.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3)': dependencies: - '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) + '@docsearch/react': 3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3) + preact: 10.29.2 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.52.1)(algoliasearch@5.52.1) + '@docsearch/css': 3.8.2 + algoliasearch: 5.52.1 optionalDependencies: - typescript: 5.9.3 + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@earendil-works/pi-agent-core@0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-ai': 0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.8.2 transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil - supports-color + - utf-8-validate + - ws + - zod - '@electron-toolkit/eslint-config@1.0.2(eslint@9.39.2(jiti@2.6.1))': + '@earendil-works/pi-ai@0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': dependencies: - eslint: 9.39.2(jiti@2.6.1) + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1053.0 + '@google/genai': 1.42.0(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)) + '@mistralai/mistralai': 2.2.1 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.19.0)(zod@3.25.76) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod '@electron-toolkit/preload@3.0.2(electron@35.7.5)': dependencies: electron: 35.7.5 - '@electron-toolkit/tsconfig@1.0.1(@types/node@25.0.3)': + '@electron-toolkit/tsconfig@1.0.1(@types/node@25.2.2)': dependencies: - '@types/node': 25.0.3 + '@types/node': 25.2.2 '@electron-toolkit/utils@4.0.0(electron@35.7.5)': dependencies: @@ -4233,6 +6693,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@electron/get@3.1.0': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + '@electron/notarize@2.5.0': dependencies: debug: 4.4.3 @@ -4252,40 +6726,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/rebuild@4.0.1': - dependencies: - '@malept/cross-spawn-promise': 2.0.0 - chalk: 4.1.2 - debug: 4.4.3 - detect-libc: 2.1.2 - got: 11.8.6 - graceful-fs: 4.2.11 - node-abi: 4.24.0 - node-api-version: 0.2.1 - node-gyp: 11.5.0 - ora: 5.4.1 - read-binary-file-arch: 1.0.6 - semver: 7.7.3 - tar: 6.2.1 - yargs: 17.7.2 - transitivePeerDependencies: - - supports-color - - '@electron/rebuild@4.0.2': + '@electron/rebuild@4.0.4': dependencies: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 - detect-libc: 2.1.2 - got: 11.8.6 - graceful-fs: 4.2.11 - node-abi: 4.24.0 + node-abi: 4.26.0 node-api-version: 0.2.1 - node-gyp: 11.5.0 - ora: 5.4.1 + node-gyp: 12.3.0 read-binary-file-arch: 1.0.6 - semver: 7.7.3 - tar: 6.2.1 - yargs: 17.7.2 transitivePeerDependencies: - supports-color @@ -4312,84 +6760,247 @@ snapshots: - supports-color optional: true - '@esbuild/aix-ppc64@0.25.12': + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.21.5': optional: true - '@esbuild/android-arm64@0.25.12': + '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/android-arm@0.25.12': + '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/android-x64@0.25.12': + '@esbuild/freebsd-arm64@0.21.5': optional: true - '@esbuild/darwin-arm64@0.25.12': + '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/darwin-x64@0.25.12': + '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.25.12': + '@esbuild/freebsd-x64@0.21.5': optional: true '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -4436,26 +7047,109 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/core@1.7.3': + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + + '@fastify/busboy@3.2.0': {} + + '@fastify/deepmerge@3.2.1': {} + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.3.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/multipart@10.0.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.0 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 - '@floating-ui/dom@1.7.4': + '@floating-ui/dom@1.7.5': dependencies: - '@floating-ui/core': 1.7.3 + '@floating-ui/core': 1.7.4 '@floating-ui/utils': 0.2.10 '@floating-ui/utils@0.2.10': {} - '@floating-ui/vue@1.1.9(vue@3.5.26(typescript@5.9.3))': + '@floating-ui/vue@1.1.10(vue@3.5.27(typescript@5.9.3))': dependencies: - '@floating-ui/dom': 1.7.4 + '@floating-ui/dom': 1.7.5 '@floating-ui/utils': 0.2.10 - vue-demi: 0.14.10(vue@3.5.26(typescript@5.9.3)) + vue-demi: 0.14.10(vue@3.5.27(typescript@5.9.3)) transitivePeerDependencies: - '@vue/composition-api' - vue + '@google/genai@1.42.0(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))': + dependencies: + google-auth-library: 10.5.0 + p-retry: 4.6.2 + protobufjs: 7.5.4 + ws: 8.19.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@hono/node-server@1.19.14(hono@4.12.18)': + dependencies: + hono: 4.12.18 + + '@huggingface/jinja@0.5.9': {} + + '@huggingface/tokenizers@0.1.3': {} + + '@huggingface/transformers@4.2.0': + dependencies: + '@huggingface/jinja': 0.5.9 + '@huggingface/tokenizers': 0.1.3 + onnxruntime-node: 1.24.3 + onnxruntime-web: 1.26.0-dev.20260416-b7804b056c + sharp: 0.34.5 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -4467,7 +7161,11 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@iconify/collections@1.0.636': + '@iconify-json/simple-icons@1.2.83': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/collections@1.0.648': dependencies: '@iconify/types': 2.0.0 @@ -4479,32 +7177,114 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.0 - '@iconify/vue@5.0.0(vue@3.5.26(typescript@5.9.3))': + '@iconify/vue@5.0.0(vue@3.5.27(typescript@5.9.3))': dependencies: '@iconify/types': 2.0.0 - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true - '@internationalized/date@3.10.1': + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': dependencies: - '@swc/helpers': 0.5.18 + '@emnapi/runtime': 1.8.1 + optional: true - '@internationalized/number@3.6.5': + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@internationalized/date@3.11.0': dependencies: '@swc/helpers': 0.5.18 - '@intlify/bundle-utils@11.0.3(vue-i18n@11.2.8(vue@3.5.26(typescript@5.9.3)))': + '@internationalized/number@3.6.5': dependencies: - '@intlify/message-compiler': 11.2.8 - '@intlify/shared': 11.2.8 - acorn: 8.15.0 - esbuild: 0.25.12 - escodegen: 2.1.0 - estree-walker: 2.0.2 - jsonc-eslint-parser: 2.4.2 - source-map-js: 1.2.1 - yaml-eslint-parser: 1.3.2 - optionalDependencies: - vue-i18n: 11.2.8(vue@3.5.26(typescript@5.9.3)) + '@swc/helpers': 0.5.18 '@intlify/core-base@11.2.8': dependencies: @@ -4518,45 +7298,6 @@ snapshots: '@intlify/shared@11.2.8': {} - '@intlify/unplugin-vue-i18n@11.0.3(@vue/compiler-dom@3.5.26)(eslint@9.39.2(jiti@2.6.1))(rollup@4.55.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3))': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@intlify/bundle-utils': 11.0.3(vue-i18n@11.2.8(vue@3.5.26(typescript@5.9.3))) - '@intlify/shared': 11.2.8 - '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.26)(vue-i18n@11.2.8(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3)) - '@rollup/pluginutils': 5.3.0(rollup@4.55.1) - '@typescript-eslint/scope-manager': 8.52.0 - '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) - debug: 4.4.3 - fast-glob: 3.3.3 - pathe: 2.0.3 - picocolors: 1.1.1 - unplugin: 2.3.11 - vue: 3.5.26(typescript@5.9.3) - optionalDependencies: - vue-i18n: 11.2.8(vue@3.5.26(typescript@5.9.3)) - transitivePeerDependencies: - - '@vue/compiler-dom' - - eslint - - rollup - - supports-color - - typescript - - '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.26)(vue-i18n@11.2.8(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3))': - dependencies: - '@babel/parser': 7.28.5 - optionalDependencies: - '@intlify/shared': 11.2.8 - '@vue/compiler-dom': 3.5.26 - vue: 3.5.26(typescript@5.9.3) - vue-i18n: 11.2.8(vue@3.5.26(typescript@5.9.3)) - - '@isaacs/balanced-match@4.0.1': {} - - '@isaacs/brace-expansion@5.0.0': - dependencies: - '@isaacs/balanced-match': 4.0.1 - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4568,7 +7309,7 @@ snapshots: '@isaacs/fs-minipass@4.0.1': dependencies: - minipass: 7.1.2 + minipass: 7.1.3 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -4589,7 +7330,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@kurkle/color@0.3.4': {} + '@lukeed/ms@2.0.2': {} '@malept/cross-spawn-promise@2.0.0': dependencies: @@ -4599,11 +7340,112 @@ snapshots: dependencies: debug: 4.4.3 fs-extra: 9.1.0 - lodash: 4.17.21 + lodash: 4.17.23 tmp-promise: 3.0.3 transitivePeerDependencies: - supports-color + '@mistralai/mistralai@2.2.1': + dependencies: + ws: 8.19.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.18) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.1(express@5.2.1) + hono: 4.12.18 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodable/entities@2.1.0': {} + + '@node-rs/jieba-android-arm-eabi@2.0.1': + optional: true + + '@node-rs/jieba-android-arm64@2.0.1': + optional: true + + '@node-rs/jieba-darwin-arm64@2.0.1': + optional: true + + '@node-rs/jieba-darwin-x64@2.0.1': + optional: true + + '@node-rs/jieba-freebsd-x64@2.0.1': + optional: true + + '@node-rs/jieba-linux-arm-gnueabihf@2.0.1': + optional: true + + '@node-rs/jieba-linux-arm64-gnu@2.0.1': + optional: true + + '@node-rs/jieba-linux-arm64-musl@2.0.1': + optional: true + + '@node-rs/jieba-linux-x64-gnu@2.0.1': + optional: true + + '@node-rs/jieba-linux-x64-musl@2.0.1': + optional: true + + '@node-rs/jieba-wasm32-wasi@2.0.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/jieba-win32-arm64-msvc@2.0.1': + optional: true + + '@node-rs/jieba-win32-ia32-msvc@2.0.1': + optional: true + + '@node-rs/jieba-win32-x64-msvc@2.0.1': + optional: true + + '@node-rs/jieba@2.0.1': + optionalDependencies: + '@node-rs/jieba-android-arm-eabi': 2.0.1 + '@node-rs/jieba-android-arm64': 2.0.1 + '@node-rs/jieba-darwin-arm64': 2.0.1 + '@node-rs/jieba-darwin-x64': 2.0.1 + '@node-rs/jieba-freebsd-x64': 2.0.1 + '@node-rs/jieba-linux-arm-gnueabihf': 2.0.1 + '@node-rs/jieba-linux-arm64-gnu': 2.0.1 + '@node-rs/jieba-linux-arm64-musl': 2.0.1 + '@node-rs/jieba-linux-x64-gnu': 2.0.1 + '@node-rs/jieba-linux-x64-musl': 2.0.1 + '@node-rs/jieba-wasm32-wasi': 2.0.1 + '@node-rs/jieba-win32-arm64-msvc': 2.0.1 + '@node-rs/jieba-win32-ia32-msvc': 2.0.1 + '@node-rs/jieba-win32-x64-msvc': 2.0.1 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4616,39 +7458,25 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@npmcli/agent@3.0.0': - dependencies: - agent-base: 7.1.4 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 10.4.3 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - '@npmcli/fs@4.0.0': - dependencies: - semver: 7.7.3 - - '@nuxt/devtools-kit@3.1.1(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))': + '@nuxt/devtools-kit@3.1.1(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@nuxt/kit': 4.2.2 + '@nuxt/kit': 4.3.1 execa: 8.0.1 - vite: 6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + vite: 7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - magicast - '@nuxt/fonts@0.12.1(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))': + '@nuxt/fonts@0.12.1(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@nuxt/devtools-kit': 3.1.1(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) - '@nuxt/kit': 4.2.2 + '@nuxt/devtools-kit': 3.1.1(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/kit': 4.3.1 consola: 3.4.2 css-tree: 3.1.0 defu: 6.1.4 esbuild: 0.25.12 fontaine: 0.7.0 - fontless: 0.1.0(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) - h3: 1.15.4 + fontless: 0.1.0(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + h3: 1.15.5 jiti: 2.6.1 magic-regexp: 0.10.0 magic-string: 0.30.21 @@ -4657,10 +7485,10 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.15 - ufo: 1.6.2 + ufo: 1.6.3 unifont: 0.6.0 unplugin: 2.3.11 - unstorage: 1.17.3 + unstorage: 1.17.4 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -4684,14 +7512,14 @@ snapshots: - uploadthing - vite - '@nuxt/icon@2.2.0(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))': + '@nuxt/icon@2.2.1(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': dependencies: - '@iconify/collections': 1.0.636 + '@iconify/collections': 1.0.648 '@iconify/types': 2.0.0 '@iconify/utils': 3.1.0 - '@iconify/vue': 5.0.0(vue@3.5.26(typescript@5.9.3)) - '@nuxt/devtools-kit': 3.1.1(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) - '@nuxt/kit': 4.2.2 + '@iconify/vue': 5.0.0(vue@3.5.27(typescript@5.9.3)) + '@nuxt/devtools-kit': 3.1.1(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/kit': 4.3.1 consola: 3.4.2 local-pkg: 1.1.2 mlly: 1.8.0 @@ -4705,7 +7533,7 @@ snapshots: - vite - vue - '@nuxt/kit@3.20.2': + '@nuxt/kit@3.21.1': dependencies: c12: 3.3.3 consola: 3.4.2 @@ -4721,17 +7549,17 @@ snapshots: ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.0 - rc9: 2.1.2 + rc9: 3.0.0 scule: 1.3.0 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 - ufo: 1.6.2 + ufo: 1.6.3 unctx: 2.5.0 untyped: 2.0.0 transitivePeerDependencies: - magicast - '@nuxt/kit@4.2.2': + '@nuxt/kit@4.3.1': dependencies: c12: 3.3.3 consola: 3.4.2 @@ -4746,55 +7574,61 @@ snapshots: ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.0 - rc9: 2.1.2 + rc9: 3.0.0 scule: 1.3.0 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 - ufo: 1.6.2 + ufo: 1.6.3 unctx: 2.5.0 untyped: 2.0.0 transitivePeerDependencies: - magicast - '@nuxt/schema@4.2.2': + '@nuxt/schema@4.3.1': dependencies: - '@vue/shared': 3.5.26 + '@vue/shared': 3.5.27 defu: 6.1.4 pathe: 2.0.3 pkg-types: 2.3.0 std-env: 3.10.0 - '@nuxt/ui@4.3.0(@babel/parser@7.28.5)(@floating-ui/dom@1.7.4)(@tiptap/extension-drag-handle@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/extension-collaboration@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)))(@tiptap/extensions@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))(axios@1.13.2)(embla-carousel@8.6.0)(typescript@5.9.3)(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3))': + '@nuxt/ui@4.4.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(axios@1.13.5)(embla-carousel@8.6.0)(focus-trap@7.8.0)(tailwindcss@4.1.18)(typescript@5.9.3)(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))(yjs@13.6.29)(zod@3.25.76)': dependencies: - '@iconify/vue': 5.0.0(vue@3.5.26(typescript@5.9.3)) - '@internationalized/date': 3.10.1 + '@floating-ui/dom': 1.7.5 + '@iconify/vue': 5.0.0(vue@3.5.27(typescript@5.9.3)) + '@internationalized/date': 3.11.0 '@internationalized/number': 3.6.5 - '@nuxt/fonts': 0.12.1(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) - '@nuxt/icon': 2.2.0(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) - '@nuxt/kit': 4.2.2 - '@nuxt/schema': 4.2.2 + '@nuxt/fonts': 0.12.1(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@nuxt/icon': 2.2.1(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) + '@nuxt/kit': 4.3.1 + '@nuxt/schema': 4.3.1 '@nuxtjs/color-mode': 3.5.2 '@standard-schema/spec': 1.1.0 '@tailwindcss/postcss': 4.1.18 - '@tailwindcss/vite': 4.1.18(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) - '@tanstack/vue-table': 8.21.3(vue@3.5.26(typescript@5.9.3)) - '@tanstack/vue-virtual': 3.13.17(vue@3.5.26(typescript@5.9.3)) - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-bubble-menu': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-drag-handle-vue-3': 3.13.0(@tiptap/extension-drag-handle@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/extension-collaboration@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)))(@tiptap/pm@3.13.0)(@tiptap/vue-3@3.13.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3)) - '@tiptap/extension-floating-menu': 3.13.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-horizontal-rule': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-image': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-mention': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/suggestion@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) - '@tiptap/extension-placeholder': 3.13.0(@tiptap/extensions@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) - '@tiptap/markdown': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 - '@tiptap/starter-kit': 3.13.0 - '@tiptap/suggestion': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/vue-3': 3.13.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(vue@3.5.26(typescript@5.9.3)) - '@unhead/vue': 2.1.1(vue@3.5.26(typescript@5.9.3)) - '@vueuse/core': 14.1.0(vue@3.5.26(typescript@5.9.3)) - '@vueuse/integrations': 14.1.0(axios@1.13.2)(fuse.js@7.1.0)(vue@3.5.26(typescript@5.9.3)) + '@tailwindcss/vite': 4.1.18(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)) + '@tanstack/vue-table': 8.21.3(vue@3.5.27(typescript@5.9.3)) + '@tanstack/vue-virtual': 3.13.18(vue@3.5.27(typescript@5.9.3)) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-bubble-menu': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-collaboration': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) + '@tiptap/extension-drag-handle': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)) + '@tiptap/extension-drag-handle-vue-3': 3.19.0(@tiptap/extension-drag-handle@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)))(@tiptap/pm@3.19.0)(@tiptap/vue-3@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) + '@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-image': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-mention': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-node-range': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-placeholder': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/markdown': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/starter-kit': 3.19.0 + '@tiptap/suggestion': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/vue-3': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(vue@3.5.27(typescript@5.9.3)) + '@unhead/vue': 2.1.4(vue@3.5.27(typescript@5.9.3)) + '@vueuse/core': 14.2.0(vue@3.5.27(typescript@5.9.3)) + '@vueuse/integrations': 14.2.0(axios@1.13.5)(focus-trap@7.8.0)(fuse.js@7.1.0)(vue@3.5.27(typescript@5.9.3)) + '@vueuse/shared': 14.2.0(vue@3.5.27(typescript@5.9.3)) colortranslator: 5.0.0 consola: 3.4.2 defu: 6.1.4 @@ -4803,30 +7637,32 @@ snapshots: embla-carousel-autoplay: 8.6.0(embla-carousel@8.6.0) embla-carousel-class-names: 8.6.0(embla-carousel@8.6.0) embla-carousel-fade: 8.6.0(embla-carousel@8.6.0) - embla-carousel-vue: 8.6.0(vue@3.5.26(typescript@5.9.3)) + embla-carousel-vue: 8.6.0(vue@3.5.27(typescript@5.9.3)) embla-carousel-wheel-gestures: 8.1.0(embla-carousel@8.6.0) fuse.js: 7.1.0 hookable: 5.5.3 knitwork: 1.3.0 magic-string: 0.30.21 mlly: 1.8.0 - motion-v: 1.7.6(@vueuse/core@14.1.0(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3)) + motion-v: 1.10.2(@vueuse/core@14.2.0(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) ohash: 2.0.11 pathe: 2.0.3 - reka-ui: 2.6.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)) + reka-ui: 2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)) scule: 1.3.0 tailwind-merge: 3.4.0 tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) tailwindcss: 4.1.18 tinyglobby: 0.2.15 typescript: 5.9.3 + ufo: 1.6.3 unplugin: 2.3.11 - unplugin-auto-import: 20.3.0(@nuxt/kit@4.2.2)(@vueuse/core@14.1.0(vue@3.5.26(typescript@5.9.3))) - unplugin-vue-components: 30.0.0(@babel/parser@7.28.5)(@nuxt/kit@4.2.2)(vue@3.5.26(typescript@5.9.3)) - vaul-vue: 0.4.1(reka-ui@2.6.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3)) - vue-component-type-helpers: 3.2.2 + unplugin-auto-import: 21.0.0(@nuxt/kit@4.3.1)(@vueuse/core@14.2.0(vue@3.5.27(typescript@5.9.3))) + unplugin-vue-components: 31.0.0(@nuxt/kit@4.3.1)(vue@3.5.27(typescript@5.9.3)) + vaul-vue: 0.4.1(reka-ui@2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) + vue-component-type-helpers: 3.2.4 optionalDependencies: - vue-router: 4.6.4(vue@3.5.26(typescript@5.9.3)) + vue-router: 4.6.4(vue@3.5.27(typescript@5.9.3)) + zod: 3.25.76 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -4834,15 +7670,13 @@ snapshots: - '@azure/identity' - '@azure/keyvault-secrets' - '@azure/storage-blob' - - '@babel/parser' - '@capacitor/preferences' - '@deno/kv' - '@emotion/is-prop-valid' - - '@floating-ui/dom' - '@netlify/blobs' - '@planetscale/database' - - '@tiptap/extension-drag-handle' - '@tiptap/extensions' + - '@tiptap/y-tiptap' - '@upstash/redis' - '@vercel/blob' - '@vercel/functions' @@ -4865,21 +7699,25 @@ snapshots: - react - react-dom - sortablejs - - supports-color - universal-cookie - uploadthing - vite - vue + - yjs '@nuxtjs/color-mode@3.5.2': dependencies: - '@nuxt/kit': 3.20.2 + '@nuxt/kit': 3.21.1 pathe: 1.1.2 pkg-types: 1.3.1 - semver: 7.7.3 + semver: 7.7.4 transitivePeerDependencies: - magicast + '@pinojs/redact@0.4.0': {} + + '@pixi/colord@2.9.6': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4887,95 +7725,198 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@remirror/core-constants@3.0.0': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} - '@rollup/pluginutils@5.3.0(rollup@4.55.1)': + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.3 - optionalDependencies: - rollup: 4.55.1 + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@remirror/core-constants@3.0.0': {} + + '@rolldown/pluginutils@1.0.1': {} - '@rollup/rollup-android-arm-eabi@4.55.1': + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true - '@rollup/rollup-android-arm64@4.55.1': + '@rollup/rollup-android-arm64@4.57.1': optional: true - '@rollup/rollup-darwin-arm64@4.55.1': + '@rollup/rollup-darwin-arm64@4.57.1': optional: true - '@rollup/rollup-darwin-x64@4.55.1': + '@rollup/rollup-darwin-x64@4.57.1': optional: true - '@rollup/rollup-freebsd-arm64@4.55.1': + '@rollup/rollup-freebsd-arm64@4.57.1': optional: true - '@rollup/rollup-freebsd-x64@4.55.1': + '@rollup/rollup-freebsd-x64@4.57.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.55.1': + '@rollup/rollup-linux-arm-musleabihf@4.57.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.55.1': + '@rollup/rollup-linux-arm64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.55.1': + '@rollup/rollup-linux-arm64-musl@4.57.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.55.1': + '@rollup/rollup-linux-loong64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-loong64-musl@4.55.1': + '@rollup/rollup-linux-loong64-musl@4.57.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.55.1': + '@rollup/rollup-linux-ppc64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-ppc64-musl@4.55.1': + '@rollup/rollup-linux-ppc64-musl@4.57.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.55.1': + '@rollup/rollup-linux-riscv64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.55.1': + '@rollup/rollup-linux-riscv64-musl@4.57.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.55.1': + '@rollup/rollup-linux-s390x-gnu@4.57.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.55.1': + '@rollup/rollup-linux-x64-gnu@4.57.1': optional: true - '@rollup/rollup-linux-x64-musl@4.55.1': + '@rollup/rollup-linux-x64-musl@4.57.1': optional: true - '@rollup/rollup-openbsd-x64@4.55.1': + '@rollup/rollup-openbsd-x64@4.57.1': optional: true - '@rollup/rollup-openharmony-arm64@4.55.1': + '@rollup/rollup-openharmony-arm64@4.57.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.55.1': + '@rollup/rollup-win32-arm64-msvc@4.57.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.55.1': + '@rollup/rollup-win32-ia32-msvc@4.57.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.55.1': + '@rollup/rollup-win32-x64-gnu@4.57.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.55.1': + '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true - '@rushstack/eslint-patch@1.15.0': {} + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} '@sindresorhus/is@4.6.0': {} + '@smithy/core@3.24.4': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/types@4.14.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.18': @@ -4989,7 +7930,7 @@ snapshots: '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.4 + enhanced-resolve: 5.19.0 jiti: 2.6.1 lightningcss: 1.30.2 magic-string: 0.30.21 @@ -5055,186 +7996,186 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 - '@tailwindcss/vite@4.1.18(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + vite: 7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) '@tanstack/table-core@8.21.3': {} - '@tanstack/virtual-core@3.13.17': {} + '@tanstack/virtual-core@3.13.18': {} - '@tanstack/vue-table@8.21.3(vue@3.5.26(typescript@5.9.3))': + '@tanstack/vue-table@8.21.3(vue@3.5.27(typescript@5.9.3))': dependencies: '@tanstack/table-core': 8.21.3 - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) - '@tanstack/vue-virtual@3.13.17(vue@3.5.26(typescript@5.9.3))': + '@tanstack/vue-virtual@3.13.18(vue@3.5.27(typescript@5.9.3))': dependencies: - '@tanstack/virtual-core': 3.13.17 - vue: 3.5.26(typescript@5.9.3) + '@tanstack/virtual-core': 3.13.18 + vue: 3.5.27(typescript@5.9.3) - '@tiptap/core@3.13.0(@tiptap/pm@3.13.0)': + '@tiptap/core@3.19.0(@tiptap/pm@3.19.0)': dependencies: - '@tiptap/pm': 3.13.0 + '@tiptap/pm': 3.19.0 - '@tiptap/extension-blockquote@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-blockquote@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-bold@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-bold@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-bubble-menu@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': + '@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: - '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 + '@floating-ui/dom': 1.7.5 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 - '@tiptap/extension-bullet-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': + '@tiptap/extension-bullet-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) - '@tiptap/extension-code-block@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': + '@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 - '@tiptap/extension-code@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-code@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-collaboration@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': + '@tiptap/extension-collaboration@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 - '@tiptap/y-tiptap': 3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29) yjs: 13.6.29 - '@tiptap/extension-document@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-document@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-drag-handle-vue-3@3.13.0(@tiptap/extension-drag-handle@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/extension-collaboration@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)))(@tiptap/pm@3.13.0)(@tiptap/vue-3@3.13.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3))': + '@tiptap/extension-drag-handle-vue-3@3.19.0(@tiptap/extension-drag-handle@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)))(@tiptap/pm@3.19.0)(@tiptap/vue-3@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': dependencies: - '@tiptap/extension-drag-handle': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/extension-collaboration@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)) - '@tiptap/pm': 3.13.0 - '@tiptap/vue-3': 3.13.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(vue@3.5.26(typescript@5.9.3)) - vue: 3.5.26(typescript@5.9.3) + '@tiptap/extension-drag-handle': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)) + '@tiptap/pm': 3.19.0 + '@tiptap/vue-3': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(vue@3.5.27(typescript@5.9.3)) + vue: 3.5.27(typescript@5.9.3) - '@tiptap/extension-drag-handle@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/extension-collaboration@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))': + '@tiptap/extension-drag-handle@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))': dependencies: - '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-collaboration': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) - '@tiptap/extension-node-range': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 - '@tiptap/y-tiptap': 3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29) + '@floating-ui/dom': 1.7.5 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-collaboration': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) + '@tiptap/extension-node-range': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29) - '@tiptap/extension-dropcursor@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': + '@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/extensions': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) - '@tiptap/extension-floating-menu@3.13.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: - '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 + '@floating-ui/dom': 1.7.5 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 - '@tiptap/extension-gapcursor@3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': + '@tiptap/extension-gapcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/extensions': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) - '@tiptap/extension-hard-break@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-heading@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-heading@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-horizontal-rule@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': + '@tiptap/extension-horizontal-rule@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 - '@tiptap/extension-image@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-image@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-italic@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-italic@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-link@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': + '@tiptap/extension-link@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 linkifyjs: 4.3.2 - '@tiptap/extension-list-item@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': + '@tiptap/extension-list-item@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) - '@tiptap/extension-list-keymap@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': + '@tiptap/extension-list-keymap@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) - '@tiptap/extension-list@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': + '@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 - '@tiptap/extension-mention@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(@tiptap/suggestion@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': + '@tiptap/extension-mention@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 - '@tiptap/suggestion': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/suggestion': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) - '@tiptap/extension-node-range@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': + '@tiptap/extension-node-range@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 - '@tiptap/extension-ordered-list@3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': + '@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) - '@tiptap/extension-paragraph@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-placeholder@3.13.0(@tiptap/extensions@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0))': + '@tiptap/extension-placeholder@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/extensions': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) - '@tiptap/extension-strike@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-strike@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-text@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-text@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-underline@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))': + '@tiptap/extension-underline@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extensions@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': + '@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 - '@tiptap/markdown@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': + '@tiptap/markdown@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 - marked: 15.0.12 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + marked: 17.0.1 - '@tiptap/pm@3.13.0': + '@tiptap/pm@3.19.0': dependencies: prosemirror-changeset: 2.3.1 prosemirror-collab: 1.3.1 @@ -5244,96 +8185,107 @@ snapshots: prosemirror-history: 1.5.0 prosemirror-inputrules: 1.5.1 prosemirror-keymap: 1.2.3 - prosemirror-markdown: 1.13.2 + prosemirror-markdown: 1.13.4 prosemirror-menu: 1.2.5 prosemirror-model: 1.25.4 prosemirror-schema-basic: 1.2.4 prosemirror-schema-list: 1.5.1 prosemirror-state: 1.4.4 prosemirror-tables: 1.8.5 - prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4) - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 - - '@tiptap/starter-kit@3.13.0': - dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/extension-blockquote': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-bold': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-bullet-list': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) - '@tiptap/extension-code': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-code-block': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-document': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-dropcursor': 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) - '@tiptap/extension-gapcursor': 3.15.3(@tiptap/extensions@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) - '@tiptap/extension-hard-break': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-heading': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-horizontal-rule': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-italic': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-link': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-list': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-list-item': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) - '@tiptap/extension-list-keymap': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) - '@tiptap/extension-ordered-list': 3.15.3(@tiptap/extension-list@3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)) - '@tiptap/extension-paragraph': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-strike': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-text': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extension-underline': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0)) - '@tiptap/extensions': 3.15.3(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 - - '@tiptap/suggestion@3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)': - dependencies: - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 - - '@tiptap/vue-3@3.13.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0)(vue@3.5.26(typescript@5.9.3))': - dependencies: - '@floating-ui/dom': 1.7.4 - '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) - '@tiptap/pm': 3.13.0 - vue: 3.5.26(typescript@5.9.3) + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6) + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 + + '@tiptap/starter-kit@3.19.0': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-blockquote': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-bold': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-bullet-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-code': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-code-block': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-document': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-dropcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-gapcursor': 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-hard-break': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-heading': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-horizontal-rule': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-italic': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-link': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-list-item': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-list-keymap': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-ordered-list': 3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-strike': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-text': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-underline': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + + '@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + + '@tiptap/vue-3@3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(vue@3.5.27(typescript@5.9.3))': + dependencies: + '@floating-ui/dom': 1.7.5 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + vue: 3.5.27(typescript@5.9.3) optionalDependencies: - '@tiptap/extension-bubble-menu': 3.13.0(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) - '@tiptap/extension-floating-menu': 3.13.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.13.0(@tiptap/pm@3.13.0))(@tiptap/pm@3.13.0) + '@tiptap/extension-bubble-menu': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.5)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) - '@tiptap/y-tiptap@3.0.1(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)': + '@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)': dependencies: lib0: 0.2.117 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.4 + prosemirror-view: 1.41.6 y-protocols: 1.0.7(yjs@13.6.29) yjs: 13.6.29 + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 25.0.3 + '@types/node': 25.2.2 '@types/cacheable-request@6.0.3': dependencies: - '@types/http-cache-semantics': 4.0.4 + '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 25.0.3 + '@types/node': 25.2.2 '@types/responselike': 1.0.3 '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/earcut@3.0.0': {} + '@types/estree@1.0.8': {} '@types/fs-extra@9.0.13': dependencies: - '@types/node': 25.0.3 + '@types/node': 25.2.2 - '@types/http-cache-semantics@4.0.4': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/http-cache-semantics@4.2.0': {} '@types/json-schema@7.0.15': {} '@types/keyv@3.1.4': dependencies: - '@types/node': 25.0.3 + '@types/node': 25.2.2 '@types/linkify-it@5.0.0': {} @@ -5342,37 +8294,45 @@ snapshots: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} '@types/ms@2.1.0': {} - '@types/node@22.19.3': + '@types/node@22.19.10': dependencies: undici-types: 6.21.0 - '@types/node@25.0.3': + '@types/node@25.2.2': dependencies: undici-types: 7.16.0 '@types/plist@3.0.5': dependencies: - '@types/node': 25.0.3 + '@types/node': 25.2.2 xmlbuilder: 15.1.1 optional: true '@types/responselike@1.0.3': dependencies: - '@types/node': 25.0.3 + '@types/node': 25.2.2 + + '@types/retry@0.12.0': {} '@types/stream-chain@2.1.0': dependencies: - '@types/node': 25.0.3 + '@types/node': 25.2.2 '@types/stream-json@1.7.8': dependencies: - '@types/node': 25.0.3 + '@types/node': 25.2.2 '@types/stream-chain': 2.1.0 + '@types/unist@3.0.3': {} + '@types/verror@1.10.11': optional: true @@ -5382,170 +8342,164 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 22.19.3 + '@types/node': 25.2.2 optional: true - '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@types/yauzl@3.4.0': + dependencies: + '@types/node': 25.2.2 + + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/type-utils': 7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 7.18.0 + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 eslint: 9.39.2(jiti@2.6.1) - graphemer: 1.4.0 - ignore: 5.3.2 + ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 7.18.0 + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) - optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.52.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) - '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@7.18.0': - dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - - '@typescript-eslint/scope-manager@8.52.0': + '@typescript-eslint/scope-manager@8.55.0': dependencies: - '@typescript-eslint/types': 8.52.0 - '@typescript-eslint/visitor-keys': 8.52.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 - '@typescript-eslint/tsconfig-utils@8.52.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) - '@typescript-eslint/utils': 7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@7.18.0': {} - - '@typescript-eslint/types@8.52.0': {} - - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.3 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.3 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color + '@typescript-eslint/types@8.55.0': {} - '@typescript-eslint/typescript-estree@8.52.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.52.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) - '@typescript-eslint/types': 8.52.0 - '@typescript-eslint/visitor-keys': 8.52.0 + '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.18.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - - typescript - '@typescript-eslint/visitor-keys@7.18.0': + '@typescript-eslint/visitor-keys@8.55.0': dependencies: - '@typescript-eslint/types': 7.18.0 - eslint-visitor-keys: 3.4.3 + '@typescript-eslint/types': 8.55.0 + eslint-visitor-keys: 4.2.1 + + '@ungap/structured-clone@1.3.1': {} - '@typescript-eslint/visitor-keys@8.52.0': + '@unhead/vue@2.1.4(vue@3.5.27(typescript@5.9.3))': dependencies: - '@typescript-eslint/types': 8.52.0 - eslint-visitor-keys: 4.2.1 + hookable: 6.0.1 + unhead: 2.1.4 + vue: 3.5.27(typescript@5.9.3) - '@unhead/vue@2.1.1(vue@3.5.26(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.31.1))(vue@3.5.27(typescript@5.9.3))': dependencies: - hookable: 5.5.3 - unhead: 2.1.1 - vue: 3.5.26(typescript@5.9.3) + vite: 5.4.21(@types/node@25.2.2)(lightningcss@1.31.1) + vue: 3.5.27(typescript@5.9.3) + + '@vitejs/plugin-vue@6.0.7(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) + vue: 3.5.27(typescript@5.9.3) - '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))': + '@volar/language-core@2.4.28': dependencies: - vite: 6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) - vue: 3.5.26(typescript@5.9.3) + '@volar/source-map': 2.4.28 - '@vue/compiler-core@3.5.26': + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.27': dependencies: - '@babel/parser': 7.28.5 - '@vue/shared': 3.5.26 - entities: 7.0.0 + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.27 + entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.26': + '@vue/compiler-dom@3.5.27': dependencies: - '@vue/compiler-core': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/compiler-core': 3.5.27 + '@vue/shared': 3.5.27 - '@vue/compiler-sfc@3.5.26': + '@vue/compiler-sfc@3.5.27': dependencies: - '@babel/parser': 7.28.5 - '@vue/compiler-core': 3.5.26 - '@vue/compiler-dom': 3.5.26 - '@vue/compiler-ssr': 3.5.26 - '@vue/shared': 3.5.26 + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.5.27 + '@vue/compiler-dom': 3.5.27 + '@vue/compiler-ssr': 3.5.27 + '@vue/shared': 3.5.27 estree-walker: 2.0.2 magic-string: 0.30.21 postcss: 8.5.6 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.26': + '@vue/compiler-ssr@3.5.27': dependencies: - '@vue/compiler-dom': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/compiler-dom': 3.5.27 + '@vue/shared': 3.5.27 '@vue/devtools-api@6.6.4': {} @@ -5567,45 +8521,68 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/eslint-config-prettier@10.2.0(eslint@9.39.2(jiti@2.6.1))(prettier@3.7.4)': + '@vue/eslint-config-prettier@10.2.0(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1)': dependencies: eslint: 9.39.2(jiti@2.6.1) eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-prettier: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.7.4) - prettier: 3.7.4 + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) + prettier: 3.8.1 transitivePeerDependencies: - '@types/eslint' - '@vue/reactivity@3.5.26': + '@vue/eslint-config-typescript@14.6.0(eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + eslint-plugin-vue: 9.33.0(eslint@9.39.2(jiti@2.6.1)) + fast-glob: 3.3.3 + typescript-eslint: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@2.6.1)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@3.2.6': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.27 + '@vue/shared': 3.5.27 + alien-signals: 3.1.2 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.3 + + '@vue/reactivity@3.5.27': dependencies: - '@vue/shared': 3.5.26 + '@vue/shared': 3.5.27 - '@vue/runtime-core@3.5.26': + '@vue/runtime-core@3.5.27': dependencies: - '@vue/reactivity': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/reactivity': 3.5.27 + '@vue/shared': 3.5.27 - '@vue/runtime-dom@3.5.26': + '@vue/runtime-dom@3.5.27': dependencies: - '@vue/reactivity': 3.5.26 - '@vue/runtime-core': 3.5.26 - '@vue/shared': 3.5.26 + '@vue/reactivity': 3.5.27 + '@vue/runtime-core': 3.5.27 + '@vue/shared': 3.5.27 csstype: 3.2.3 - '@vue/server-renderer@3.5.26(vue@3.5.26(typescript@5.9.3))': + '@vue/server-renderer@3.5.27(vue@3.5.27(typescript@5.9.3))': dependencies: - '@vue/compiler-ssr': 3.5.26 - '@vue/shared': 3.5.26 - vue: 3.5.26(typescript@5.9.3) + '@vue/compiler-ssr': 3.5.27 + '@vue/shared': 3.5.27 + vue: 3.5.27(typescript@5.9.3) - '@vue/shared@3.5.26': {} + '@vue/shared@3.5.27': {} - '@vueuse/core@10.11.1(vue@3.5.26(typescript@5.9.3))': + '@vueuse/core@10.11.1(vue@3.5.27(typescript@5.9.3))': dependencies: '@types/web-bluetooth': 0.0.20 '@vueuse/metadata': 10.11.1 - '@vueuse/shared': 10.11.1(vue@3.5.26(typescript@5.9.3)) - vue-demi: 0.14.10(vue@3.5.26(typescript@5.9.3)) + '@vueuse/shared': 10.11.1(vue@3.5.27(typescript@5.9.3)) + vue-demi: 0.14.10(vue@3.5.27(typescript@5.9.3)) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -5615,31 +8592,44 @@ snapshots: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 12.8.2 '@vueuse/shared': 12.8.2(typescript@5.9.3) - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - typescript - '@vueuse/core@13.9.0(vue@3.5.26(typescript@5.9.3))': + '@vueuse/core@13.9.0(vue@3.5.27(typescript@5.9.3))': dependencies: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 13.9.0 - '@vueuse/shared': 13.9.0(vue@3.5.26(typescript@5.9.3)) - vue: 3.5.26(typescript@5.9.3) + '@vueuse/shared': 13.9.0(vue@3.5.27(typescript@5.9.3)) + vue: 3.5.27(typescript@5.9.3) - '@vueuse/core@14.1.0(vue@3.5.26(typescript@5.9.3))': + '@vueuse/core@14.2.0(vue@3.5.27(typescript@5.9.3))': dependencies: '@types/web-bluetooth': 0.0.21 - '@vueuse/metadata': 14.1.0 - '@vueuse/shared': 14.1.0(vue@3.5.26(typescript@5.9.3)) - vue: 3.5.26(typescript@5.9.3) + '@vueuse/metadata': 14.2.0 + '@vueuse/shared': 14.2.0(vue@3.5.27(typescript@5.9.3)) + vue: 3.5.27(typescript@5.9.3) + + '@vueuse/integrations@12.8.2(axios@1.13.5)(focus-trap@7.8.0)(fuse.js@7.1.0)(typescript@5.9.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) + optionalDependencies: + axios: 1.13.5 + focus-trap: 7.8.0 + fuse.js: 7.1.0 + transitivePeerDependencies: + - typescript - '@vueuse/integrations@14.1.0(axios@1.13.2)(fuse.js@7.1.0)(vue@3.5.26(typescript@5.9.3))': + '@vueuse/integrations@14.2.0(axios@1.13.5)(focus-trap@7.8.0)(fuse.js@7.1.0)(vue@3.5.27(typescript@5.9.3))': dependencies: - '@vueuse/core': 14.1.0(vue@3.5.26(typescript@5.9.3)) - '@vueuse/shared': 14.1.0(vue@3.5.26(typescript@5.9.3)) - vue: 3.5.26(typescript@5.9.3) + '@vueuse/core': 14.2.0(vue@3.5.27(typescript@5.9.3)) + '@vueuse/shared': 14.2.0(vue@3.5.27(typescript@5.9.3)) + vue: 3.5.27(typescript@5.9.3) optionalDependencies: - axios: 1.13.2 + axios: 1.13.5 + focus-trap: 7.8.0 fuse.js: 7.1.0 '@vueuse/metadata@10.11.1': {} @@ -5648,34 +8638,45 @@ snapshots: '@vueuse/metadata@13.9.0': {} - '@vueuse/metadata@14.1.0': {} + '@vueuse/metadata@14.2.0': {} - '@vueuse/shared@10.11.1(vue@3.5.26(typescript@5.9.3))': + '@vueuse/shared@10.11.1(vue@3.5.27(typescript@5.9.3))': dependencies: - vue-demi: 0.14.10(vue@3.5.26(typescript@5.9.3)) + vue-demi: 0.14.10(vue@3.5.27(typescript@5.9.3)) transitivePeerDependencies: - '@vue/composition-api' - vue '@vueuse/shared@12.8.2(typescript@5.9.3)': dependencies: - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - typescript - '@vueuse/shared@13.9.0(vue@3.5.26(typescript@5.9.3))': + '@vueuse/shared@13.9.0(vue@3.5.27(typescript@5.9.3))': dependencies: - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) - '@vueuse/shared@14.1.0(vue@3.5.26(typescript@5.9.3))': + '@vueuse/shared@14.2.0(vue@3.5.27(typescript@5.9.3))': dependencies: - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) + + '@webgpu/types@0.1.71': {} '@xmldom/xmldom@0.8.11': {} - '@zumer/snapdom@2.0.1': {} + '@xmldom/xmldom@0.8.13': {} + + '@zumer/snapdom@2.0.2': {} - abbrev@3.0.1: {} + abbrev@4.0.0: {} + + abstract-logging@2.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 acorn-jsx@5.3.2(acorn@8.15.0): dependencies: @@ -5683,8 +8684,14 @@ snapshots: acorn@8.15.0: {} + adm-zip@0.5.17: {} + agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv-keywords@3.5.2(ajv@6.12.6): dependencies: ajv: 6.12.6 @@ -5696,6 +8703,32 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + algoliasearch@5.52.1: + dependencies: + '@algolia/abtesting': 1.18.1 + '@algolia/client-abtesting': 5.52.1 + '@algolia/client-analytics': 5.52.1 + '@algolia/client-common': 5.52.1 + '@algolia/client-insights': 5.52.1 + '@algolia/client-personalization': 5.52.1 + '@algolia/client-query-suggestions': 5.52.1 + '@algolia/client-search': 5.52.1 + '@algolia/ingestion': 1.52.1 + '@algolia/monitoring': 1.52.1 + '@algolia/recommend': 5.52.1 + '@algolia/requester-browser-xhr': 5.52.1 + '@algolia/requester-fetch': 5.52.1 + '@algolia/requester-node-http': 5.52.1 + + alien-signals@3.1.2: {} + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -5706,6 +8739,8 @@ snapshots: ansi-styles@6.2.3: {} + any-promise@1.3.0: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -5713,29 +8748,30 @@ snapshots: app-builder-bin@5.0.0-alpha.12: {} - app-builder-lib@26.4.0(dmg-builder@26.4.0)(electron-builder-squirrel-windows@26.4.0): + app-builder-lib@26.7.0(dmg-builder@26.7.0)(electron-builder-squirrel-windows@26.7.0): dependencies: '@develar/schema-utils': 2.6.5 '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 + '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 '@electron/osx-sign': 1.3.3 - '@electron/rebuild': 4.0.1 + '@electron/rebuild': 4.0.4 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 '@types/fs-extra': 9.0.13 async-exit-hook: 2.0.1 - builder-util: 26.3.4 + builder-util: 26.4.1 builder-util-runtime: 9.5.1 chromium-pickle-js: 0.2.0 ci-info: 4.3.1 debug: 4.4.3 - dmg-builder: 26.4.0(electron-builder-squirrel-windows@26.4.0) + dmg-builder: 26.7.0(electron-builder-squirrel-windows@26.7.0) dotenv: 16.6.1 dotenv-expand: 11.0.7 ejs: 3.1.10 - electron-builder-squirrel-windows: 26.4.0(dmg-builder@26.4.0) - electron-publish: 26.3.4 + electron-builder-squirrel-windows: 26.7.0(dmg-builder@26.7.0) + electron-publish: 26.6.0 fs-extra: 10.1.0 hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 @@ -5743,25 +8779,28 @@ snapshots: js-yaml: 4.1.1 json5: 2.2.3 lazy-val: 1.0.5 - minimatch: 10.1.1 + minimatch: 10.2.5 plist: 3.1.0 + proper-lockfile: 4.1.2 resedit: 1.7.2 - semver: 7.7.3 - tar: 6.2.1 + semver: 7.7.4 + tar: 7.5.7 temp-file: 3.4.0 tiny-async-pool: 1.3.0 which: 5.0.0 transitivePeerDependencies: - supports-color + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: dependencies: tslib: 2.8.1 - array-union@2.1.0: {} - assert-plus@1.0.0: optional: true @@ -5776,25 +8815,37 @@ snapshots: at-least-node@1.0.0: {} - axios@1.13.2: + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axios@1.13.5: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug + optional: true balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} - baseline-browser-mapping@2.9.12: {} + baseline-browser-mapping@2.9.19: {} - better-sqlite3@12.5.0: + better-sqlite3@12.6.2: dependencies: bindings: 1.5.0 prebuild-install: 7.1.3 + bignumber.js@9.3.1: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -5807,10 +8858,25 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} - boolean@3.2.0: - optional: true + boolean@3.2.0: {} + + bowser@2.14.1: {} brace-expansion@1.1.12: dependencies: @@ -5821,6 +8887,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -5831,14 +8901,16 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.12 - caniuse-lite: 1.0.30001762 - electron-to-chromium: 1.5.267 + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -5849,11 +8921,11 @@ snapshots: builder-util-runtime@9.5.1: dependencies: debug: 4.4.3 - sax: 1.4.3 + sax: 1.4.4 transitivePeerDependencies: - supports-color - builder-util@26.3.4: + builder-util@26.4.1: dependencies: 7zip-bin: 5.2.0 '@types/debug': 4.1.12 @@ -5874,38 +8946,30 @@ snapshots: transitivePeerDependencies: - supports-color + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + bytes@3.1.2: {} + c12@3.3.3: dependencies: chokidar: 5.0.0 - confbox: 0.2.2 + confbox: 0.2.4 defu: 6.1.4 - dotenv: 17.2.3 + dotenv: 17.2.4 exsolve: 1.0.8 giget: 2.0.0 jiti: 2.6.1 ohash: 2.0.11 pathe: 2.0.3 - perfect-debounce: 2.0.0 + perfect-debounce: 2.1.0 pkg-types: 2.3.0 rc9: 2.1.2 cac@6.7.14: {} - cacache@19.0.1: - dependencies: - '@npmcli/fs': 4.0.0 - fs-minipass: 3.0.3 - glob: 10.5.0 - lru-cache: 10.4.3 - minipass: 7.1.2 - minipass-collect: 2.0.1 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - p-map: 7.0.4 - ssri: 12.0.0 - tar: 7.5.2 - unique-filename: 4.0.0 - cacheable-lookup@5.0.4: {} cacheable-request@7.0.4: @@ -5923,18 +8987,25 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} - caniuse-lite@1.0.30001762: {} + caniuse-lite@1.0.30001769: {} + + ccount@2.0.1: {} chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - chart.js@4.5.1: - dependencies: - '@kurkle/color': 0.3.4 + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} chokidar@4.0.3: dependencies: @@ -5946,23 +9017,19 @@ snapshots: chownr@1.1.4: {} - chownr@2.0.0: {} - chownr@3.0.0: {} chromium-pickle-js@0.2.0: {} ci-info@4.3.1: {} + ci-info@4.4.0: {} + citty@0.1.6: dependencies: consola: 3.4.2 - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - - cli-spinners@2.9.2: {} + citty@0.2.0: {} cli-truncate@2.1.0: dependencies: @@ -5980,8 +9047,6 @@ snapshots: dependencies: mimic-response: 1.0.1 - clone@1.0.4: {} - clone@2.1.2: {} color-convert@2.0.1: @@ -5996,6 +9061,12 @@ snapshots: dependencies: delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} + + commander@13.1.0: {} + + commander@4.1.1: {} + commander@5.1.0: {} commander@9.5.0: @@ -6007,14 +9078,24 @@ snapshots: confbox@0.1.8: {} - confbox@0.2.2: {} + confbox@0.2.4: {} consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 @@ -6022,6 +9103,11 @@ snapshots: core-util-is@1.0.2: optional: true + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + crc@3.8.0: dependencies: buffer: 5.7.1 @@ -6032,10 +9118,6 @@ snapshots: cross-dirname@0.1.0: optional: true - cross-env@7.0.3: - dependencies: - cross-spawn: 7.0.6 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6055,6 +9137,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + dayjs@1.11.19: {} debug@4.4.3: @@ -6069,10 +9153,6 @@ snapshots: deep-is@0.1.4: {} - defaults@1.0.4: - dependencies: - clone: 1.0.4 - defer-to-connect@2.0.1: {} define-data-property@1.1.4: @@ -6080,25 +9160,30 @@ snapshots: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 - optional: true define-properties@1.2.1: dependencies: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 - optional: true defu@6.1.4: {} delayed-stream@1.0.0: {} + depd@2.0.0: {} + + dequal@2.0.3: {} + destr@2.0.5: {} detect-libc@2.1.2: {} - detect-node@2.1.0: - optional: true + detect-node@2.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 dfa@1.2.0: {} @@ -6107,14 +9192,10 @@ snapshots: minimatch: 3.1.2 p-limit: 3.1.0 - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - dmg-builder@26.4.0(electron-builder-squirrel-windows@26.4.0): + dmg-builder@26.7.0(electron-builder-squirrel-windows@26.7.0): dependencies: - app-builder-lib: 26.4.0(dmg-builder@26.4.0)(electron-builder-squirrel-windows@26.4.0) - builder-util: 26.3.4 + app-builder-lib: 26.7.0(dmg-builder@26.7.0)(electron-builder-squirrel-windows@26.7.0) + builder-util: 26.4.1 fs-extra: 10.1.0 iconv-lite: 0.6.3 js-yaml: 4.1.1 @@ -6142,7 +9223,7 @@ snapshots: dotenv@16.6.1: {} - dotenv@17.2.3: {} + dotenv@17.2.4: {} dunder-proto@1.0.1: dependencies: @@ -6150,29 +9231,46 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + earcut@3.0.2: {} + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + echarts-wordcloud@2.1.0(echarts@6.0.0): + dependencies: + echarts: 6.0.0 + + echarts@6.0.0: + dependencies: + tslib: 2.3.0 + zrender: 6.0.0 + + ee-first@1.1.1: {} + ejs@3.1.10: dependencies: jake: 10.9.4 - electron-builder-squirrel-windows@26.4.0(dmg-builder@26.4.0): + electron-builder-squirrel-windows@26.7.0(dmg-builder@26.7.0): dependencies: - app-builder-lib: 26.4.0(dmg-builder@26.4.0)(electron-builder-squirrel-windows@26.4.0) - builder-util: 26.3.4 + app-builder-lib: 26.7.0(dmg-builder@26.7.0)(electron-builder-squirrel-windows@26.7.0) + builder-util: 26.4.1 electron-winstaller: 5.4.0 transitivePeerDependencies: - dmg-builder - supports-color - electron-builder@26.4.0(electron-builder-squirrel-windows@26.4.0): + electron-builder@26.7.0(electron-builder-squirrel-windows@26.7.0): dependencies: - app-builder-lib: 26.4.0(dmg-builder@26.4.0)(electron-builder-squirrel-windows@26.4.0) - builder-util: 26.3.4 + app-builder-lib: 26.7.0(dmg-builder@26.7.0)(electron-builder-squirrel-windows@26.7.0) + builder-util: 26.4.1 builder-util-runtime: 9.5.1 chalk: 4.1.2 - ci-info: 4.3.1 - dmg-builder: 26.4.0(electron-builder-squirrel-windows@26.4.0) + ci-info: 4.4.0 + dmg-builder: 26.7.0(electron-builder-squirrel-windows@26.7.0) fs-extra: 10.1.0 lazy-val: 1.0.5 simple-update-notifier: 2.0.0 @@ -6181,10 +9279,10 @@ snapshots: - electron-builder-squirrel-windows - supports-color - electron-publish@26.3.4: + electron-publish@26.6.0: dependencies: '@types/fs-extra': 9.0.13 - builder-util: 26.3.4 + builder-util: 26.4.1 builder-util-runtime: 9.5.1 chalk: 4.1.2 form-data: 4.0.5 @@ -6194,7 +9292,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.267: {} + electron-to-chromium@1.5.286: {} electron-updater@6.7.3: dependencies: @@ -6204,20 +9302,20 @@ snapshots: lazy-val: 1.0.5 lodash.escaperegexp: 4.1.2 lodash.isequal: 4.5.0 - semver: 7.7.3 + semver: 7.7.4 tiny-typed-emitter: 2.1.0 transitivePeerDependencies: - supports-color - electron-vite@3.1.0(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)): + electron-vite@5.0.0(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.5) + '@babel/core': 7.29.0 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) cac: 6.7.14 esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + vite: 7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -6226,7 +9324,7 @@ snapshots: '@electron/asar': 3.4.1 debug: 4.4.3 fs-extra: 7.0.1 - lodash: 4.17.21 + lodash: 4.17.23 temp: 0.9.4 optionalDependencies: '@electron/windows-sign': 1.2.2 @@ -6236,7 +9334,7 @@ snapshots: electron@35.7.5: dependencies: '@electron/get': 2.0.3 - '@types/node': 22.19.3 + '@types/node': 22.19.10 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -6265,11 +9363,11 @@ snapshots: dependencies: embla-carousel: 8.6.0 - embla-carousel-vue@8.6.0(vue@3.5.26(typescript@5.9.3)): + embla-carousel-vue@8.6.0(vue@3.5.27(typescript@5.9.3)): dependencies: embla-carousel: 8.6.0 embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) embla-carousel-wheel-gestures@8.1.0(embla-carousel@8.6.0): dependencies: @@ -6278,27 +9376,26 @@ snapshots: embla-carousel@8.6.0: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} - encoding@0.1.13: - dependencies: - iconv-lite: 0.6.3 - optional: true + encodeurl@2.0.0: {} end-of-stream@1.4.5: dependencies: once: 1.4.0 - enhanced-resolve@5.18.4: + enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 entities@4.5.0: {} - entities@7.0.0: {} + entities@7.0.1: {} env-paths@2.2.1: {} @@ -6321,8 +9418,33 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es6-error@4.1.1: - optional: true + es6-error@4.1.1: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 esbuild@0.25.12: optionalDependencies: @@ -6353,30 +9475,53 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} - escodegen@2.1.0: - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)): dependencies: eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.7.4): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1): dependencies: eslint: 9.39.2(jiti@2.6.1) - prettier: 3.7.4 + prettier: 3.8.1 prettier-linter-helpers: 1.0.1 - synckit: 0.11.11 + synckit: 0.11.12 optionalDependencies: eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) @@ -6388,7 +9533,7 @@ snapshots: natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 6.1.2 - semver: 7.7.3 + semver: 7.7.4 vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@2.6.1)) xml-name-validator: 4.0.0 transitivePeerDependencies: @@ -6481,6 +9626,16 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -6497,8 +9652,52 @@ snapshots: exponential-backoff@3.1.3: {} + express-rate-limit@8.5.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend@3.0.2: {} + extract-zip@2.0.1: dependencies: debug: 4.4.3 @@ -6512,6 +9711,8 @@ snapshots: extsprintf@1.4.1: optional: true + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -6524,9 +9725,56 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@6.3.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.0: {} + + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.1.0 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 - fast-levenshtein@2.0.6: {} + fastify-plugin@5.1.0: {} + + fastify@5.8.4: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.0 fastq@1.20.1: dependencies: @@ -6540,6 +9788,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -6554,19 +9807,49 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-my-way@9.5.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 path-exists: 4.0.0 + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.0 + rollup: 4.57.1 + flat-cache@4.0.1: dependencies: flatted: 3.3.3 keyv: 4.5.4 + flatbuffers@25.9.23: {} + flatted@3.3.3: {} - follow-redirects@1.15.11: {} + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + follow-redirects@1.15.11: + optional: true fontaine@0.7.0: dependencies: @@ -6575,7 +9858,7 @@ snapshots: magic-regexp: 0.10.0 magic-string: 0.30.21 pathe: 2.0.3 - ufo: 1.6.2 + ufo: 1.6.3 unplugin: 2.3.11 fontkit@2.0.4: @@ -6590,7 +9873,7 @@ snapshots: unicode-properties: 1.4.1 unicode-trie: 2.0.0 - fontless@0.1.0(vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)): + fontless@0.1.0(vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 css-tree: 3.1.0 @@ -6598,15 +9881,15 @@ snapshots: esbuild: 0.25.12 fontaine: 0.7.0 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 magic-string: 0.30.21 ohash: 2.0.11 pathe: 2.0.3 - ufo: 1.6.2 + ufo: 1.6.3 unifont: 0.6.0 - unstorage: 1.17.3 + unstorage: 1.17.4 optionalDependencies: - vite: 6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + vite: 7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -6641,12 +9924,20 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - framer-motion@12.23.26: + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + framer-motion@12.33.0: dependencies: - motion-dom: 12.24.10 - motion-utils: 12.24.10 + motion-dom: 12.33.0 + motion-utils: 12.29.2 tslib: 2.8.1 + fresh@2.0.0: {} + fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -6680,14 +9971,6 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fs-minipass@2.1.0: - dependencies: - minipass: 3.3.6 - - fs-minipass@3.0.3: - dependencies: - minipass: 7.1.2 - fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -6697,6 +9980,23 @@ snapshots: fuse.js@7.1.0: {} + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -6725,13 +10025,21 @@ snapshots: get-stream@8.0.1: {} + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + gifuct-js@2.1.2: + dependencies: + js-binary-schema-parser: 2.0.3 + giget@2.0.0: dependencies: citty: 0.1.6 consola: 3.4.2 defu: 6.1.4 node-fetch-native: 1.6.7 - nypm: 0.6.2 + nypm: 0.6.5 pathe: 2.0.3 github-from-package@0.0.0: {} @@ -6744,15 +10052,21 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: + glob@10.4.5: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 minimatch: 9.0.5 - minipass: 7.1.2 + minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -6768,9 +10082,8 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.7.3 + semver: 7.7.4 serialize-error: 7.0.1 - optional: true globals@13.24.0: dependencies: @@ -6782,16 +10095,20 @@ snapshots: dependencies: define-properties: 1.2.1 gopd: 1.2.0 - optional: true - globby@11.1.0: + google-auth-library@10.5.0: dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} gopd@1.2.0: {} @@ -6811,9 +10128,23 @@ snapshots: graceful-fs@4.2.11: {} - graphemer@1.4.0: {} + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.2 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + guid-typescript@1.0.9: {} - h3@1.15.4: + h3@1.15.5: dependencies: cookie-es: 1.2.2 crossws: 0.3.5 @@ -6822,7 +10153,7 @@ snapshots: iron-webcrypto: 1.2.1 node-mock-http: 1.0.4 radix3: 1.1.2 - ufo: 1.6.2 + ufo: 1.6.3 uncrypto: 0.1.3 has-flag@4.0.0: {} @@ -6830,7 +10161,6 @@ snapshots: has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 - optional: true has-symbols@1.1.0: {} @@ -6842,16 +10172,48 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hey-listen@1.0.8: {} + hono@4.12.18: {} + hookable@5.5.3: {} + hookable@6.0.1: {} + hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 + html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -6873,6 +10235,12 @@ snapshots: human-signals@5.0.0: {} + i18next@25.8.5(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + optionalDependencies: + typescript: 5.9.3 + iconv-corefoundation@1.1.7: dependencies: cli-truncate: 2.1.0 @@ -6883,6 +10251,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -6905,10 +10277,16 @@ snapshots: ini@1.3.8: {} - ip-address@10.1.0: {} + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.3.0: {} iron-webcrypto@1.2.1: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -6917,13 +10295,11 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-interactive@1.0.0: {} - is-number@7.0.0: {} - is-stream@3.0.0: {} + is-promise@4.0.0: {} - is-unicode-supported@0.1.0: {} + is-stream@3.0.0: {} is-what@5.5.0: {} @@ -6933,7 +10309,11 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.1: {} + isexe@3.1.4: {} + + isexe@4.0.0: {} + + ismobilejs@1.1.1: {} isomorphic.js@0.2.5: {} @@ -6951,34 +10331,58 @@ snapshots: jiti@2.6.1: {} + jose@6.2.3: {} + + joycon@3.1.1: {} + + js-binary-schema-parser@2.0.3: {} + + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.1: dependencies: argparse: 2.0.1 jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.6 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} - json-stringify-safe@5.0.1: - optional: true + json-stringify-safe@5.0.1: {} json5@2.2.3: {} - jsonc-eslint-parser@2.4.2: - dependencies: - acorn: 8.15.0 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 - semver: 7.7.3 - jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -6989,10 +10393,23 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + klona@2.0.6: {} knitwork@1.3.0: {} @@ -7008,39 +10425,78 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.30.2: optional: true + lightningcss-android-arm64@1.31.1: + optional: true + lightningcss-darwin-arm64@1.30.2: optional: true + lightningcss-darwin-arm64@1.31.1: + optional: true + lightningcss-darwin-x64@1.30.2: optional: true + lightningcss-darwin-x64@1.31.1: + optional: true + lightningcss-freebsd-x64@1.30.2: optional: true + lightningcss-freebsd-x64@1.31.1: + optional: true + lightningcss-linux-arm-gnueabihf@1.30.2: optional: true + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + lightningcss-linux-arm64-gnu@1.30.2: optional: true + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + lightningcss-linux-arm64-musl@1.30.2: optional: true + lightningcss-linux-arm64-musl@1.31.1: + optional: true + lightningcss-linux-x64-gnu@1.30.2: optional: true + lightningcss-linux-x64-gnu@1.31.1: + optional: true + lightningcss-linux-x64-musl@1.30.2: optional: true + lightningcss-linux-x64-musl@1.31.1: + optional: true + lightningcss-win32-arm64-msvc@1.30.2: optional: true + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + lightningcss-win32-x64-msvc@1.30.2: optional: true + lightningcss-win32-x64-msvc@1.31.1: + optional: true + lightningcss@1.30.2: dependencies: detect-libc: 2.1.2 @@ -7057,12 +10513,34 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + linkify-it@5.0.0: dependencies: uc.micro: 2.1.0 linkifyjs@4.3.2: {} + load-tsconfig@0.2.5: {} + local-pkg@1.1.2: dependencies: mlly: 1.8.0 @@ -7079,17 +10557,16 @@ snapshots: lodash.merge@4.6.2: {} - lodash@4.17.21: {} + lodash@4.17.23: {} - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 + long@5.3.2: {} lowercase-keys@2.0.0: {} lru-cache@10.4.3: {} + lru-cache@11.2.5: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7105,28 +10582,14 @@ snapshots: mlly: 1.8.0 regexp-tree: 0.1.27 type-level-regexp: 0.1.17 - ufo: 1.6.2 + ufo: 1.6.3 unplugin: 2.3.11 magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - make-fetch-happen@14.0.3: - dependencies: - '@npmcli/agent': 3.0.0 - cacache: 19.0.1 - http-cache-semantics: 4.2.0 - minipass: 7.1.2 - minipass-fetch: 4.0.1 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - negotiator: 1.0.0 - proc-log: 5.0.0 - promise-retry: 2.0.1 - ssri: 12.0.0 - transitivePeerDependencies: - - supports-color + mark.js@8.11.1: {} markdown-it@14.1.0: dependencies: @@ -7137,23 +10600,55 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 - marked@15.0.12: {} + marked@17.0.1: {} matcher@3.0.0: dependencies: escape-string-regexp: 4.0.0 - optional: true math-intrinsics@1.1.0: {} + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + mdn-data@2.12.2: {} mdurl@2.0.0: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -7161,13 +10656,19 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@2.6.0: {} - mimic-fn@2.1.0: {} + mime@3.0.0: {} mimic-fn@4.0.0: {} @@ -7175,9 +10676,9 @@ snapshots: mimic-response@3.1.0: {} - minimatch@10.1.1: + minimatch@10.2.5: dependencies: - '@isaacs/brace-expansion': 5.0.0 + brace-expansion: 5.0.6 minimatch@3.1.2: dependencies: @@ -7193,46 +10694,13 @@ snapshots: minimist@1.2.8: {} - minipass-collect@2.0.1: - dependencies: - minipass: 7.1.2 - - minipass-fetch@4.0.1: - dependencies: - minipass: 7.1.2 - minipass-sized: 1.0.3 - minizlib: 3.1.0 - optionalDependencies: - encoding: 0.1.13 - - minipass-flush@1.0.5: - dependencies: - minipass: 3.3.6 - - minipass-pipeline@1.2.4: - dependencies: - minipass: 3.3.6 - - minipass-sized@1.0.3: - dependencies: - minipass: 3.3.6 - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - - minipass@5.0.0: {} + minipass@7.1.3: {} - minipass@7.1.2: {} - - minizlib@2.1.2: - dependencies: - minipass: 3.3.6 - yallist: 4.0.0 + minisearch@7.2.0: {} minizlib@3.1.0: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 mitt@3.0.1: {} @@ -7242,28 +10710,26 @@ snapshots: dependencies: minimist: 1.2.8 - mkdirp@1.0.4: {} - mlly@1.8.0: dependencies: acorn: 8.15.0 pathe: 2.0.3 pkg-types: 1.3.1 - ufo: 1.6.2 + ufo: 1.6.3 - motion-dom@12.24.10: + motion-dom@12.33.0: dependencies: - motion-utils: 12.24.10 + motion-utils: 12.29.2 - motion-utils@12.24.10: {} + motion-utils@12.29.2: {} - motion-v@1.7.6(@vueuse/core@14.1.0(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3)): + motion-v@1.10.2(@vueuse/core@14.2.0(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)): dependencies: - '@vueuse/core': 14.1.0(vue@3.5.26(typescript@5.9.3)) - framer-motion: 12.23.26 + '@vueuse/core': 14.2.0(vue@3.5.27(typescript@5.9.3)) + framer-motion: 12.33.0 hey-listen: 1.0.8 - motion-dom: 12.24.10 - vue: 3.5.26(typescript@5.9.3) + motion-dom: 12.33.0 + vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - '@emotion/is-prop-valid' - react @@ -7273,6 +10739,14 @@ snapshots: ms@2.1.3: {} + muggle-string@0.4.1: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.11: {} napi-build-utils@2.0.0: {} @@ -7281,45 +10755,51 @@ snapshots: negotiator@1.0.0: {} - node-abi@3.85.0: + node-abi@3.87.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 - node-abi@4.24.0: + node-abi@4.26.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 node-addon-api@1.7.2: optional: true node-api-version@0.2.1: dependencies: - semver: 7.7.3 + semver: 7.7.4 + + node-domexception@1.0.0: {} node-fetch-native@1.6.7: {} - node-gyp@11.5.0: + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-gyp@12.3.0: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 graceful-fs: 4.2.11 - make-fetch-happen: 14.0.3 - nopt: 8.1.0 - proc-log: 5.0.0 - semver: 7.7.3 - tar: 7.5.2 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.7.4 + tar: 7.5.7 tinyglobby: 0.2.15 - which: 5.0.0 - transitivePeerDependencies: - - supports-color + undici: 6.25.0 + which: 6.0.1 node-mock-http@1.0.4: {} node-releases@2.0.27: {} - nopt@8.1.0: + nopt@9.0.0: dependencies: - abbrev: 3.0.1 + abbrev: 4.0.0 normalize-path@3.0.0: {} @@ -7333,37 +10813,72 @@ snapshots: dependencies: boolbase: 1.0.0 - nypm@0.6.2: + nypm@0.6.5: dependencies: - citty: 0.1.6 - consola: 3.4.2 + citty: 0.2.0 pathe: 2.0.3 - pkg-types: 2.3.0 tinyexec: 1.0.2 - object-keys@1.1.1: - optional: true + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + obug@2.1.1: {} ofetch@1.5.1: dependencies: destr: 2.0.5 node-fetch-native: 1.6.7 - ufo: 1.6.2 + ufo: 1.6.3 ohash@2.0.11: {} - once@1.4.0: + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: dependencies: - wrappy: 1.0.2 + ee-first: 1.1.1 - onetime@5.1.2: + once@1.4.0: dependencies: - mimic-fn: 2.1.0 + wrappy: 1.0.2 onetime@6.0.0: dependencies: mimic-fn: 4.0.0 + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + + onnxruntime-common@1.24.0-dev.20251116-b39e144322: {} + + onnxruntime-common@1.24.3: {} + + onnxruntime-node@1.24.3: + dependencies: + adm-zip: 0.5.17 + global-agent: 3.0.0 + onnxruntime-common: 1.24.3 + + onnxruntime-web@1.26.0-dev.20260416-b7804b056c: + dependencies: + flatbuffers: 25.9.23 + guid-typescript: 1.0.9 + long: 5.3.2 + onnxruntime-common: 1.24.0-dev.20251116-b39e144322 + platform: 1.3.6 + protobufjs: 7.5.4 + + openai@6.26.0(ws@8.19.0)(zod@3.25.76): + optionalDependencies: + ws: 8.19.0 + zod: 3.25.76 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7373,18 +10888,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - orderedmap@2.1.1: {} p-cancelable@2.1.1: {} @@ -7397,7 +10900,10 @@ snapshots: dependencies: p-limit: 3.1.0 - p-map@7.0.4: {} + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 package-json-from-dist@1.0.1: {} @@ -7409,8 +10915,18 @@ snapshots: dependencies: callsites: 3.1.0 + parse-svg-path@0.2.0: {} + + parseurl@1.3.3: {} + + partial-json@0.1.7: {} + + path-browserify@1.0.1: {} + path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -7420,9 +10936,14 @@ snapshots: path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 - minipass: 7.1.2 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.5 + minipass: 7.1.3 - path-type@4.0.0: {} + path-to-regexp@8.4.2: {} pathe@1.1.2: {} @@ -7434,7 +10955,7 @@ snapshots: perfect-debounce@1.0.0: {} - perfect-debounce@2.0.0: {} + perfect-debounce@2.1.0: {} picocolors@1.1.1: {} @@ -7442,20 +10963,61 @@ snapshots: picomatch@4.0.3: {} - pinia-plugin-persistedstate@4.7.1(@nuxt/kit@4.2.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))): + pinia-plugin-persistedstate@4.7.1(@nuxt/kit@4.3.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))): dependencies: defu: 6.1.4 optionalDependencies: - '@nuxt/kit': 4.2.2 - pinia: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)) + '@nuxt/kit': 4.3.1 + pinia: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)) - pinia@3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)): + pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)): dependencies: '@vue/devtools-api': 7.7.9 - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + + pirates@4.0.7: {} + + pixi-viewport@6.0.3(pixi.js@8.19.0): + dependencies: + pixi.js: 8.19.0 + + pixi.js@8.19.0: + dependencies: + '@pixi/colord': 2.9.6 + '@types/earcut': 3.0.0 + '@webgpu/types': 0.1.71 + '@xmldom/xmldom': 0.8.13 + earcut: 3.0.2 + eventemitter3: 5.0.4 + gifuct-js: 2.1.2 + ismobilejs: 1.1.1 + parse-svg-path: 0.2.0 + tiny-lru: 11.4.7 + + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -7464,16 +11026,27 @@ snapshots: pkg-types@2.3.0: dependencies: - confbox: 0.2.2 + confbox: 0.2.4 exsolve: 1.0.8 pathe: 2.0.3 + platform@1.3.6: {} + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 base64-js: 1.5.1 xmlbuilder: 15.1.1 + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.6.1 + postcss: 8.5.6 + tsx: 4.21.0 + yaml: 2.8.2 + postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 @@ -7490,6 +11063,8 @@ snapshots: commander: 9.5.0 optional: true + preact@10.29.2: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -7498,7 +11073,7 @@ snapshots: minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 2.0.0 - node-abi: 3.85.0 + node-abi: 3.87.0 pump: 3.0.3 rc: 1.2.8 simple-get: 4.0.1 @@ -7511,9 +11086,13 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier@3.7.4: {} + prettier@3.8.1: {} + + proc-log@6.1.0: {} - proc-log@5.0.0: {} + process-warning@4.0.1: {} + + process-warning@5.0.0: {} progress@2.0.3: {} @@ -7522,9 +11101,17 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + property-information@7.1.0: {} + prosemirror-changeset@2.3.1: dependencies: - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.11.0 prosemirror-collab@1.3.1: dependencies: @@ -7534,39 +11121,39 @@ snapshots: dependencies: prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.11.0 prosemirror-dropcursor@1.8.2: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 prosemirror-gapcursor@1.4.0: dependencies: prosemirror-keymap: 1.2.3 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.4 + prosemirror-view: 1.41.6 prosemirror-history@1.5.0: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 rope-sequence: 1.3.4 prosemirror-inputrules@1.5.1: dependencies: prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.11.0 prosemirror-keymap@1.2.3: dependencies: prosemirror-state: 1.4.4 w3c-keyname: 2.2.8 - prosemirror-markdown@1.13.2: + prosemirror-markdown@1.13.4: dependencies: '@types/markdown-it': 14.1.2 markdown-it: 14.1.0 @@ -7591,41 +11178,62 @@ snapshots: dependencies: prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.11.0 prosemirror-state@1.4.4: dependencies: prosemirror-model: 1.25.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 prosemirror-tables@1.8.5: dependencies: prosemirror-keymap: 1.2.3 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 - prosemirror-view: 1.41.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.6 - prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.4): + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6): dependencies: '@remirror/core-constants': 3.0.0 escape-string-regexp: 4.0.0 prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-view: 1.41.4 + prosemirror-view: 1.41.6 - prosemirror-transform@1.10.5: + prosemirror-transform@1.11.0: dependencies: prosemirror-model: 1.25.4 - prosemirror-view@1.41.4: + prosemirror-view@1.41.6: dependencies: prosemirror-model: 1.25.4 prosemirror-state: 1.4.4 - prosemirror-transform: 1.10.5 + prosemirror-transform: 1.11.0 + + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 25.2.2 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 - proxy-from-env@1.1.0: {} + proxy-from-env@1.1.0: + optional: true pump@3.0.3: dependencies: @@ -7636,19 +11244,39 @@ snapshots: punycode@2.3.1: {} + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + quick-lru@5.1.1: {} radix3@1.1.2: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc9@2.1.2: dependencies: defu: 6.1.4 destr: 2.0.5 + rc9@3.0.0: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -7672,27 +11300,41 @@ snapshots: readdirp@5.0.0: {} + real-require@0.2.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexp-tree@0.1.27: {} - reka-ui@2.6.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)): + reka-ui@2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)): dependencies: - '@floating-ui/dom': 1.7.4 - '@floating-ui/vue': 1.1.9(vue@3.5.26(typescript@5.9.3)) - '@internationalized/date': 3.10.1 + '@floating-ui/dom': 1.7.5 + '@floating-ui/vue': 1.1.10(vue@3.5.27(typescript@5.9.3)) + '@internationalized/date': 3.11.0 '@internationalized/number': 3.6.5 - '@tanstack/vue-virtual': 3.13.17(vue@3.5.26(typescript@5.9.3)) + '@tanstack/vue-virtual': 3.13.18(vue@3.5.27(typescript@5.9.3)) '@vueuse/core': 12.8.2(typescript@5.9.3) '@vueuse/shared': 12.8.2(typescript@5.9.3) aria-hidden: 1.2.6 defu: 6.1.4 ohash: 2.0.11 - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - '@vue/composition-api' - typescript require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -7701,19 +11343,22 @@ snapshots: resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + responselike@2.0.1: dependencies: lowercase-keys: 2.0.0 - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - restructure@3.0.2: {} + ret@0.5.0: {} + retry@0.12.0: {} + retry@0.13.1: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -7722,6 +11367,10 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@5.0.10: + dependencies: + glob: 10.4.5 + roarr@2.15.4: dependencies: boolean: 3.2.0 @@ -7730,70 +11379,152 @@ snapshots: json-stringify-safe: 5.0.1 semver-compare: 1.0.0 sprintf-js: 1.1.3 - optional: true - rollup@4.55.1: + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.55.1 - '@rollup/rollup-android-arm64': 4.55.1 - '@rollup/rollup-darwin-arm64': 4.55.1 - '@rollup/rollup-darwin-x64': 4.55.1 - '@rollup/rollup-freebsd-arm64': 4.55.1 - '@rollup/rollup-freebsd-x64': 4.55.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.55.1 - '@rollup/rollup-linux-arm-musleabihf': 4.55.1 - '@rollup/rollup-linux-arm64-gnu': 4.55.1 - '@rollup/rollup-linux-arm64-musl': 4.55.1 - '@rollup/rollup-linux-loong64-gnu': 4.55.1 - '@rollup/rollup-linux-loong64-musl': 4.55.1 - '@rollup/rollup-linux-ppc64-gnu': 4.55.1 - '@rollup/rollup-linux-ppc64-musl': 4.55.1 - '@rollup/rollup-linux-riscv64-gnu': 4.55.1 - '@rollup/rollup-linux-riscv64-musl': 4.55.1 - '@rollup/rollup-linux-s390x-gnu': 4.55.1 - '@rollup/rollup-linux-x64-gnu': 4.55.1 - '@rollup/rollup-linux-x64-musl': 4.55.1 - '@rollup/rollup-openbsd-x64': 4.55.1 - '@rollup/rollup-openharmony-arm64': 4.55.1 - '@rollup/rollup-win32-arm64-msvc': 4.55.1 - '@rollup/rollup-win32-ia32-msvc': 4.55.1 - '@rollup/rollup-win32-x64-gnu': 4.55.1 - '@rollup/rollup-win32-x64-msvc': 4.55.1 + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 rope-sequence@1.3.4: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 safe-buffer@5.2.1: {} + safe-regex2@5.1.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sanitize-filename@1.6.3: dependencies: truncate-utf8-bytes: 1.0.2 - sax@1.4.3: {} + sax@1.4.4: {} scule@1.3.0: {} - semver-compare@1.0.0: - optional: true + search-insights@2.17.3: {} + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + secure-json-parse@4.1.0: {} + + semver-compare@1.0.0: {} semver@5.7.2: {} semver@6.3.1: {} - semver@7.7.3: {} + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color serialize-error@7.0.1: dependencies: type-fest: 0.13.1 - optional: true + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 shebang-command@2.0.0: dependencies: @@ -7801,6 +11532,45 @@ snapshots: shebang-regex@3.0.0: {} + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -7815,7 +11585,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 sirv@3.0.2: dependencies: @@ -7823,8 +11593,6 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - slash@3.0.0: {} - slice-ansi@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -7832,20 +11600,14 @@ snapshots: is-fullwidth-code-point: 3.0.0 optional: true - smart-buffer@4.2.0: {} + smart-buffer@4.2.0: + optional: true - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color + smol-toml@1.6.1: {} - socks@2.8.7: + sonic-boom@4.2.1: dependencies: - ip-address: 10.1.0 - smart-buffer: 4.2.0 + atomic-sleep: 1.0.0 source-map-js@1.2.1: {} @@ -7856,17 +11618,45 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + speakingurl@14.0.1: {} - sprintf-js@1.1.3: + split2@4.2.0: {} + + sprintf-js@1.0.3: {} + + sprintf-js@1.1.3: {} + + sqlite-vec-darwin-arm64@0.1.9: optional: true - ssri@12.0.0: - dependencies: - minipass: 7.1.2 + sqlite-vec-darwin-x64@0.1.9: + optional: true + + sqlite-vec-linux-arm64@0.1.9: + optional: true + + sqlite-vec-linux-x64@0.1.9: + optional: true + + sqlite-vec-windows-x64@0.1.9: + optional: true + + sqlite-vec@0.1.9: + optionalDependencies: + sqlite-vec-darwin-arm64: 0.1.9 + sqlite-vec-darwin-x64: 0.1.9 + sqlite-vec-linux-arm64: 0.1.9 + sqlite-vec-linux-x64: 0.1.9 + sqlite-vec-windows-x64: 0.1.9 stat-mode@1.0.0: {} + statuses@2.0.2: {} + std-env@3.10.0: {} stream-chain@2.2.5: {} @@ -7891,6 +11681,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -7899,6 +11694,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-bom-string@1.0.0: {} + strip-final-newline@3.0.0: {} strip-json-comments@2.0.1: {} @@ -7909,6 +11706,18 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.3.0: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -7923,10 +11732,12 @@ snapshots: dependencies: has-flag: 4.0.0 - synckit@0.11.11: + synckit@0.11.12: dependencies: '@pkgr/core': 0.2.9 + tabbable@6.4.0: {} + tailwind-merge@3.4.0: {} tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18): @@ -7954,20 +11765,11 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar@6.2.1: - dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 - - tar@7.5.2: + tar@7.5.7: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 - minipass: 7.1.2 + minipass: 7.1.3 minizlib: 3.1.0 yallist: 5.0.0 @@ -7981,14 +11783,32 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + three@0.185.0: {} + tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 tiny-inflate@1.0.3: {} + tiny-lru@11.4.7: {} + tiny-typed-emitter@2.1.0: {} + tinyexec@0.3.2: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -8006,22 +11826,67 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + totalist@3.0.1: {} + tree-kill@1.2.2: {} + + trim-lines@3.0.1: {} + truncate-utf8-bytes@1.0.2: dependencies: utf8-byte-length: 1.0.5 - ts-api-utils@1.4.3(typescript@5.9.3): - dependencies: - typescript: 5.9.3 + ts-algebra@2.0.0: {} ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 + ts-interface-checker@0.1.13: {} + + tslib@2.3.0: {} + tslib@2.8.1: {} + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + resolve-from: 5.0.0 + rollup: 4.57.1 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -8030,18 +11895,36 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@0.13.1: - optional: true + type-fest@0.13.1: {} type-fest@0.20.2: {} + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + type-level-regexp@0.1.17: {} + typebox@1.1.38: {} + + typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} uc.micro@2.1.0: {} - ufo@1.6.2: {} + ufo@1.6.3: {} uncrypto@0.1.3: {} @@ -8056,9 +11939,11 @@ snapshots: undici-types@7.16.0: {} - unhead@2.1.1: + undici@6.25.0: {} + + unhead@2.1.4: dependencies: - hookable: 5.5.3 + hookable: 6.0.1 unicode-properties@1.4.1: dependencies: @@ -8093,19 +11978,36 @@ snapshots: unplugin: 2.3.11 unplugin-utils: 0.3.1 - unique-filename@4.0.0: + unist-util-is@6.0.1: dependencies: - unique-slug: 5.0.0 + '@types/unist': 3.0.3 - unique-slug@5.0.0: + unist-util-position@5.0.0: dependencies: - imurmurhash: 0.1.4 + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 universalify@0.1.2: {} universalify@2.0.1: {} - unplugin-auto-import@20.3.0(@nuxt/kit@4.2.2)(@vueuse/core@14.1.0(vue@3.5.26(typescript@5.9.3))): + unpipe@1.0.0: {} + + unplugin-auto-import@21.0.0(@nuxt/kit@4.3.1)(@vueuse/core@14.2.0(vue@3.5.27(typescript@5.9.3))): dependencies: local-pkg: 1.1.2 magic-string: 0.30.21 @@ -8114,30 +12016,28 @@ snapshots: unplugin: 2.3.11 unplugin-utils: 0.3.1 optionalDependencies: - '@nuxt/kit': 4.2.2 - '@vueuse/core': 14.1.0(vue@3.5.26(typescript@5.9.3)) + '@nuxt/kit': 4.3.1 + '@vueuse/core': 14.2.0(vue@3.5.27(typescript@5.9.3)) unplugin-utils@0.3.1: dependencies: pathe: 2.0.3 picomatch: 4.0.3 - unplugin-vue-components@30.0.0(@babel/parser@7.28.5)(@nuxt/kit@4.2.2)(vue@3.5.26(typescript@5.9.3)): + unplugin-vue-components@31.0.0(@nuxt/kit@4.3.1)(vue@3.5.27(typescript@5.9.3)): dependencies: - chokidar: 4.0.3 - debug: 4.4.3 + chokidar: 5.0.0 local-pkg: 1.1.2 magic-string: 0.30.21 mlly: 1.8.0 + obug: 2.1.1 + picomatch: 4.0.3 tinyglobby: 0.2.15 unplugin: 2.3.11 unplugin-utils: 0.3.1 - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) optionalDependencies: - '@babel/parser': 7.28.5 - '@nuxt/kit': 4.2.2 - transitivePeerDependencies: - - supports-color + '@nuxt/kit': 4.3.1 unplugin@2.3.11: dependencies: @@ -8146,16 +12046,16 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - unstorage@1.17.3: + unstorage@1.17.4: dependencies: anymatch: 3.1.3 - chokidar: 4.0.3 + chokidar: 5.0.0 destr: 2.0.5 - h3: 1.15.4 - lru-cache: 10.4.3 + h3: 1.15.5 + lru-cache: 11.2.5 node-fetch-native: 1.6.7 ofetch: 1.5.1 - ufo: 1.6.2 + ufo: 1.6.3 untyped@2.0.0: dependencies: @@ -8179,11 +12079,13 @@ snapshots: util-deprecate@1.0.2: {} - vaul-vue@0.4.1(reka-ui@2.6.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)))(vue@3.5.26(typescript@5.9.3)): + vary@1.1.2: {} + + vaul-vue@0.4.1(reka-ui@2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)): dependencies: - '@vueuse/core': 10.11.1(vue@3.5.26(typescript@5.9.3)) - reka-ui: 2.6.1(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3)) - vue: 3.5.26(typescript@5.9.3) + '@vueuse/core': 10.11.1(vue@3.5.27(typescript@5.9.3)) + reka-ui: 2.7.0(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)) + vue: 3.5.27(typescript@5.9.3) transitivePeerDependencies: - '@vue/composition-api' @@ -8194,31 +12096,110 @@ snapshots: extsprintf: 1.4.1 optional: true - vite@6.4.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2): + vfile-message@4.0.3: dependencies: - esbuild: 0.25.12 + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@5.4.21(@types/node@25.2.2)(lightningcss@1.31.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.57.1 + optionalDependencies: + '@types/node': 25.2.2 + fsevents: 2.3.3 + lightningcss: 1.31.1 + + vite@7.3.3(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 - rollup: 4.55.1 + rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.0.3 + '@types/node': 25.2.2 fsevents: 2.3.3 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 + tsx: 4.21.0 yaml: 2.8.2 - vue-chartjs@5.3.3(chart.js@4.5.1)(vue@3.5.26(typescript@5.9.3)): + vitepress@1.6.4(@algolia/client-search@5.52.1)(@types/node@25.2.2)(axios@1.13.5)(fuse.js@7.1.0)(lightningcss@1.31.1)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3): dependencies: - chart.js: 4.5.1 - vue: 3.5.26(typescript@5.9.3) + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.52.1)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.83 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.31.1))(vue@3.5.27(typescript@5.9.3)) + '@vue/devtools-api': 7.7.9 + '@vue/shared': 3.5.27 + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/integrations': 12.8.2(axios@1.13.5)(focus-trap@7.8.0)(fuse.js@7.1.0)(typescript@5.9.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@25.2.2)(lightningcss@1.31.1) + vue: 3.5.27(typescript@5.9.3) + optionalDependencies: + postcss: 8.5.6 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie - vue-component-type-helpers@3.2.2: {} + vscode-uri@3.1.0: {} - vue-demi@0.14.10(vue@3.5.26(typescript@5.9.3)): + vue-component-type-helpers@3.2.4: {} + + vue-demi@0.14.10(vue@3.5.27(typescript@5.9.3)): + dependencies: + vue: 3.5.27(typescript@5.9.3) + + vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1)): dependencies: - vue: 3.5.26(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@2.6.1)): dependencies: @@ -8228,38 +12209,42 @@ snapshots: eslint-visitor-keys: 3.4.3 espree: 9.6.1 esquery: 1.7.0 - lodash: 4.17.21 - semver: 7.7.3 + lodash: 4.17.23 + semver: 7.7.4 transitivePeerDependencies: - supports-color - vue-i18n@11.2.8(vue@3.5.26(typescript@5.9.3)): + vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)): dependencies: '@intlify/core-base': 11.2.8 '@intlify/shared': 11.2.8 '@vue/devtools-api': 6.6.4 - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) - vue-router@4.6.4(vue@3.5.26(typescript@5.9.3)): + vue-router@4.6.4(vue@3.5.27(typescript@5.9.3)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.26(typescript@5.9.3) + vue: 3.5.27(typescript@5.9.3) + + vue-tsc@3.2.6(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.2.6 + typescript: 5.9.3 - vue@3.5.26(typescript@5.9.3): + vue@3.5.27(typescript@5.9.3): dependencies: - '@vue/compiler-dom': 3.5.26 - '@vue/compiler-sfc': 3.5.26 - '@vue/runtime-dom': 3.5.26 - '@vue/server-renderer': 3.5.26(vue@3.5.26(typescript@5.9.3)) - '@vue/shared': 3.5.26 + '@vue/compiler-dom': 3.5.27 + '@vue/compiler-sfc': 3.5.27 + '@vue/runtime-dom': 3.5.27 + '@vue/server-renderer': 3.5.27(vue@3.5.27(typescript@5.9.3)) + '@vue/shared': 3.5.27 optionalDependencies: typescript: 5.9.3 w3c-keyname@2.2.8: {} - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 + web-streams-polyfill@3.3.3: {} webpack-virtual-modules@0.6.2: {} @@ -8271,7 +12256,11 @@ snapshots: which@5.0.0: dependencies: - isexe: 3.1.1 + isexe: 3.1.4 + + which@6.0.1: + dependencies: + isexe: 4.0.0 word-wrap@1.2.5: {} @@ -8289,8 +12278,12 @@ snapshots: wrappy@1.0.2: {} + ws@8.19.0: {} + xml-name-validator@4.0.0: {} + xml-naming@0.1.0: {} + xmlbuilder@15.1.1: {} y-protocols@1.0.7(yjs@13.6.29): @@ -8306,11 +12299,6 @@ snapshots: yallist@5.0.0: {} - yaml-eslint-parser@1.3.2: - dependencies: - eslint-visitor-keys: 3.4.3 - yaml: 2.8.2 - yaml@2.8.2: {} yargs-parser@21.1.1: {} @@ -8330,8 +12318,24 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + yjs@13.6.29: dependencies: lib0: 0.2.117 yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zrender@6.0.0: + dependencies: + tslib: 2.3.0 + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..5e83252ef --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - 'packages/**' + - 'apps/*' + - 'docs' diff --git a/public/images/banner-light.png b/public/images/banner-light.png new file mode 100644 index 000000000..ab99fc9ae Binary files /dev/null and b/public/images/banner-light.png differ diff --git a/public/images/banner.png b/public/images/banner.png new file mode 100644 index 000000000..84bae307e Binary files /dev/null and b/public/images/banner.png differ diff --git a/public/images/intro_en.png b/public/images/intro_en.png index 76f562488..8b7352ada 100644 Binary files a/public/images/intro_en.png and b/public/images/intro_en.png differ diff --git a/public/images/intro_zh.png b/public/images/intro_zh.png index 48135de87..ec4eadc05 100644 Binary files a/public/images/intro_zh.png and b/public/images/intro_zh.png differ diff --git a/scripts/changelog-to-markdown.mjs b/scripts/changelog-to-markdown.mjs new file mode 100644 index 000000000..3f49d62db --- /dev/null +++ b/scripts/changelog-to-markdown.mjs @@ -0,0 +1,102 @@ +/** + * changelog-to-markdown.mjs + * + * 将 changelogs/{cn,en,tw,ja}.json 全量渲染为同名 .md 文件。 + * 幂等:每次全量重写,md 始终与 json 保持一致。 + * + * 用法: + * node scripts/changelog-to-markdown.mjs + */ + +import { readFileSync, writeFileSync } from 'fs' +import { resolve, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(__dirname, '..') + +// ── 本地化配置 ────────────────────────────────────────────────────────────── + +const LOCALES = [ + { key: 'cn', h1: '更新日志' }, + { key: 'en', h1: 'Changelog' }, + { key: 'tw', h1: '更新日誌' }, + { key: 'ja', h1: '変更履歴' }, +] + +/** type → 各语言的标题(含 emoji)。缺失 type 回退为原始字符串,兼容未来扩展。 */ +const TYPE_LABELS = { + feat: { cn: '✨ 新功能', en: '✨ Features', tw: '✨ 新功能', ja: '✨ 新機能' }, + fix: { cn: '🐛 修复', en: '🐛 Bug Fixes', tw: '🐛 修復', ja: '🐛 バグ修正' }, + refactor: { cn: '♻️ 重构', en: '♻️ Refactoring', tw: '♻️ 重構', ja: '♻️ リファクタリング' }, + perf: { cn: '⚡ 性能', en: '⚡ Performance', tw: '⚡ 效能', ja: '⚡ パフォーマンス' }, + docs: { cn: '📝 文档', en: '📝 Documentation', tw: '📝 文件', ja: '📝 ドキュメント' }, + style: { cn: '💄 样式', en: '💄 Styles', tw: '💄 樣式', ja: '💄 スタイル' }, + ci: { cn: '👷 CI', en: '👷 CI', tw: '👷 CI', ja: '👷 CI' }, + chore: { cn: '🔧 杂项', en: '🔧 Chores', tw: '🔧 雜項', ja: '🔧 雑務' }, +} + +// ── 渲染函数 ──────────────────────────────────────────────────────────────── + +/** + * 将单个语言的 changelog 数组渲染为 Markdown 字符串。 + * @param {string} h1 - H1 标题文字 + * @param {string} langKey - 'cn' | 'en' | 'tw' | 'ja'(用于 type 标题查表) + * @param {Array} entries - changelog 数组 + */ +function renderMarkdown(h1, langKey, entries) { + const lines = [`# ${h1}`, ''] + + for (const entry of entries) { + const { version, date, summary, changes } = entry + + lines.push(`## v${version} (${date})`) + lines.push('') + + if (summary) { + lines.push(`> ${summary}`) + lines.push('') + } + + if (Array.isArray(changes)) { + for (const group of changes) { + const { type, items } = group + const typeMap = TYPE_LABELS[type] + const label = typeMap ? typeMap[langKey] : type + lines.push(`### ${label}`) + lines.push('') + for (const item of items) { + lines.push(`- ${item}`) + } + lines.push('') + } + } + } + + // 确保文件以单个换行结尾 + return lines.join('\n').trimEnd() + '\n' +} + +// ── 主流程 ────────────────────────────────────────────────────────────────── + +let hasError = false + +for (const { key, h1 } of LOCALES) { + const jsonPath = resolve(ROOT, 'changelogs', `${key}.json`) + const mdPath = resolve(ROOT, 'changelogs', `${key}.md`) + + let entries + try { + entries = JSON.parse(readFileSync(jsonPath, 'utf8')) + } catch (err) { + console.error(`✗ 读取 ${jsonPath} 失败:${err.message}`) + hasError = true + continue + } + + const md = renderMarkdown(h1, key, entries) + writeFileSync(mdPath, md, 'utf8') + console.log(`✓ changelogs/${key}.md (${entries.length} 版本)`) +} + +if (hasError) process.exit(1) diff --git a/scripts/dev-select.mjs b/scripts/dev-select.mjs new file mode 100644 index 000000000..ddc0d0b2d --- /dev/null +++ b/scripts/dev-select.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +/** + * Interactive dev mode selector. + * Usage: node scripts/dev-select.mjs + * + * Arrow keys to move, Enter to confirm. + * Remembers last selection in node_modules/.cache/dev-mode. + */ + +import { execSync } from 'child_process' +import { readFileSync, writeFileSync, mkdirSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const rootDir = join(__dirname, '..') +const cacheFile = join(rootDir, 'node_modules', '.cache', 'dev-mode') + +const options = [ + { key: 'desktop', label: 'Desktop (Electron)', command: 'pnpm run dev:desktop' }, + { key: 'web', label: 'Web (CLI start + frontend)', command: 'pnpm run dev:web' }, + { key: 'server', label: 'Server Only (API backend)', command: 'pnpm run dev:serve' }, + { key: 'docs', label: 'Docs (VitePress)', command: 'pnpm run docs:dev' }, +] + +function loadLastChoice() { + try { + const key = readFileSync(cacheFile, 'utf8').trim() + const idx = options.findIndex((o) => o.key === key) + return idx >= 0 ? idx : 0 + } catch { + return 0 + } +} + +function saveChoice(key) { + try { + mkdirSync(dirname(cacheFile), { recursive: true }) + writeFileSync(cacheFile, key) + } catch { + // best-effort + } +} + +let selected = loadLastChoice() + +function render() { + process.stdout.write(`\x1b[${options.length}A`) + for (let i = 0; i < options.length; i++) { + const prefix = i === selected ? '\x1b[36m❯\x1b[0m' : ' ' + const text = i === selected ? `\x1b[1m${options[i].label}\x1b[0m` : options[i].label + process.stdout.write(`\x1b[2K ${prefix} ${text}\n`) + } +} + +function run() { + console.log('\n\x1b[1mSelect dev mode:\x1b[0m (↑↓ to move, Enter to confirm)\n') + for (let i = 0; i < options.length; i++) process.stdout.write('\n') + render() + + process.stdin.setRawMode(true) + process.stdin.resume() + process.stdin.setEncoding('utf8') + + process.stdin.on('data', (key) => { + if (key === '\x1b[A') { + selected = (selected - 1 + options.length) % options.length + render() + } else if (key === '\x1b[B') { + selected = (selected + 1) % options.length + render() + } else if (key === '\r' || key === '\n') { + process.stdin.setRawMode(false) + process.stdin.pause() + const choice = options[selected] + saveChoice(choice.key) + console.log(`\n\x1b[32m▶\x1b[0m Running: ${choice.command}\n`) + try { + execSync(choice.command, { stdio: 'inherit' }) + } catch { + process.exit(1) + } + } else if (key === '\x03') { + process.stdin.setRawMode(false) + console.log() + process.exit(0) + } + }) +} + +run() diff --git a/scripts/dev-serve.sh b/scripts/dev-serve.sh new file mode 100755 index 000000000..809a87855 --- /dev/null +++ b/scripts/dev-serve.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Dev server with tsx watch — monitors workspace packages for changes. +set -e + +pnpm run ensure:server-native + +exec tsx watch \ + --include 'packages/core/src/**' \ + --include 'packages/node-runtime/src/**' \ + apps/cli/src/cli.ts start --headless --no-open "$@" diff --git a/scripts/dev-server-command.mjs b/scripts/dev-server-command.mjs new file mode 100644 index 000000000..f1974bf7b --- /dev/null +++ b/scripts/dev-server-command.mjs @@ -0,0 +1,51 @@ +import { spawnSync } from 'node:child_process' + +export function createChatlabStartCommand({ serverDir, backendPort, nodeExecutable = process.execPath }) { + return { + command: nodeExecutable, + args: [ + '--watch', + '--import', + 'tsx', + 'src/cli.ts', + 'start', + '--headless', + '--no-open', + '--port', + String(backendPort), + ], + options: { + cwd: serverDir, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env }, + detached: true, + }, + } +} + +export function terminateChatlabStartProcess( + childProcess, + { platform = process.platform, killProcess = process.kill, spawnSyncFn = spawnSync } = {} +) { + if (!childProcess) return + if (childProcess.exitCode !== null || childProcess.signalCode !== null) return + + const pid = childProcess.pid + if (!pid) { + childProcess.kill('SIGTERM') + return + } + + if (platform === 'win32') { + const result = spawnSyncFn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) + if (result.status === 0) return + childProcess.kill('SIGTERM') + return + } + + try { + killProcess(-pid, 'SIGTERM') + } catch { + childProcess.kill('SIGTERM') + } +} diff --git a/scripts/dev-server-command.test.mjs b/scripts/dev-server-command.test.mjs new file mode 100644 index 000000000..61810b89b --- /dev/null +++ b/scripts/dev-server-command.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createChatlabStartCommand, terminateChatlabStartProcess } from './dev-server-command.mjs' + +test('web dev backend runs through the current Node executable with the tsx loader', () => { + const command = createChatlabStartCommand({ + rootDir: '/repo', + serverDir: '/repo/apps/cli', + coreDir: '/repo/packages/core/src', + runtimeDir: '/repo/packages/node-runtime/src', + backendPort: 3110, + nodeExecutable: '/custom/node', + }) + + assert.equal(command.command, '/custom/node') + assert.deepEqual(command.args, [ + '--watch', + '--import', + 'tsx', + 'src/cli.ts', + 'start', + '--headless', + '--no-open', + '--port', + '3110', + ]) + assert.equal(command.options.detached, true) +}) + +test('web dev backend cleanup terminates the whole POSIX process group', () => { + const killed = [] + const proc = { + pid: 12345, + exitCode: null, + signalCode: null, + kill(signal) { + killed.push(['child', signal]) + return true + }, + } + + terminateChatlabStartProcess(proc, { + platform: 'darwin', + killProcess: (pid, signal) => { + killed.push(['process', pid, signal]) + return true + }, + }) + + assert.deepEqual(killed, [['process', -12345, 'SIGTERM']]) +}) diff --git a/scripts/find-unused-i18n.mjs b/scripts/find-unused-i18n.mjs new file mode 100644 index 000000000..54d2ec1f7 --- /dev/null +++ b/scripts/find-unused-i18n.mjs @@ -0,0 +1,368 @@ +#!/usr/bin/env node +/** + * ============================================================================ + * 前端 vue-i18n 废弃 key 扫描 / 清理工具 + * ---------------------------------------------------------------------------- + * 作用:扫描 src/i18n/locales 下的语言包,找出「在整个仓库源码中没有任何引用」 + * 的 i18n key,分级后可自动删除高置信度的遗留 key,并把需人工确认的写入 + * 一份本地复查清单。语言包随功能迭代很容易残留废弃 key,本脚本可定期复用。 + * + * ── 命名空间规则 ──────────────────────────────────────────────────────────── + * src/i18n/locales//.json 的内容会挂在顶层 key `` 下 + * (见 src/i18n/locales//index.ts),所以一个叶子 key 的完整路径是 + * `.`,例如 ai.json 里的 chat.message.title → ai.chat.message.title。 + * 命名空间集合由 canonical locale 目录下的 *.json 文件名动态推导,无需手工维护。 + * + * ── 引用判定(保守:宁可漏报“废弃”,也不误删在用 key)──────────────────────── + * 扫描 src / apps / packages 下的 .ts/.tsx/.vue/.js/.mjs/.cjs,以「引号/反引号 + + * 命名空间 + 至少一段 .path」为锚点抓取 key 引用(这样能正确穿透 Vue 模板里 + * :attr="t('k')" 的嵌套引号)。一个 key 视为「已用」当且仅当满足任一: + * 1. 精确:源码出现完整 key 字面量,如 t('ai.chat.title')。 + * 2. 动态前缀:模板串 t(`ai.x.${v}`) 截断出的前缀 `ai.x.`,覆盖其下所有子 key。 + * 3. 拼接前缀:'ai.x.' + v 形成的前缀,同样覆盖子 key。 + * 4. 子树:父路径被当字面量引用(如 tm('ai.x')),则 ai.x.* 全部视为已用。 + * 局限:完全由两个变量拼出、静态前缀从不出现的 key 无法识别 → 会进入 review 兜底。 + * + * ── 置信度分级 ────────────────────────────────────────────────────────────── + * - high :同级(同一父路径)至少有一个 key 被静态使用 → 该分组在用、仅此一个 + * 未被引用,几乎可断定是遗留,--apply 会自动删除。 + * - review:整个父分组都无任何引用 → 可能整块功能被移除(多半可删),也可能是 + * 完全动态拼接访问(有风险),交人工逐个确认。 + * 含纯数字路径段(数组元素,如 quotes 列表项)一律归入 review,不自动删除。 + * + * ── 用法 ──────────────────────────────────────────────────────────────────── + * node scripts/find-unused-i18n.mjs + * 文本报告,分别列出 high / review 两组(只读,不改文件)。 + * node scripts/find-unused-i18n.mjs --json + * 机器可读 JSON:{ totalKeys, highCount, reviewCount, high{}, review{} }。 + * node scripts/find-unused-i18n.mjs --locale en-US + * 指定 canonical locale(默认 zh-CN);key 集合与原文展示以该语言为准。 + * node scripts/find-unused-i18n.mjs --apply + * 从「所有」locale 删除 high-confidence key(缺失的自动跳过),并把 review + * 清单写入 .docs/tasks/i18n-unused-review.md(带 zh-CN 原文与勾选框)。 + * + * ── 推荐复用流程 ──────────────────────────────────────────────────────────── + * 1. 先 `node scripts/find-unused-i18n.mjs` 看分级数量与样本,抽查几个 high 是否确为遗留; + * 2. 确认无误后 `--apply` 删除 high 并生成 review 清单; + * 3. 对改动的语言包跑 prettier 格式化(src/i18n/locales 下的所有 json); + * 4. 跑 `pnpm run type-check:web` 确认无破坏,再提交; + * 5. 打开 .docs/tasks/i18n-unused-review.md 逐个确认 review key 后手动删除。 + * ============================================================================ + */ + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const args = process.argv.slice(2) +const asJson = args.includes('--json') +const apply = args.includes('--apply') +const localeArgIdx = args.indexOf('--locale') +const CANONICAL_LOCALE = localeArgIdx >= 0 ? args[localeArgIdx + 1] : 'zh-CN' + +const LOCALES_DIR = path.join(ROOT, 'src', 'i18n', 'locales') +const REVIEW_DOC = path.join(ROOT, '.docs', 'tasks', 'i18n-unused-review.md') + +/** 扫描这些目录里的源码作为“使用方” */ +const SOURCE_DIRS = ['src', 'apps', 'packages'] +const SOURCE_EXT = new Set(['.ts', '.tsx', '.vue', '.js', '.mjs', '.cjs']) +const SKIP_DIR = new Set(['node_modules', 'dist', 'build', 'out', '.git', 'coverage', '.vite', 'release']) + +/** 把嵌套对象拍平成叶子 key 列表(点号路径) */ +function flattenLeafKeys(obj, prefix, out) { + for (const [k, v] of Object.entries(obj)) { + const full = prefix ? `${prefix}.${k}` : k + if (v && typeof v === 'object' && !Array.isArray(v)) { + flattenLeafKeys(v, full, out) + } else { + // 数组(如 quotes 列表)与字符串都视为叶子 + out.push(full) + } + } +} + +/** 读取 canonical locale 下所有 namespace 的叶子 key,返回 { keys, namespaces } */ +function collectAllKeys() { + const dir = path.join(LOCALES_DIR, CANONICAL_LOCALE) + if (!fs.existsSync(dir)) { + throw new Error(`Locale dir not found: ${dir}`) + } + const keys = [] + const namespaces = [] + for (const file of fs.readdirSync(dir)) { + if (!file.endsWith('.json')) continue + const namespace = path.basename(file, '.json') + namespaces.push(namespace) + const json = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) + const leaves = [] + flattenLeafKeys(json, '', leaves) + for (const leaf of leaves) keys.push({ fullKey: `${namespace}.${leaf}`, namespace }) + } + return { keys, namespaces } +} + +/** 递归收集源码文件 */ +function collectSourceFiles() { + const files = [] + const walk = (abs) => { + let entries + try { + entries = fs.readdirSync(abs, { withFileTypes: true }) + } catch { + return + } + for (const e of entries) { + if (e.isDirectory()) { + if (SKIP_DIR.has(e.name)) continue + walk(path.join(abs, e.name)) + } else if (SOURCE_EXT.has(path.extname(e.name))) { + files.push(path.join(abs, e.name)) + } + } + } + for (const d of SOURCE_DIRS) walk(path.join(ROOT, d)) + return files +} + +/** + * 从源码集合里构建引用集合。 + * + * 关键点:Vue 模板里 `:title="t('a.b.c')"` 外层双引号会吞掉内层单引号, + * 因此不能按引号配对解析字符串,而是「以命名空间锚定」直接抓 key: + * 匹配 引号/反引号 + 命名空间 + 至少一段 .path。要求至少一段路径,避免 + * 裸串 'ai'(如 mode: 'ai')把整个 ai.* 命名空间误判为已用。 + * + * 对每个命中的 base(ns + 静态路径): + * - exact 记 base(精确引用 / 动态拼接前 base)。 + * - dotPrefixes 记 base + '.',覆盖: + * - tm(base) 取子树; + * - `base.${var}` 模板动态(路径在 ${ 前被截断到 base); + * - 'base.' + var 拼接。 + */ +function buildReferenceIndex(files, namespaces) { + const exact = new Set() + const dotPrefixes = new Set() + const nsAlt = namespaces.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + const keyRe = new RegExp('[\'"`](' + nsAlt + ')((?:\\.[A-Za-z0-9_]+)+)', 'g') + + for (const file of files) { + if (file.includes(path.join('i18n', 'locales'))) continue + let text + try { + text = fs.readFileSync(file, 'utf8') + } catch { + continue + } + let m + keyRe.lastIndex = 0 + while ((m = keyRe.exec(text)) !== null) { + const base = m[1] + m[2] + exact.add(base) + dotPrefixes.add(base + '.') + } + } + return { exact, dotPrefixes: Array.from(dotPrefixes) } +} + +function isUsed(key, ref) { + if (ref.exact.has(key)) return true + for (const p of ref.dotPrefixes) { + if (key.startsWith(p)) return true + } + return false +} + +const parentOf = (fullKey) => fullKey.slice(0, fullKey.lastIndexOf('.')) +const hasNumericSegment = (fullKey) => fullKey.split('.').some((s) => /^\d+$/.test(s)) + +/** 取 json 内某点号路径的值(用于复查文档展示原文) */ +function valueAtPath(json, relPath) { + let cur = json + for (const seg of relPath.split('.')) { + if (cur == null || typeof cur !== 'object') return undefined + cur = cur[seg] + } + return cur +} + +/** 删除 json 内某叶子路径,并向上修剪变空的对象;遇到数组则放弃(返回 false) */ +function deleteLeaf(root, segments) { + const stack = [] + let cur = root + for (let i = 0; i < segments.length - 1; i++) { + const s = segments[i] + if (cur == null || typeof cur !== 'object' || Array.isArray(cur) || !(s in cur)) return false + stack.push([cur, s]) + cur = cur[s] + } + const last = segments[segments.length - 1] + if (cur == null || typeof cur !== 'object' || Array.isArray(cur) || !(last in cur)) return false + delete cur[last] + for (let i = stack.length - 1; i >= 0; i--) { + const [parent, key] = stack[i] + const v = parent[key] + if (v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) delete parent[key] + else break + } + return true +} + +/** 在所有 locale 里删除给定的 high-confidence full key 列表 */ +function applyDeletions(highKeys) { + const locales = fs + .readdirSync(LOCALES_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + + const removedPerLocale = {} + for (const locale of locales) { + const dir = path.join(LOCALES_DIR, locale) + const touched = new Map() // namespace -> json + let removed = 0 + for (const fullKey of highKeys) { + const namespace = fullKey.slice(0, fullKey.indexOf('.')) + const relPath = fullKey.slice(namespace.length + 1) + const file = path.join(dir, `${namespace}.json`) + if (!fs.existsSync(file)) continue + let json = touched.get(namespace) + if (!json) { + json = JSON.parse(fs.readFileSync(file, 'utf8')) + touched.set(namespace, json) + } + if (deleteLeaf(json, relPath.split('.'))) removed++ + } + for (const [namespace, json] of touched) { + fs.writeFileSync(path.join(dir, `${namespace}.json`), JSON.stringify(json, null, 2) + '\n') + } + removedPerLocale[locale] = removed + } + return removedPerLocale +} + +/** 生成复查文档(review 级别清单 + 当前 zh-CN 原文) */ +function writeReviewDoc(reviewKeys, canonicalJsonByNs, stats) { + const byNs = {} + for (const k of reviewKeys) { + const ns = k.slice(0, k.indexOf('.')) + ;(byNs[ns] ||= []).push(k) + } + const lines = [] + lines.push('# i18n 待复查未使用 key') + lines.push('') + lines.push(`> 由 \`scripts/find-unused-i18n.mjs --apply\` 生成(canonical: ${CANONICAL_LOCALE})。`) + lines.push('>') + lines.push( + '> 这些 key 在源码中**完全没有静态/动态前缀引用**,且**整个父分组都无引用**——可能是整块功能被移除(多半可删),也可能被完全动态拼接访问(有风险)。请逐个确认后删除。' + ) + lines.push('>') + lines.push(`> 高置信度(同级有在用 key)的 ${stats.highCount} 个已自动从各 locale 删除,不在此列表。`) + lines.push('') + lines.push(`合计待复查:**${reviewKeys.length}** 个。`) + lines.push('') + for (const ns of Object.keys(byNs).sort()) { + const list = byNs[ns].sort() + lines.push(`## ${ns} (${list.length})`) + lines.push('') + lines.push('| key | zh-CN 原文 | 已确认可删 |') + lines.push('| --- | --- | --- |') + for (const fullKey of list) { + const relPath = fullKey.slice(ns.length + 1) + const val = valueAtPath(canonicalJsonByNs[ns], relPath) + const text = + typeof val === 'string' + ? val.replace(/\|/g, '\\|').replace(/\n/g, ' ') + : Array.isArray(val) + ? `[数组 ${val.length} 项]` + : '' + lines.push(`| \`${fullKey}\` | ${text} | [ ] |`) + } + lines.push('') + } + fs.mkdirSync(path.dirname(REVIEW_DOC), { recursive: true }) + fs.writeFileSync(REVIEW_DOC, lines.join('\n')) +} + +function classify(keys, ref) { + // 统计每个父路径下是否存在「在用」的 key + const parentHasUsed = new Map() + for (const { fullKey } of keys) { + const p = parentOf(fullKey) + if (!parentHasUsed.has(p)) parentHasUsed.set(p, false) + if (isUsed(fullKey, ref)) parentHasUsed.set(p, true) + } + const high = [] + const review = [] + for (const { fullKey } of keys) { + if (isUsed(fullKey, ref)) continue + if (!hasNumericSegment(fullKey) && parentHasUsed.get(parentOf(fullKey))) high.push(fullKey) + else review.push(fullKey) + } + return { high, review } +} + +function groupByNs(list) { + const byNs = {} + for (const k of list) (byNs[k.slice(0, k.indexOf('.'))] ||= []).push(k) + for (const ns of Object.keys(byNs)) byNs[ns].sort() + return byNs +} + +function main() { + const { keys, namespaces } = collectAllKeys() + const files = collectSourceFiles() + const ref = buildReferenceIndex(files, namespaces) + const { high, review } = classify(keys, ref) + + if (asJson) { + process.stdout.write( + JSON.stringify( + { + canonicalLocale: CANONICAL_LOCALE, + totalKeys: keys.length, + highCount: high.length, + reviewCount: review.length, + high: groupByNs(high), + review: groupByNs(review), + }, + null, + 2 + ) + '\n' + ) + return + } + + if (apply) { + const canonicalJsonByNs = {} + for (const ns of namespaces) { + canonicalJsonByNs[ns] = JSON.parse( + fs.readFileSync(path.join(LOCALES_DIR, CANONICAL_LOCALE, `${ns}.json`), 'utf8') + ) + } + writeReviewDoc(review, canonicalJsonByNs, { highCount: high.length }) + const removed = applyDeletions(high) + console.log(`已删除 high-confidence key:${high.length} 个 / locale`) + for (const [locale, n] of Object.entries(removed)) console.log(` ${locale}: 实删 ${n}`) + console.log(`复查清单(${review.length} 个)已写入: ${path.relative(ROOT, REVIEW_DOC)}`) + console.log('请对修改的语言包运行 prettier 后提交。') + return + } + + console.log(`i18n 未使用 key 扫描(canonical: ${CANONICAL_LOCALE})`) + console.log( + `扫描源码文件: ${files.length},叶子 key 总数: ${keys.length},high-confidence: ${high.length},待复查: ${review.length}\n` + ) + const printGroup = (title, byNs) => { + console.log(`==== ${title} ====`) + for (const ns of Object.keys(byNs).sort()) { + console.log(`【${ns}】(${byNs[ns].length})`) + for (const k of byNs[ns]) console.log(` ${k}`) + } + console.log('') + } + printGroup('high-confidence(同级有在用 key,建议删除)', groupByNs(high)) + printGroup('review(整组无引用,需人工确认)', groupByNs(review)) +} + +main() diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 000000000..a23ea91c2 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import { readdirSync, statSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const SKIP_DIRS = new Set([ + '.git', + '.docs', + 'node_modules', + 'dist', + 'dist-web', + 'out', + 'build', + 'coverage', + '.vitepress', +]) + +const TEST_FILE_RE = /\.(?:test|spec)\.(?:ts|tsx|js|jsx|mjs|mts|cjs|cts)$/ +const SUPPORTED_NODE_MAJOR = 24 + +function normalizePath(filePath) { + return filePath.split(sep).join('/') +} + +export function filterDefaultTestFiles(files) { + return files + .map(normalizePath) + .filter((file) => TEST_FILE_RE.test(file)) + .filter((file) => !file.startsWith('tests/e2e/')) + .filter((file) => !file.includes('/smoke/')) + .filter((file) => !file.includes('.smoke.test.')) + .filter((file) => !file.includes('.e2e.test.')) +} + +function collectFiles(rootDir, dir = rootDir) { + const entries = readdirSync(dir, { withFileTypes: true }) + const files = [] + + for (const entry of entries) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue + files.push(...collectFiles(rootDir, join(dir, entry.name))) + continue + } + + if (!entry.isFile()) continue + + const filePath = join(dir, entry.name) + files.push(normalizePath(relative(rootDir, filePath))) + } + + return files +} + +export function collectDefaultTestFiles(rootDir = process.cwd()) { + if (!statSync(rootDir).isDirectory()) { + throw new Error(`Test root is not a directory: ${rootDir}`) + } + return filterDefaultTestFiles(collectFiles(rootDir)).sort() +} + +export function buildNodeTestArgs(testArgs) { + return ['--experimental-test-module-mocks', '--import', 'tsx', '--test', ...testArgs] +} + +export function checkSupportedNodeVersion(version = process.versions.node) { + const major = Number.parseInt(version.split('.')[0] ?? '', 10) + if (major === SUPPORTED_NODE_MAJOR) return { ok: true } + return { + ok: false, + message: `ChatLab tests require Node.js >=24 <25. Current Node.js is ${version}. Switch to Node 24 before running tests.`, + } +} + +function run() { + const nodeVersion = checkSupportedNodeVersion() + if (!nodeVersion.ok) { + console.error(nodeVersion.message) + process.exit(1) + } + + const explicitArgs = process.argv.slice(2) + const testArgs = explicitArgs.length > 0 ? explicitArgs : collectDefaultTestFiles() + + if (testArgs.length === 0) { + console.error('No test files found.') + process.exit(1) + } + + if (explicitArgs.length === 0) { + console.log(`Running ${testArgs.length} default test files.`) + } + + const result = spawnSync(process.execPath, buildNodeTestArgs(testArgs), { + stdio: 'inherit', + }) + + if (result.error) { + throw result.error + } + + process.exit(result.status ?? 1) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + run() +} diff --git a/scripts/run-tests.test.mjs b/scripts/run-tests.test.mjs new file mode 100644 index 000000000..fabbabb5c --- /dev/null +++ b/scripts/run-tests.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { buildNodeTestArgs, checkSupportedNodeVersion, filterDefaultTestFiles } from './run-tests.mjs' + +test('default test collection excludes e2e, smoke, and real external tests', () => { + const files = [ + 'apps/cli/src/ai/chat-command.test.ts', + 'tests/chart-runtime/agent-chart-flow.test.mts', + 'tests/chart-runtime/render-chart.integration.test.ts', + 'tests/e2e/helpers/app-launcher.test.js', + 'tests/e2e/smoke/chart-runtime.smoke.test.js', + 'tests/chart-runtime/real-llm-chart-flow.e2e.test.ts', + 'tests/e2e/helpers/app-launcher.js', + ] + + assert.deepEqual(filterDefaultTestFiles(files), [ + 'apps/cli/src/ai/chat-command.test.ts', + 'tests/chart-runtime/agent-chart-flow.test.mts', + 'tests/chart-runtime/render-chart.integration.test.ts', + ]) +}) + +test('explicit test arguments are passed through without default exclusions', () => { + assert.deepEqual(buildNodeTestArgs(['tests/e2e/helpers/app-launcher.test.js']), [ + '--experimental-test-module-mocks', + '--import', + 'tsx', + '--test', + 'tests/e2e/helpers/app-launcher.test.js', + ]) +}) + +test('node version check rejects unsupported test runtimes before native modules load', () => { + assert.deepEqual(checkSupportedNodeVersion('24.2.0'), { ok: true }) + assert.deepEqual(checkSupportedNodeVersion('22.20.0'), { + ok: false, + message: + 'ChatLab tests require Node.js >=24 <25. Current Node.js is 22.20.0. Switch to Node 24 before running tests.', + }) +}) diff --git a/src/App.vue b/src/App.vue index 148f8b65d..b376a5800 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,72 +1,211 @@ diff --git a/src/assets/docs/agreement_en.md b/src/assets/docs/agreement_en.md index 356f6049c..106427770 100644 --- a/src/assets/docs/agreement_en.md +++ b/src/assets/docs/agreement_en.md @@ -1,39 +1,43 @@ -Welcome to ChatLab, a free, open-source, and local-first application dedicated to chat record analysis. +Welcome to ChatLab, an **open-source, local-first tool for analyzing chat records**. -We want you to have full control over your chat data, provided it is done within a legal and compliant framework. Please read the following terms carefully: +We believe you should be able to understand your own social data. But chat histories do not belong to you alone; they also carry the words and trust of your friends, family, and colleagues. Please read the following terms carefully before using this tool. ## 1. Core Functionality & Independence -- **Analysis Only, No Exporting**: This tool is designed solely for analyzing chat records that have already been exported. We do not provide any decryption, packet capture, or extraction tools. Obtaining data from third-party chat applications is your personal action. -- **Independent Third-Party Tool**: This is an independent open-source project and has no affiliation with any third-party companies or organizations. +- **Analysis Only, No Exporting**: This tool is intended only for analyzing chat records that have already been exported. We do not provide any decryption, packet capture, or export tools. Obtaining data from third-party chat platforms is your own action and responsibility. +- **Independent Third-Party Tool**: This project is an independent open-source project and **is not affiliated with any chat platform, company, or organization**. ## 2. Data Privacy & Security -- **Purely Local Analysis**: By default, the storage and analysis of chat records are performed entirely on your local device with **zero uploading**. -- **AI Feature Exception**: If you voluntarily enable AI features, relevant chat data will be sent to your configured third-party model provider (e.g., DeepSeek, Tongyi Qianwen, etc.). **Do not send information involving state secrets or highly sensitive personal privacy**, at your own risk. -- **Functional Networking**: The application connects to the network to fetch update prompts, help documentation, and system prompt configurations. This operation is solely for obtaining the latest information and does not upload any data. -- **Anonymous Usage Statistics**: To optimize the product, the software collects **non-sensitive** data (such as version numbers and operating system types). This data contains no personal information and is used only to assist in future development decisions. You can disable this in the settings at any time. +- **Purely Local Analysis**: By default, chat records are stored and analyzed entirely on your device with **zero uploads**. +- **AI Feature Exception**: If you choose to enable AI features, relevant chat data will be sent to the third-party model provider you configure (such as OpenAI, DeepSeek, or another provider). Before using this feature, assess how sensitive your data is and **avoid sending highly private content**. The privacy practices of that third-party provider are independent from this project, so please review their policies yourself. +- **Functional Networking**: The app connects to the internet to fetch update notices, usage instructions, and configuration data. No local data is uploaded in this process. +- **Anonymous Usage Statistics**: To improve the product, the app may collect **non-sensitive** data (such as version numbers and operating system type). This data does not include personal information, and you can disable this feature at any time in Settings. ## 3. Data Authorization & Usage Restrictions -- **Lawful Authorization Principle**: You may only process chat records that you **personally participated in**. If the analyzed content involves the privacy of others (especially **private chat partners or group members**), please ensure you have obtained informed consent from the relevant parties. +Chat histories do not contain only your data. They also include what the other people in the conversation said. Please follow these principles when using this tool: + +- **Lawful Authorization Principle**: You may only process chat records in which you **personally participated**. Other participants in those conversations also have privacy rights. If your analysis involves the privacy of identifiable individuals, especially **private chat contacts or group members**, you must ensure that you have obtained their informed consent. - **Forbidden Uses**: -- **Strictly forbidden** to use for stealing, monitoring, or analyzing unauthorized privacy of others. -- **Strictly forbidden** to analyze chat records obtained through illegal means (such as illegal intrusion, account theft, or data hijacking). -- **Strictly forbidden** to use analysis results for harassment, fraud, doxing (human flesh searching), or any actions that infringe upon the rights of others. -- **Strictly forbidden** to use analysis content generated by this software (including AI-generated content) to fabricate or spread false or misleading information. + - It is **strictly prohibited** to use this tool to steal, monitor, or analyze another person's private information without authorization. + - It is **strictly prohibited** to analyze chat records obtained through illegal means, including unauthorized intrusion, account theft, or data hijacking. + - It is **strictly prohibited** to use analysis results for harassment, fraud, doxing, or any other conduct that infringes on the rights of others. + - It is **strictly prohibited** to use analysis content generated by this software, including AI-generated content, to fabricate or spread false or misleading information. ## 4. Risk Warning -- **Official Channels**: Please download this software only through [chatlab.fun](https://chatlab.fun) or [GitHub Releases](https://github.com/hellodigua/ChatLab/releases). -- **Supply Chain Risk**: The source code of this project is completely open-source and can be repackaged by anyone. Non-official versions shared by others may contain malicious code that could lead to the leakage of your API tokens, chat records, or local data. Please stay vigilant. -- **Accuracy of Results**: Analysis results generated by the software and AI may contain errors or "hallucinations". They are for reference only and should not be used as legal evidence or as a basis for formal decision-making. +- **Official Channels**: Please download this software only from [chatlab.fun](https://chatlab.fun) or [GitHub Releases](https://github.com/ChatLab/ChatLab/releases). +- **Supply Chain Risk**: This project is fully open-source, so anyone can repackage it. Unofficial versions shared by others may contain malicious code that could expose your API tokens, chat records, or local data. Please stay vigilant. +- **Accuracy of Results**: Analysis results generated by the software or AI may contain errors or hallucinations. They are for reference only and should not be used as legal evidence or as the basis for formal decisions. ## 5. Disclaimer -- **Software Purpose**: This software is intended for technical research, learning, and communication purposes only. -- **Platform Compliance**: Users assume full responsibility for the compliance of their actions in obtaining chat records. The author assumes no liability if an account is restricted due to analysis behavior violating the original data source platform's terms of service. -- **Sole Responsibility**: All consequences arising from the use of this software (including but not limited to data loss, privacy disputes, and legal liabilities) shall be borne solely by the user. -- **Acceptance of Statement**: By downloading, installing, or using this software, you indicate that you have read, understood, and agreed to all terms of this statement. If you do not agree, please stop using the software immediately and delete all related programs. +- **Software Purpose**: This software is provided for technical research, learning, and communication purposes only. +- **Platform Compliance**: You are solely responsible for ensuring that your acquisition and analysis of chat records complies with the terms of the original platform. The author assumes no liability if your account is restricted because your actions violate those terms. +- **Privacy Responsibility**: If the way you use this tool infringes another person's privacy or other lawful rights, you are solely responsible for the consequences. +- **Sole Responsibility**: All consequences arising from the use of this software, including but not limited to data loss, privacy disputes, and legal liability, are your sole responsibility. +- **Applicable Law**: This statement is governed by the laws applicable in your jurisdiction. If any part of this statement conflicts with mandatory local law, the local law shall prevail. +- **Acceptance of Statement**: By downloading, installing, or using this software, you acknowledge that you have read, understood, and agreed to all terms of this statement. If you do not agree, please stop using the software immediately and delete all related files. -Revised Date: January 7, 2026 +Last Updated: March 14, 2026 diff --git a/src/assets/docs/agreement_ja.md b/src/assets/docs/agreement_ja.md new file mode 100644 index 000000000..4fbe41173 --- /dev/null +++ b/src/assets/docs/agreement_ja.md @@ -0,0 +1,43 @@ +ChatLab へようこそ。ChatLab は、**オープンソースかつローカル完結型のチャット履歴分析ツール**です。 + +私たちは、あなたが自分のソーシャルデータを理解する権利を尊重しています。ただし、チャット履歴はあなただけのものではなく、友人、家族、同僚の言葉や信頼も含んでいます。本ツールを利用する前に、以下の内容をよくお読みください。 + +## 1. コア機能と独立性 + +- **分析専用で、取得機能は提供しません**:本ツールは、すでにエクスポート済みのチャット履歴を分析するためのものです。復号、パケットキャプチャ、エクスポートツールは提供していません。第三者のチャットプラットフォームからデータを取得する行為は、利用者自身の判断と責任で行ってください。 +- **独立した第三者ツールです**:本プロジェクトは独立したオープンソースプロジェクトであり、**いかなるチャットプラットフォーム、企業、団体とも関係ありません**。 + +## 2. データのプライバシーとセキュリティ + +- **ローカル完結の分析**:初期設定では、チャット履歴の保存と分析はすべて端末内で行われ、**外部へアップロードされません**。 +- **AI 機能利用時の例外**:AI 機能を有効にした場合、関連するチャットデータは、利用者が設定した外部モデル提供事業者(OpenAI、DeepSeek、その他の事業者など)に送信されます。利用前にデータの機微性を十分に確認し、**特に私的性の高い内容は送信しないでください**。当該事業者のプライバシーポリシーは本プロジェクトとは別に定められているため、各自で確認してください。 +- **機能上必要な通信**:アプリは、更新案内、利用ガイド、設定情報を取得するために通信を行います。この過程でローカルデータが送信されることはありません。 +- **匿名の利用統計**:製品改善のため、バージョン番号や OS 種別などの**非機微情報**を収集する場合があります。これらのデータには個人情報は含まれず、設定画面からいつでも無効にできます。 + +## 3. データ利用の範囲と制限 + +チャット履歴に含まれるのは、あなたのデータだけではありません。会話相手の発言や情報も含まれます。本ツールを利用する際は、以下の原則を守ってください。 + +- **正当な権限があるデータのみ扱ってください**:処理できるのは、**自分が参加した**チャット履歴に限られます。その履歴に含まれる他の参加者にもプライバシー権があります。分析対象が特定の個人のプライバシー、特に**個人チャットの相手やグループチャットの参加者**に及ぶ場合は、必ず事前に十分な説明と同意を得てください。 +- **禁止される用途**: + - 他者の未承認のプライバシーを窃取、監視、または分析する目的での使用は**厳禁**です。 + - 違法な手段(不正アクセス、アカウント窃取、データハイジャックなど)で取得したチャット記録の分析は**厳禁**です。 + - 分析結果を嫌がらせ、詐欺、晒し行為(人肉検索)、または他者の権利を侵害するいかなる行為に使用することは**厳禁**です。 + - 本ソフトウェアが生成した分析コンテンツ(AI 生成コンテンツを含む)を使用して、虚偽または誤解を招く情報を捏造または拡散することは**厳禁**です。 + +## 4. 注意事項 + +- **公式配布元のみ利用してください**:本ソフトウェアは [chatlab.fun](https://chatlab.fun) または [GitHub Releases](https://github.com/ChatLab/ChatLab/releases) からのみ入手してください。 +- **サプライチェーンリスクに注意してください**:本プロジェクトは完全オープンソースのため、第三者が再配布版を作成できます。非公式版には悪意あるコードが含まれる可能性があり、API トークン、チャット履歴、ローカルデータの漏えいにつながるおそれがあります。 +- **結果の正確性は保証されません**:ソフトウェアや AI が生成する分析結果には、誤りやハルシネーションが含まれる可能性があります。参考情報として扱い、法的証拠や正式な判断材料には使用しないでください。 + +## 5. 免責事項 + +- **利用目的**:本ソフトウェアは、技術研究、学習、交流を目的として提供されています。 +- **各プラットフォームの規約遵守は利用者の責任です**:チャット履歴の取得や分析が、元のサービスの利用規約に抵触した結果としてアカウント制限などが発生しても、作者は責任を負いません。 +- **プライバシー侵害に関する責任**:本ツールの利用方法によって他者のプライバシー権その他の適法な権利を侵害した場合、その責任は利用者自身が負います。 +- **利用は自己責任です**:本ソフトウェアの利用によって生じたいかなる結果も、利用者自身の責任となります。これにはデータ損失、プライバシー上の争い、法的責任などが含まれます。 +- **準拠法**:本声明には、利用者が所在する法域で適用される法令が適用されます。本声明の内容が現地の強行法規と抵触する場合は、当該法令が優先されます。 +- **本声明への同意**:本ソフトウェアをダウンロード、インストール、または使用した時点で、本声明の内容を読み、理解し、同意したものとみなされます。同意できない場合は、直ちに使用を中止し、関連ファイルを削除してください。 + +改訂日:2026年3月14日 diff --git a/src/assets/docs/agreement_zh.md b/src/assets/docs/agreement_zh.md index f1e657352..4fd79f4e6 100644 --- a/src/assets/docs/agreement_zh.md +++ b/src/assets/docs/agreement_zh.md @@ -1,22 +1,24 @@ -欢迎使用 ChatLab,它是一个免费、开源、本地化的,专注于分析聊天记录的应用。 +欢迎使用 ChatLab,它是一款**开源、本地化的聊天记录分析工具**。 -我们希望你能自由地掌控你的聊天数据,但必须是在合法、合规的前提下。请务必阅读以下条款: +我们相信,你有权理解自己的社交数据。但聊天记录不只属于你,它同时承载着你朋友、家人、同事的话语与信任。因此,在使用本工具之前,请认真阅读以下条款。 ## 1. 核心功能与独立性 -- **仅分析,不导出**:本工具仅用于对已导出的聊天记录进行分析。我们不提供任何解密、抓包或导出工具,从第三方聊天软件获取数据是您的个人行为。 -- **第三方独立工具**:本项目为独立开源项目,与任何第三方公司或组织等无关。 +- **仅分析,不导出**:本工具仅用于对已导出的聊天记录进行分析。我们不提供任何解密、抓包或导出工具,从第三方聊天软件获取数据是你的个人行为。 +- **第三方独立工具**:本项目为独立开源项目,**与任何聊天平台、公司或组织等无关联**。 ## 2. 数据隐私与安全 -- **纯本地分析**:默认情况下,聊天记录的存储与分析均在您的设备本地完成,**零上传**。 -- **AI 功能例外**:若您主动启用 AI 功能,相关聊天数据将发送至您配置的第三方模型服务商(如 DeepSeek/通义千问等)。**请勿发送涉及国家秘密或高度敏感的个人隐私信息**,风险自担。 -- **功能性联网**:应用会联网获取更新提示、帮助说明及系统提示词等配置,此操作仅用于获取最新信息,不会上传任何数据。 -- **匿名使用统计**:为了优化产品,软件会收集**非敏感**的数据(如版本号、操作系统类型等)。此数据不包含任何个人信息,仅用于辅助后续开发决策,你可在设置中随时关闭。 +- **纯本地分析**:默认情况下,聊天记录的存储与分析均在你的设备本地完成,**零上传**。 +- **AI 功能例外**:若你主动启用 AI 功能,相关聊天数据将发送至你配置的第三方模型服务商(如 OpenAI、DeepSeek 或其他服务商)。请在使用前自行评估数据的敏感程度,**避免发送高度私密的内容**。该第三方服务商的隐私政策独立于本项目,请自行查阅。 +- **功能性联网**:应用会通过网络获取更新提示、使用说明及配置信息。此过程不会上传任何本地数据。 +- **匿名使用统计**:为了优化产品,软件会收集**非敏感**的数据(如版本号、操作系统类型等)。这些数据不含任何个人信息,你可以在设置中随时关闭此功能。 ## 3. 数据授权与使用限制 -- **合法授权原则**:您仅可处理您**本人参与**的聊天记录。若分析内容涉及他人隐私(尤其是**私聊对象、群聊成员**),请务必确保已获得相关人员的知情同意。 +聊天记录中不只有你的数据——它同样包含对话另一方的内容。请在使用时遵守以下原则: + +- **合法授权原则**:你仅可处理你**本人参与**的聊天记录。聊天记录中涉及的其他参与者同样拥有隐私权,若分析内容涉及特定个人隐私(**私聊对象、群聊成员**),请务必确保已获得相关人员的知情同意。 - **禁止非法用途**: - **严禁**用于窃取、监控或分析未经授权的他人隐私。 - **严禁**分析通过非法手段(如非法入侵、账号窃取、数据劫持等)获取的聊天记录。 @@ -25,15 +27,17 @@ ## 4. 风险警告 -- **官方渠道**:请仅通过 [chatlab.fun](https://chatlab.fun) 或 [GitHub Release](https://github.com/hellodigua/ChatLab/releases) 下载本软件。 -- **供应链风险**:本项目代码完全开源,任何人均可二次打包。他人分享的非官方版本可能会植入恶意代码,导致您的 API Token、聊天记录或本地数据泄露,请务必保持警惕。 +- **官方渠道**:请仅通过 [chatlab.fun](https://chatlab.fun) 或 [GitHub Release](https://github.com/ChatLab/ChatLab/releases) 下载本软件。 +- **供应链风险**:本项目代码完全开源,任何人均可二次打包。他人分享的非官方版本可能会植入恶意代码,导致你的 API Token、聊天记录或本地数据泄露,请务必保持警惕。 - **结果准确性**:软件和 AI 生成的分析结果可能存在错误或"幻觉",仅供参考,不应作为法律证据或正式决策依据。 ## 5. 免责声明 - **软件用途**:本软件仅供技术研究、学习与交流目的使用。 - **平台合规性**:用户需自行承担获取聊天记录行为的合规性。若因分析行为违反了原始数据来源平台的服务条款而导致账号受限,作者不承担任何责任。 +- **隐私责任**:若因你对本工具的使用方式侵犯了他人隐私权或其他合法权益,相关责任由你自行承担。 - **责任自负**:使用本软件产生的一切后果(包括但不限于数据丢失、隐私纠纷、法律责任)均由用户自行承担。 -- **接受声明**:下载、安装或使用本软件,即表示您已阅读、理解并同意本声明的所有条款。如不同意,请立即停止使用并删除相关程序。 +- **适用法律**:本声明适用你所在司法管辖区的相关法律。如本声明与当地强制性法律规定存在冲突,以当地法律为准。 +- **接受声明**:下载、安装或使用本软件,即表示你已阅读、理解并同意本声明的所有条款。如不同意,请立即停止使用并删除相关程序。 -修订日期:2026-01-07 +修订日期:2026-03-14 diff --git a/src/assets/docs/agreement_zh_tw.md b/src/assets/docs/agreement_zh_tw.md new file mode 100644 index 000000000..d812b5bff --- /dev/null +++ b/src/assets/docs/agreement_zh_tw.md @@ -0,0 +1,43 @@ +歡迎使用 ChatLab。這是一款**開源、以本機優先的聊天紀錄分析工具**。 + +我們相信,你有權理解自己的社交資料。但聊天紀錄不只屬於你,也承載著朋友、家人、同事的話語與信任。因此,在使用本工具前,請先仔細閱讀以下條款。 + +## 1. 核心功能與獨立性 + +- **僅分析,不提供匯出能力**:本工具僅用於分析你已自行匯出的聊天紀錄。我們不提供任何解密、抓包或匯出工具;從第三方聊天平台取得資料屬於你自己的行為與責任。 +- **獨立第三方工具**:本專案為獨立開源專案,**與任何聊天平台、公司或組織皆無關聯**。 + +## 2. 資料隱私與安全 + +- **純本機分析**:預設情況下,聊天紀錄的儲存與分析都在你的裝置本機完成,**零上傳**。 +- **AI 功能例外**:若你主動啟用 AI 功能,相關聊天資料會傳送至你設定的第三方模型服務商(如 OpenAI、DeepSeek 或其他服務商)。使用前請自行評估資料的敏感程度,**避免傳送高度私密的內容**。該第三方服務商的隱私政策獨立於本專案,請自行查閱。 +- **功能性連網**:應用程式會透過網路取得更新通知、使用說明及設定資訊。此過程不會上傳任何本機資料。 +- **匿名使用統計**:為了改善產品,軟體可能收集**非敏感**資料(如版本號、作業系統類型等)。這些資料不包含任何個人資訊,你也可以在設定中隨時關閉此功能。 + +## 3. 資料授權與使用限制 + +聊天紀錄中不只有你的資料,也包含對話另一方的內容。使用本工具時,請遵守以下原則: + +- **合法授權原則**:你僅可處理你**本人參與**的聊天紀錄。聊天紀錄中的其他參與者同樣享有隱私權;若分析內容涉及特定個人的隱私,尤其是**私聊對象或群聊成員**,請務必先取得相關人士的知情同意。 +- **禁止非法用途**: + - **嚴禁**用於竊取、監控或分析未經授權的他人隱私。 + - **嚴禁**分析透過非法手段(如非法入侵、帳號竊取、資料劫持等)取得的聊天記錄。 + - **嚴禁**將分析結果用於騷擾、詐騙、人肉搜尋或任何侵犯他人權益的行為。 + - **嚴禁**利用本軟體產生的分析內容(含 AI 產生內容)編造、傳播虛假資訊或誤導性資訊。 + +## 4. 風險警告 + +- **官方下載管道**:請僅透過 [chatlab.fun](https://chatlab.fun) 或 [GitHub Releases](https://github.com/ChatLab/ChatLab/releases) 下載本軟體。 +- **供應鏈風險**:本專案程式碼完全開源,任何人都可再次打包。非官方版本可能植入惡意程式碼,導致你的 API Token、聊天紀錄或本機資料外洩,請務必提高警覺。 +- **結果準確性**:軟體與 AI 產生的分析結果可能有誤或出現「幻覺」,僅供參考,不應作為法律證據或正式決策依據。 + +## 5. 免責聲明 + +- **軟體用途**:本軟體僅供技術研究、學習與交流使用。 +- **平台合規性**:取得聊天紀錄行為的合規性由使用者自行負責。若分析行為違反原始資料來源平台的服務條款,導致帳號受限或其他後果,作者不承擔任何責任。 +- **隱私責任**:若你對本工具的使用方式侵犯了他人的隱私權或其他合法權益,相關責任由你自行承擔。 +- **責任自負**:使用本軟體所產生的一切後果,包括但不限於資料遺失、隱私糾紛或法律責任,均由使用者自行承擔。 +- **適用法律**:本聲明適用你所在司法管轄區的相關法律;若本聲明與當地強制性法律規定牴觸,應以當地法律為準。 +- **接受聲明**:下載、安裝或使用本軟體,即表示你已閱讀、理解並同意本聲明全部內容;若不同意,請立即停止使用並刪除相關程式。 + +修訂日期:2026-03-14 diff --git a/src/assets/images/logo.svg b/src/assets/images/logo.svg new file mode 100644 index 000000000..1a89ebf19 --- /dev/null +++ b/src/assets/images/logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/styles/main.css b/src/assets/styles/main.css index 4f07ecf05..efc8912f2 100644 --- a/src/assets/styles/main.css +++ b/src/assets/styles/main.css @@ -1,4 +1,5 @@ @import 'tailwindcss'; +@source "../../packages"; @import '@nuxt/ui'; @import './markdown.css'; @@ -15,6 +16,14 @@ --color-pink-800: #a01d42; --color-pink-900: #84173b; --color-pink-950: #4a071c; + + /* 页面背景 - 语义化主题 Token,未来可通过设置动态覆盖 */ + --color-page-bg: #f9fafb; + --color-page-dark: #181818; + + /* 卡片背景 - 语义化主题 Token,未来可通过设置动态覆盖 */ + --color-card-bg: #ffffff; + --color-card-dark: rgba(255, 255, 255, 0.03); } /* 全局滚动条样式 */ @@ -47,3 +56,20 @@ .dark ::-webkit-scrollbar-thumb:hover { background-color: var(--color-gray-600); } + +/* 标题栏安全区域高度 - 页面顶部 padding 需要避开此区域 + * macOS: 红绿灯在左侧由 Sidebar 处理,右侧内容区无遮挡,使用较小值 + * Windows/Linux: 窗口控制按钮在右上角占据 32px 高度,需要完整避开 */ +:root { + --titlebar-area-height: 1rem; /* 16px - macOS */ +} + +.platform-windows, +.platform-linux { + --titlebar-area-height: 2rem; /* 32px - 匹配 titleBarOverlay height */ +} + +/* 禁用窗口拖拽区域 - 用于弹窗等需要正常交互的元素 */ +.app-region-no-drag { + -webkit-app-region: no-drag; +} diff --git a/src/assets/styles/markdown.css b/src/assets/styles/markdown.css index 3f03197bd..52c467670 100644 --- a/src/assets/styles/markdown.css +++ b/src/assets/styles/markdown.css @@ -4,82 +4,82 @@ * 使用方式:在组件的 diff --git a/src/components/AIChat/GlobalTaskBar.vue b/src/components/AIChat/GlobalTaskBar.vue new file mode 100644 index 000000000..6d72d622a --- /dev/null +++ b/src/components/AIChat/GlobalTaskBar.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/components/AIChat/assistant/AssistantCard.vue b/src/components/AIChat/assistant/AssistantCard.vue new file mode 100644 index 000000000..b86e0569a --- /dev/null +++ b/src/components/AIChat/assistant/AssistantCard.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/components/AIChat/assistant/AssistantConfigModal.vue b/src/components/AIChat/assistant/AssistantConfigModal.vue new file mode 100644 index 000000000..72b1e67ae --- /dev/null +++ b/src/components/AIChat/assistant/AssistantConfigModal.vue @@ -0,0 +1,538 @@ + + + diff --git a/src/components/AIChat/assistant/AssistantInlineBar.vue b/src/components/AIChat/assistant/AssistantInlineBar.vue new file mode 100644 index 000000000..728491d52 --- /dev/null +++ b/src/components/AIChat/assistant/AssistantInlineBar.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/src/components/AIChat/assistant/AssistantMarketModal.vue b/src/components/AIChat/assistant/AssistantMarketModal.vue new file mode 100644 index 000000000..495a6af02 --- /dev/null +++ b/src/components/AIChat/assistant/AssistantMarketModal.vue @@ -0,0 +1,343 @@ + + + diff --git a/src/components/AIChat/assistant/AssistantSelector.vue b/src/components/AIChat/assistant/AssistantSelector.vue new file mode 100644 index 000000000..73d42634c --- /dev/null +++ b/src/components/AIChat/assistant/AssistantSelector.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/src/components/AIChat/chat/AIThinkingIndicator.vue b/src/components/AIChat/chat/AIThinkingIndicator.vue new file mode 100644 index 000000000..14f6d9da9 --- /dev/null +++ b/src/components/AIChat/chat/AIThinkingIndicator.vue @@ -0,0 +1,125 @@ + + + diff --git a/src/components/AIChat/chat/ChartBlockRenderer.vue b/src/components/AIChat/chat/ChartBlockRenderer.vue new file mode 100644 index 000000000..00fb2fff8 --- /dev/null +++ b/src/components/AIChat/chat/ChartBlockRenderer.vue @@ -0,0 +1,206 @@ + + + diff --git a/src/components/AIChat/chat/ChatMessage.planValidation.test.ts b/src/components/AIChat/chat/ChatMessage.planValidation.test.ts new file mode 100644 index 000000000..ee72123f8 --- /dev/null +++ b/src/components/AIChat/chat/ChatMessage.planValidation.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' + +describe('ChatMessage plan validation rendering', () => { + it('lets assistant messages fill the available row while keeping user bubbles constrained', () => { + const source = readFileSync(new URL('./ChatMessage.vue', import.meta.url), 'utf8') + + assert.ok( + source.includes("isUser && !isEditing ? 'max-w-[85%] min-w-0' : 'w-full min-w-0'"), + 'assistant messages should use full width while user messages keep the existing max width' + ) + }) + + it('does not render a manual step number inside the ordered validation list', () => { + const source = readFileSync(new URL('./ChatMessage.vue', import.meta.url), 'utf8') + const validationSection = source.slice( + source.indexOf("block.tag === 'plan_validation'"), + source.indexOf('') + ) + + assert.ok(validationSection.includes(' +import { computed, ref, watch, nextTick } from 'vue' +import { useI18n } from 'vue-i18n' +import dayjs from 'dayjs' +import MarkdownIt from 'markdown-it' +import type { ContentBlock, ToolBlockContent } from '@/composables/useAIChat' +import CaptureButton from '@/components/common/CaptureButton.vue' +import ErrorBlock from './ErrorBlock.vue' +import ChartBlockRenderer from './ChartBlockRenderer.vue' +import EvidenceBlock from './EvidenceBlock.vue' +import { useToast } from '@/composables/useToast' +import { stripChartImagePlaceholders } from '@/services/ai/chartMarkdownPlaceholders' +import { shouldHideRecoverableChartError } from '@/stores/aiChatChartBlocks' +import { + buildProcessSegments, + getProcessSegmentStatusLabel, + getVisibleSegmentBlocks, + type ProcessSegment, +} from './chatMessageProcessSegments' + +const { t, te, locale } = useI18n() +const toast = useToast() + +// Props +const props = defineProps<{ + messageId?: string + role: 'user' | 'assistant' | 'summary' + content: string + timestamp: number + isStreaming?: boolean + /** AI 消息的混合内容块(按时序排列的文本和工具调用) */ + contentBlocks?: ContentBlock[] + /** 是否显示截屏按钮(仅 AI 回复) */ + showCaptureButton?: boolean + editable?: boolean +}>() + +const emit = defineEmits<{ + edit: [payload: { messageId: string; content: string; overwriteSubsequent?: boolean }] + fork: [messageId: string] +}>() + +// 格式化时间 +const formattedTime = computed(() => { + return dayjs(props.timestamp).format('HH:mm') +}) + +// 是否是用户消息 +const isUser = computed(() => props.role === 'user') +const isSummary = computed(() => props.role === 'summary') +const isEditing = ref(false) +const editContent = ref(props.content) +const editTextareaRef = ref(null) +const canEdit = computed(() => isUser.value && props.editable && !props.isStreaming && !!props.messageId) +const canFork = computed(() => !isUser.value && !isSummary.value && !props.isStreaming && !!props.messageId) +const overwriteSubsequent = ref(false) + +// 创建 markdown-it 实例 +const md = new MarkdownIt({ + html: false, // 禁用 HTML 标签 + breaks: true, // 将换行转为
+ linkify: true, // 自动将 URL 转为链接 + typographer: true, // 启用排版优化 +}) + +md.renderer.rules.link_open = (tokens, idx, options, _env, self) => { + tokens[idx].attrSet('target', '_blank') + tokens[idx].attrSet('rel', 'noopener noreferrer') + return self.renderToken(tokens, idx, options) +} + +// 渲染 Markdown 文本 +function renderMarkdown(text: string): string { + if (!text) return '' + return md.render(text) +} + +// 思考标签名称映射 +function getThinkLabel(tag: string): string { + const normalized = tag?.toLowerCase() || 'think' + if (normalized === 'analysis') return t('ai.chat.message.think.labels.analysis') + if (normalized === 'reasoning') return t('ai.chat.message.think.labels.reasoning') + if (normalized === 'reflection') return t('ai.chat.message.think.labels.reflection') + if (normalized === 'plan_validation') return t('ai.chat.message.think.labels.planValidation') + if (normalized === 'think' || normalized === 'thought' || normalized === 'thinking') { + return t('ai.chat.message.think.labels.think') + } + return t('ai.chat.message.think.labels.other', { tag }) +} + +// 格式化思考耗时(毫秒 -> 秒) +function formatThinkDuration(durationMs?: number): string { + if (!durationMs) return '' + const seconds = (durationMs / 1000).toFixed(1) + return t('ai.chat.message.think.duration', { seconds }) +} + +// 渲染后的 HTML(用于用户消息或纯文本 AI 消息) +const renderedContent = computed(() => { + if (!props.content) return '' + return md.render(getDisplayText(props.content)) +}) + +watch( + () => props.content, + (content) => { + if (!isEditing.value) editContent.value = content + } +) + +function syncEditTextareaHeight() { + const el = editTextareaRef.value + if (!el) return + el.style.height = 'auto' + const maxHeight = 384 + const nextHeight = Math.min(el.scrollHeight, maxHeight) + el.style.height = `${nextHeight}px` + el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden' +} + +async function startEditing() { + if (!canEdit.value) return + editContent.value = props.content + isEditing.value = true + await nextTick() + syncEditTextareaHeight() + editTextareaRef.value?.focus() +} + +function cancelEditing() { + isEditing.value = false + editContent.value = props.content + overwriteSubsequent.value = false +} + +function submitEditing() { + if (!props.messageId) return + const content = editContent.value.trim() + if (!content || content === props.content.trim()) { + cancelEditing() + return + } + isEditing.value = false + emit('edit', { messageId: props.messageId, content, overwriteSubsequent: overwriteSubsequent.value }) + overwriteSubsequent.value = false +} + +function getDisplayText(text: string): string { + return stripChartImagePlaceholders(text) +} + +// 过滤无内容的文本/思考块,避免显示空气泡 +const visibleBlocks = computed(() => { + const blocks = props.contentBlocks || [] + return blocks.filter((block, index) => { + if (block.type === 'text') { + return getDisplayText(block.text).trim().length > 0 + } + if (block.type === 'think') { + return block.text.trim().length > 0 + } + if (block.type === 'error') { + return !shouldHideRecoverableChartError(blocks, index, { isStreaming: props.isStreaming }) + } + return true + }) +}) + +function isFoldableProcessBlock(block: ContentBlock): boolean { + return block.type === 'think' || block.type === 'tool' || block.type === 'plan' || block.type === 'plan_draft' +} + +function isTextBlock(block: ContentBlock): boolean { + return block.type === 'text' +} + +const renderSegments = computed(() => + buildProcessSegments(visibleBlocks.value, { + isFoldableProcessBlock, + isTextBlock, + }) +) + +const copyableBlocks = computed(() => getVisibleSegmentBlocks(renderSegments.value)) + +const processSegmentOpenOverrides = ref>({}) + +function getSegmentBlocks(segment: ProcessSegment): ContentBlock[] { + return segment.type === 'process' ? segment.blocks : [segment.block] +} + +function getProcessSegmentKey(segmentIndex: number): string { + return `${props.messageId ?? props.timestamp}:process:${segmentIndex}` +} + +function isProcessSegmentOpen(segmentIndex: number): boolean { + const key = getProcessSegmentKey(segmentIndex) + const override = processSegmentOpenOverrides.value[key] + if (override !== undefined) return override + return isProcessingProcessSegment(segmentIndex) +} + +function toggleProcessSegment(segmentIndex: number): void { + const key = getProcessSegmentKey(segmentIndex) + processSegmentOpenOverrides.value = { + ...processSegmentOpenOverrides.value, + [key]: !isProcessSegmentOpen(segmentIndex), + } +} + +function isLastVisibleBlock(block: ContentBlock): boolean { + return visibleBlocks.value[visibleBlocks.value.length - 1] === block +} + +function isProcessingProcessSegment(segmentIndex: number): boolean { + return !!props.isStreaming && segmentIndex === renderSegments.value.length - 1 +} + +function getBlockDurationMs(block: ContentBlock): number { + if (block.type === 'think') return block.durationMs ?? 0 + if (block.type === 'tool') return block.tool.durationMs ?? 0 + return 0 +} + +function getProcessSegmentLabel(segment: ProcessSegment, segmentIndex: number): string { + return getProcessSegmentStatusLabel(segment, { + getBlockDurationMs, + isProcessing: isProcessingProcessSegment(segmentIndex), + labels: { + processed: t('ai.chat.message.process.processed'), + processing: t('ai.chat.message.process.processing'), + }, + locale: locale.value, + }) +} + +// 是否使用 contentBlocks 渲染(AI 消息且有内容块) +const useBlocksRendering = computed(() => { + return props.role === 'assistant' && visibleBlocks.value.length > 0 +}) + +function getToolDisplayName(tool: ToolBlockContent): string { + return te(`ai.assistant.builtinToolDesc.${tool.name}`) + ? t(`ai.assistant.builtinToolDesc.${tool.name}`) + : tool.displayName +} + +function formatToolStatusForCopy(status: ToolBlockContent['status']): string { + if (status === 'running') return 'running' + if (status === 'done') return 'done' + return 'error' +} + +function getToolResultText(tool: ToolBlockContent): string { + return tool.displayResult ?? tool.result ?? '' +} + +function hasToolResult(tool: ToolBlockContent): boolean { + return tool.status !== 'running' && getToolResultText(tool).trim().length > 0 +} + +function isToolResultDisplayTruncated(tool: ToolBlockContent): boolean { + return !tool.displayResult && (tool.result ?? '').includes('…[truncated]') +} + +async function copyToolResult(tool: ToolBlockContent) { + const text = getToolResultText(tool) + if (!text.trim()) return + try { + await navigator.clipboard.writeText(text) + toast.success(t('ai.chat.message.toolResult.copySuccess')) + } catch (error) { + toast.fail(t('ai.chat.message.toolResult.copyFailed'), { description: String(error) }) + } +} + +function formatPlanTools(tools: string[]): string { + if (tools.length === 0) return t('ai.chat.message.plan.noTools') + return tools.join(', ') +} + +function parsePlanValidation(text: string): { + title?: string + steps: Array<{ goal: string; suggestedTools: string[]; evidenceNeeded: string }> + successCriteria: string[] +} | null { + try { + const parsed = JSON.parse(text.trim()) as unknown + if (!parsed || typeof parsed !== 'object') return null + const record = parsed as Record + const steps = Array.isArray(record.steps) + ? record.steps + .filter((step): step is Record => !!step && typeof step === 'object') + .map((step) => ({ + goal: typeof step.goal === 'string' ? step.goal : '', + suggestedTools: Array.isArray(step.suggestedTools) + ? step.suggestedTools.filter((tool): tool is string => typeof tool === 'string') + : [], + evidenceNeeded: typeof step.evidenceNeeded === 'string' ? step.evidenceNeeded : '', + })) + .filter((step) => step.goal) + : [] + return { + title: typeof record.title === 'string' ? record.title : undefined, + steps, + successCriteria: Array.isArray(record.successCriteria) + ? record.successCriteria.filter((item): item is string => typeof item === 'string') + : [], + } + } catch { + return null + } +} + +const planValidationCache = new Map>>() + +function getPlanValidation(text: string): ReturnType { + const cached = planValidationCache.get(text) + if (cached) return cached + const parsed = parsePlanValidation(text) + if (parsed) planValidationCache.set(text, parsed) + return parsed +} + +// 格式化时间参数显示 +function formatTimeParams(params: Record): string { + // 优先使用 start_time/end_time + if (params.start_time || params.end_time) { + const start = params.start_time ? String(params.start_time) : '' + const end = params.end_time ? String(params.end_time) : '' + if (start && end) { + return `${start} ~ ${end}` + } + return start || end + } + + // 使用 year/month/day/hour 组合 + if (params.year) { + if (locale.value.startsWith('zh')) { + let result = `${params.year}年` + if (params.month) { + result += `${params.month}月` + if (params.day) { + result += `${params.day}日` + if (params.hour !== undefined) { + result += ` ${params.hour}点` + } + } + } + return result + } else { + // English format + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + let result = '' + if (params.month) { + result = monthNames[(params.month as number) - 1] || String(params.month) + if (params.day) { + result += ` ${params.day}` + if (params.hour !== undefined) { + const hour = params.hour as number + const suffix = hour >= 12 ? 'pm' : 'am' + const hour12 = hour % 12 || 12 + result += `, ${hour12}${suffix}` + } + } + result += `, ${params.year}` + } else { + result = String(params.year) + } + return result + } + } + + return '' +} + +// 格式化工具参数显示 +function formatToolParams(tool: ToolBlockContent): string { + if (!tool.params) return '' + + const name = tool.name + const params = tool.params + + if (name === 'search_messages') { + const keywords = params.keywords as string[] | undefined + const parts: string[] = [] + + if (keywords && keywords.length > 0) { + parts.push(`${t('ai.chat.message.toolParams.keywords')}: ${keywords.join(', ')}`) + } + + const timeStr = formatTimeParams(params) + if (timeStr) { + parts.push(`${t('ai.chat.message.toolParams.time')}: ${timeStr}`) + } + + return parts.join(' | ') + } + + if (name === 'get_recent_messages') { + const parts: string[] = [] + parts.push(t('ai.chat.message.toolParams.getMessages', { count: params.limit || 100 })) + + const timeStr = formatTimeParams(params) + if (timeStr) { + parts.push(timeStr) + } + + return parts.join(' | ') + } + + if (name === 'get_conversation_between') { + const parts: string[] = [] + + const timeStr = formatTimeParams(params) + if (timeStr) { + parts.push(`${t('ai.chat.message.toolParams.time')}: ${timeStr}`) + } + + if (params.limit) { + parts.push(t('ai.chat.message.toolParams.limit', { count: params.limit })) + } + + return parts.join(' | ') + } + + if (name === 'get_message_context') { + const ids = params.message_ids as number[] | undefined + const size = params.context_size || 20 + if (ids && ids.length > 0) { + return t('ai.chat.message.toolParams.contextWithMessages', { msgCount: ids.length, contextSize: size }) + } + return t('ai.chat.message.toolParams.context', { size }) + } + + if (name === 'get_member_stats') { + return t('ai.chat.message.toolParams.topMembers', { count: params.top_n || 10 }) + } + + if (name === 'get_time_stats') { + const typeKey = params.type as string + return t(`ai.chat.message.toolParams.timeStats.${typeKey}`) || String(params.type) + } + + if (name === 'render_chart') { + const spec = params.spec && typeof params.spec === 'object' ? (params.spec as Record) : null + const parts = [spec?.title, spec?.type].filter( + (part): part is string => typeof part === 'string' && part.length > 0 + ) + return parts.join(' | ') + } + + if (name === 'get_members') { + if (params.search) { + return `${t('ai.chat.message.toolParams.search')}: ${params.search}` + } + return t('ai.chat.message.toolParams.getMemberList') + } + + if (name === 'get_member_name_history') { + return `${t('ai.chat.message.toolParams.memberId')}: ${params.member_id}` + } + + if (name === 'semantic_search_current_chat') { + const query = typeof params.query === 'string' ? params.query : '' + if (!query) return '' + return query.length > 40 ? `“${query.slice(0, 40)}…”` : `“${query}”` + } + + // 通用兜底方案:展示最多3个非空参数 + const genericParts: string[] = [] + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue + const strVal = typeof value === 'object' ? JSON.stringify(value) : String(value) + const displayVal = strVal.length > 30 ? strVal.substring(0, 30) + '...' : strVal + genericParts.push(`${key}: ${displayVal}`) + if (genericParts.length >= 3) { + genericParts.push('...') + break + } + } + + return genericParts.join(' | ') +} + +const copyMarkdownText = computed(() => { + if (!useBlocksRendering.value && props.content.trim()) return getDisplayText(props.content) + if (!useBlocksRendering.value) return '' + + const lines = copyableBlocks.value + .map((block) => { + if (block.type === 'text') { + return getDisplayText(block.text) + } + + if (block.type === 'think') { + const thinkTitle = getThinkLabel(block.tag) + const thinkBody = block.text + .split('\n') + .map((line) => `> ${line}`) + .join('\n') + return `> ${thinkTitle}\n>\n${thinkBody}` + } + + if (block.type === 'skill') { + return `> ${t('ai.skill.active.label', { name: block.skillName })}` + } + + if (block.type === 'chart') { + return `> Chart: ${block.chart.spec.title}` + } + + if (block.type === 'evidence') { + const header = `> ${t('ai.chat.evidence.title')}` + const groupLines = block.evidence.groups.map((group) => { + const status = t(`ai.chat.evidence.group.${group.status}`) + const sources = group.sources.map((source) => `> - ${source.snippet}`).join('\n') + return `> [${status}] ${group.title}\n${sources}` + }) + return [header, ...groupLines].join('\n') + } + + if (block.type === 'plan') { + const steps = block.plan.steps + .map( + (step, index) => + `${index + 1}. ${step.goal}\n - ${t('ai.chat.message.plan.evidenceNeeded')}: ${step.evidenceNeeded}\n - ${t('ai.chat.message.plan.suggestedTools')}: ${formatPlanTools(step.suggestedTools)}` + ) + .join('\n') + const criteria = block.plan.successCriteria.map((item) => `- ${item}`).join('\n') + const displayText = block.displayText + ? `${block.displayText + .split('\n') + .map((line) => `> ${line}`) + .join('\n')}\n\n` + : '' + if (block.displayText) { + return `> ${t('ai.chat.message.plan.label')}: ${block.plan.title}\n\n${displayText.trimEnd()}` + } + return `> ${t('ai.chat.message.plan.label')}: ${block.plan.title}\n\n${steps}\n\n${t('ai.chat.message.plan.successCriteria')}:\n${criteria}` + } + + if (block.type === 'plan_draft') { + return `> ${t('ai.chat.message.plan.label')}\n>\n${block.text + .split('\n') + .map((line) => `> ${line}`) + .join('\n')}` + } + + if (block.type === 'tool') { + const toolName = getToolDisplayName(block.tool) + const toolParams = formatToolParams(block.tool) + const paramsSuffix = toolParams ? ` (${toolParams})` : '' + return `- [${formatToolStatusForCopy(block.tool.status)}] ${toolName}${paramsSuffix}` + } + + return '' + }) + .filter((line) => line.trim().length > 0) + + return lines.join('\n\n') +}) + +const canCopyMarkdown = computed(() => !props.isStreaming && copyMarkdownText.value.trim().length > 0) + +async function handleCopyMarkdown() { + if (!canCopyMarkdown.value) return + + try { + await navigator.clipboard.writeText(copyMarkdownText.value) + toast.success(t('ai.chat.message.copy.success')) + } catch (error) { + toast.fail(t('ai.chat.message.copy.failed'), { description: String(error) }) + } +} + + +