Compare commits

..

1 Commits

Author SHA1 Message Date
DarkSky 6557e5d01d feat: init disk remote source 2026-02-27 02:39:53 +08:00
346 changed files with 15202 additions and 12591 deletions
+7 -16
View File
@@ -197,8 +197,8 @@
"properties": {
"SMTP.name": {
"type": "string",
"description": "Hostname used for SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.\n@default \"\"\n@environment `MAILER_SERVERNAME`",
"default": ""
"description": "Name of the email server (e.g. your domain name)\n@default \"AFFiNE Server\"\n@environment `MAILER_SERVERNAME`",
"default": "AFFiNE Server"
},
"SMTP.host": {
"type": "string",
@@ -237,8 +237,8 @@
},
"fallbackSMTP.name": {
"type": "string",
"description": "Hostname used for fallback SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.\n@default \"\"",
"default": ""
"description": "Name of the fallback email server (e.g. your domain name)\n@default \"AFFiNE Server\"",
"default": "AFFiNE Server"
},
"fallbackSMTP.host": {
"type": "string",
@@ -971,7 +971,7 @@
},
"scenarios": {
"type": "object",
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"coding\":\"claude-sonnet-4-5@20250929\",\"complex_text_generation\":\"gpt-5-mini\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"gemini-2.5-flash\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4-5@20250929\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-5-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
"default": {
"override_enabled": false,
"scenarios": {
@@ -979,24 +979,15 @@
"chat": "gemini-2.5-flash",
"embedding": "gemini-embedding-001",
"image": "gpt-image-1",
"rerank": "gpt-4.1",
"coding": "claude-sonnet-4-5@20250929",
"complex_text_generation": "gpt-5-mini",
"complex_text_generation": "gpt-4o-2024-08-06",
"quick_decision_making": "gpt-5-mini",
"quick_text_generation": "gemini-2.5-flash",
"polish_and_summarize": "gemini-2.5-flash"
}
}
},
"providers.profiles": {
"type": "array",
"description": "The profile list for copilot providers.\n@default []",
"default": []
},
"providers.defaults": {
"type": "object",
"description": "The default provider ids for model output types and global fallback.\n@default {}",
"default": {}
},
"providers.openai": {
"type": "object",
"description": "The config for the openai provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://api.openai.com/v1\"}\n@link https://github.com/openai/openai-node",
+2 -8
View File
@@ -50,14 +50,8 @@ runs:
# https://github.com/tree-sitter/tree-sitter/issues/4186
# pass -D_BSD_SOURCE to clang to fix the tree-sitter build issue
run: |
if [[ "${{ inputs.target }}" == "aarch64-unknown-linux-gnu" ]]; then
# napi cross-toolchain 1.0.3 headers miss AT_HWCAP2 in elf.h
echo "CC=clang -D_BSD_SOURCE -DAT_HWCAP2=26" >> "$GITHUB_ENV"
echo "TARGET_CC=clang -D_BSD_SOURCE -DAT_HWCAP2=26" >> "$GITHUB_ENV"
else
echo "CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
echo "TARGET_CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
fi
echo "CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
echo "TARGET_CC=clang -D_BSD_SOURCE" >> "$GITHUB_ENV"
- name: Cache cargo
uses: Swatinem/rust-cache@v2
+7 -7
View File
@@ -53,7 +53,7 @@ runs:
fi
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: https://npm.pkg.github.com
@@ -93,7 +93,7 @@ runs:
run: node -e "const p = $(yarn config cacheFolder --json).effective; console.log('yarn_global_cache=' + p)" >> $GITHUB_OUTPUT
- name: Cache non-full yarn cache on Linux
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.full-cache != 'true' && runner.os == 'Linux' }}
with:
path: |
@@ -105,7 +105,7 @@ runs:
# and the decompression performance on Windows is very terrible
# so we reduce the number of cached files on non-Linux systems by remove node_modules from cache path.
- name: Cache non-full yarn cache on non-Linux
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.full-cache != 'true' && runner.os != 'Linux' }}
with:
path: |
@@ -113,7 +113,7 @@ runs:
key: node_modules-cache-${{ github.job }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }}
- name: Cache full yarn cache on Linux
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.full-cache == 'true' && runner.os == 'Linux' }}
with:
path: |
@@ -122,7 +122,7 @@ runs:
key: node_modules-cache-full-${{ runner.os }}-${{ runner.arch }}-${{ steps.system-info.outputs.name }}-${{ steps.system-info.outputs.release }}-${{ steps.system-info.outputs.version }}
- name: Cache full yarn cache on non-Linux
uses: actions/cache@v5
uses: actions/cache@v4
if: ${{ inputs.full-cache == 'true' && runner.os != 'Linux' }}
with:
path: |
@@ -154,7 +154,7 @@ runs:
# Note: Playwright's cache directory is hard coded because that's what it
# says to do in the docs. There doesn't appear to be a command that prints
# it out for us.
- uses: actions/cache@v5
- uses: actions/cache@v4
id: playwright-cache
if: ${{ inputs.playwright-install == 'true' }}
with:
@@ -189,7 +189,7 @@ runs:
run: |
echo "version=$(yarn why --json electron | grep -h 'workspace:.' | jq --raw-output '.children[].locator' | sed -e 's/@playwright\/test@.*://' | head -n 1)" >> $GITHUB_OUTPUT
- uses: actions/cache@v5
- uses: actions/cache@v4
id: electron-cache
if: ${{ inputs.electron-install == 'true' }}
with:
+2 -2
View File
@@ -13,5 +13,5 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/labeler@v6
- uses: actions/checkout@v4
- uses: actions/labeler@v5
+8 -8
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
environment: ${{ inputs.build-type }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -57,7 +57,7 @@ jobs:
runs-on: ubuntu-latest
environment: ${{ inputs.build-type }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -89,7 +89,7 @@ jobs:
runs-on: ubuntu-latest
environment: ${{ inputs.build-type }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -118,7 +118,7 @@ jobs:
build-server-native:
name: Build Server native - ${{ matrix.targets.name }}
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
environment: ${{ inputs.build-type }}
strategy:
fail-fast: false
@@ -132,7 +132,7 @@ jobs:
file: server-native.armv7.node
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -166,7 +166,7 @@ jobs:
needs:
- build-server-native
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -202,7 +202,7 @@ jobs:
- build-mobile
- build-admin
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Download server dist
uses: actions/download-artifact@v4
with:
@@ -222,7 +222,7 @@ jobs:
# setup node without cache configuration
# Prisma cache is not compatible with docker build cache
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: https://npm.pkg.github.com
+79 -33
View File
@@ -46,7 +46,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
@@ -67,9 +67,9 @@ jobs:
name: Lint
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Go (for actionlint)
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Install actionlint
@@ -111,7 +111,7 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=14384
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -138,7 +138,7 @@ jobs:
outputs:
run-rust: ${{ steps.rust-filter.outputs.rust }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: rust-filter
@@ -159,7 +159,7 @@ jobs:
needs:
- rust-test-filter
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: ./.github/actions/build-rust
with:
target: x86_64-unknown-linux-gnu
@@ -182,7 +182,7 @@ jobs:
needs:
- build-server-native
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -212,7 +212,7 @@ jobs:
name: Check yarn binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Run check
run: |
set -euo pipefail
@@ -226,9 +226,9 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
shard: [1, 2]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -256,7 +256,7 @@ jobs:
name: E2E BlockSuite Cross Browser Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -282,6 +282,52 @@ jobs:
path: ./test-results
if-no-files-found: ignore
bundler-matrix:
name: Bundler Matrix (${{ matrix.bundler }})
runs-on: ubuntu-24.04-arm
strategy:
fail-fast: false
matrix:
bundler: [webpack, rspack]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: false
electron-install: false
full-cache: true
- name: Run frontend build matrix
env:
AFFINE_BUNDLER: ${{ matrix.bundler }}
run: |
set -euo pipefail
packages=(
"@affine/web"
"@affine/mobile"
"@affine/ios"
"@affine/android"
"@affine/admin"
"@affine/electron-renderer"
)
summary="test-results-bundler-${AFFINE_BUNDLER}.txt"
: > "$summary"
for pkg in "${packages[@]}"; do
start=$(date +%s)
yarn affine "$pkg" build
end=$(date +%s)
echo "${pkg},$((end-start))" >> "$summary"
done
- name: Upload bundler timing
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-bundler-${{ matrix.bundler }}
path: ./test-results-bundler-${{ matrix.bundler }}.txt
if-no-files-found: ignore
e2e-test:
name: E2E Test
runs-on: ubuntu-24.04-arm
@@ -294,7 +340,7 @@ jobs:
matrix:
shard: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -326,7 +372,7 @@ jobs:
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -356,9 +402,9 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -391,7 +437,7 @@ jobs:
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -430,7 +476,7 @@ jobs:
- { os: macos-latest, target: aarch64-apple-darwin }
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -471,7 +517,7 @@ jobs:
- { os: windows-latest, target: aarch64-pc-windows-msvc }
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: samypr100/setup-dev-drive@v3
with:
workspace-copy: true
@@ -511,7 +557,7 @@ jobs:
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -534,7 +580,7 @@ jobs:
name: Build @affine/electron renderer
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -561,7 +607,7 @@ jobs:
needs:
- build-native-linux
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -615,7 +661,7 @@ jobs:
ports:
- 9308:9308
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
@@ -696,7 +742,7 @@ jobs:
stack-version: 9.0.1
security-enabled: false
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
@@ -759,7 +805,7 @@ jobs:
ports:
- 9308:9308
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
@@ -800,7 +846,7 @@ jobs:
CARGO_TERM_COLOR: always
MIRIFLAGS: -Zmiri-backtrace=full -Zmiri-tree-borrows
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -828,7 +874,7 @@ jobs:
RUST_BACKTRACE: full
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -852,7 +898,7 @@ jobs:
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -891,7 +937,7 @@ jobs:
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Rust
uses: ./.github/actions/build-rust
with:
@@ -914,7 +960,7 @@ jobs:
run-api: ${{ steps.decision.outputs.run_api }}
run-e2e: ${{ steps.decision.outputs.run_e2e }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: copilot-filter
@@ -983,7 +1029,7 @@ jobs:
ports:
- 9308:9308
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
@@ -1056,7 +1102,7 @@ jobs:
ports:
- 9308:9308
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
@@ -1139,7 +1185,7 @@ jobs:
ports:
- 9308:9308
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
@@ -1220,7 +1266,7 @@ jobs:
test: true,
}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
timeout-minutes: 10
+4 -4
View File
@@ -10,7 +10,7 @@ jobs:
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -64,7 +64,7 @@ jobs:
ports:
- 9308:9308
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
@@ -134,7 +134,7 @@ jobs:
ports:
- 9308:9308
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
@@ -167,7 +167,7 @@ jobs:
runs-on: ubuntu-latest
name: Post test result message
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event.action != 'edited' || github.event.changes.title != null }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
cache: 'yarn'
node-version-file: '.nvmrc'
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- build-images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Deploy to ${{ inputs.build-type }}
uses: ./.github/actions/deploy
with:
@@ -69,7 +69,7 @@ jobs:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_RELEASE: ${{ inputs.app_version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
@@ -101,7 +101,7 @@ jobs:
- name: Signing By Apple Developer ID
if: ${{ inputs.platform == 'darwin' && inputs.apple_codesign }}
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v5
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
@@ -178,14 +178,14 @@ jobs:
mv packages/frontend/apps/electron/out/*/make/deb/${{ inputs.arch }}/*.deb ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.deb
mv packages/frontend/apps/electron/out/*/make/flatpak/*/*.flatpak ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.flatpak
- uses: actions/attest-build-provenance@v4
- uses: actions/attest-build-provenance@v2
if: ${{ inputs.platform == 'darwin' }}
with:
subject-path: |
./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ inputs.arch }}.zip
./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ inputs.arch }}.dmg
- uses: actions/attest-build-provenance@v4
- uses: actions/attest-build-provenance@v2
if: ${{ inputs.platform == 'linux' }}
with:
subject-path: |
+5 -5
View File
@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest
environment: ${{ inputs.build-type }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -187,7 +187,7 @@ jobs:
FILES_TO_BE_SIGNED_x64: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED_x64 }}
FILES_TO_BE_SIGNED_arm64: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED_arm64 }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -344,7 +344,7 @@ jobs:
mv packages/frontend/apps/electron/out/*/make/squirrel.windows/${{ matrix.spec.arch }}/*.exe ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.exe
mv packages/frontend/apps/electron/out/*/make/nsis.windows/${{ matrix.spec.arch }}/*.exe ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.nsis.exe
- uses: actions/attest-build-provenance@v4
- uses: actions/attest-build-provenance@v2
with:
subject-path: |
./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-${{ matrix.spec.arch }}.zip
@@ -369,7 +369,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Download Artifacts (macos-x64)
uses: actions/download-artifact@v4
with:
@@ -395,7 +395,7 @@ jobs:
with:
name: affine-linux-x64-builds
path: ./release
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Copy Selfhost Release Files
+7 -7
View File
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
environment: ${{ inputs.build-type }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -54,7 +54,7 @@ jobs:
build-android-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -83,7 +83,7 @@ jobs:
needs:
- build-ios-web
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -114,7 +114,7 @@ jobs:
- name: Cap sync
run: yarn workspace @affine/ios sync
- name: Signing By Apple Developer ID
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v5
id: import-codesign-certs
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12_MOBILE }}
@@ -147,7 +147,7 @@ jobs:
needs:
- build-android-web
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Version
uses: ./.github/actions/setup-version
with:
@@ -180,7 +180,7 @@ jobs:
no-build: 'true'
- name: Cap sync
run: yarn workspace @affine/android cap sync
- uses: actions/setup-python@v6
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Auth gcloud
@@ -192,7 +192,7 @@ jobs:
token_format: 'access_token'
project_id: '${{ secrets.GCP_PROJECT_ID }}'
access_token_scopes: 'https://www.googleapis.com/auth/androidpublisher'
- uses: actions/setup-java@v5
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
+2 -2
View File
@@ -55,7 +55,7 @@ jobs:
GIT_SHORT_HASH: ${{ steps.prepare.outputs.GIT_SHORT_HASH }}
BUILD_TYPE: ${{ steps.prepare.outputs.BUILD_TYPE }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Prepare Release
id: prepare
uses: ./.github/actions/prepare-release
@@ -72,7 +72,7 @@ jobs:
steps:
- name: Decide whether to release
id: decide
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const buildType = '${{ needs.prepare.outputs.BUILD_TYPE }}'
-1
View File
@@ -48,7 +48,6 @@ testem.log
/typings
tsconfig.tsbuildinfo
.context
/*.md
# System Files
.DS_Store
+1 -1
View File
@@ -1 +1 @@
22.22.1
22.22.0
Generated
+30 -678
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -40,20 +40,10 @@ resolver = "3"
dotenvy = "0.15"
file-format = { version = "0.28", features = ["reader"] }
homedir = "0.3"
image = { version = "0.25.9", default-features = false, features = [
"bmp",
"gif",
"jpeg",
"png",
"webp",
] }
infer = { version = "0.19.0" }
lasso = { version = "0.7", features = ["multi-threaded"] }
lib0 = { version = "0.16", features = ["lib0-serde"] }
libc = "0.2"
libwebp-sys = "0.14.2"
little_exif = "0.6.23"
llm_adapter = "0.1.1"
log = "0.4"
loom = { version = "0.7", features = ["checkpoint"] }
lru = "0.16"
-2
View File
@@ -23,6 +23,4 @@ We welcome you to provide us with bug reports via and email at [security@toevery
Since we are an open source project, we also welcome you to provide corresponding fix PRs, we will determine specific rewards based on the evaluation results.
Due to limited resources, we do not accept and will not review any AI-generated security reports.
If the vulnerability is caused by a library we depend on, we encourage you to submit a security report to the corresponding dependent library at the same time to benefit more users.
+1 -1
View File
@@ -300,6 +300,6 @@
"devDependencies": {
"@vanilla-extract/vite-plugin": "^5.0.0",
"msw": "^2.12.4",
"vitest": "^4.0.18"
"vitest": "^3.2.4"
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ export default defineConfig({
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
provider: 'istanbul',
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/blocksuite-affine',
},
@@ -31,8 +31,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
@@ -108,9 +108,7 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBloc
}
open = () => {
const link = this.link;
if (!link) return;
window.open(link, '_blank', 'noopener,noreferrer');
window.open(this.link, '_blank');
};
refreshData = () => {
@@ -1,4 +1,3 @@
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
export default defineConfig({
@@ -9,9 +8,10 @@ export default defineConfig({
browser: {
enabled: true,
headless: true,
instances: [{ browser: 'chromium' }],
provider: playwright(),
name: 'chromium',
provider: 'playwright',
isolate: false,
providerOptions: {},
},
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
@@ -45,10 +45,8 @@ export class AffineCodeUnit extends ShadowlessElement {
if (!codeBlock || !vElement) return plainContent;
const tokens = codeBlock.highlightTokens$.value;
if (tokens.length === 0) return plainContent;
const line = tokens[vElement.lineIndex];
if (!line) return plainContent;
// copy the tokens to avoid modifying the original tokens
const lineTokens = structuredClone(line);
const lineTokens = structuredClone(tokens[vElement.lineIndex]);
if (lineTokens.length === 0) return plainContent;
const startOffset = vElement.startOffset;
@@ -35,7 +35,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
+1 -1
View File
@@ -35,7 +35,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
+1 -1
View File
@@ -31,7 +31,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
@@ -221,12 +221,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
}
}
override getCSSScaleVal(): number {
const baseScale = super.getCSSScaleVal();
const extraScale = this.model.props.edgeless?.scale ?? 1;
return baseScale * extraScale;
}
override getRenderingRect() {
const { xywh, edgeless } = this.model.props;
const { collapse, scale = 1 } = edgeless;
@@ -261,6 +255,7 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
const style = {
borderRadius: borderRadius + 'px',
transform: `scale(${scale})`,
};
const extra = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
@@ -459,28 +454,6 @@ export const EdgelessNoteInteraction =
return;
}
let isClickOnTitle = false;
const titleRect = view
.querySelector('edgeless-page-block-title')
?.getBoundingClientRect();
if (titleRect) {
const titleBound = new Bound(
titleRect.x,
titleRect.y,
titleRect.width,
titleRect.height
);
if (titleBound.isPointInBound([e.clientX, e.clientY])) {
isClickOnTitle = true;
}
}
if (isClickOnTitle) {
handleNativeRangeAtPoint(e.clientX, e.clientY);
return;
}
if (model.children.length === 0) {
const blockId = std.store.addBlock(
'affine:paragraph',
@@ -22,7 +22,6 @@ import {
FrameBlockModel,
ImageBlockModel,
isExternalEmbedModel,
MindmapElementModel,
NoteBlockModel,
ParagraphBlockModel,
} from '@blocksuite/affine-model';
@@ -402,17 +401,7 @@ function reorderElements(
) {
if (!models.length) return;
const normalizedModels = Array.from(
new Map(
models.map(model => {
const reorderTarget =
model.group instanceof MindmapElementModel ? model.group : model;
return [reorderTarget.id, reorderTarget];
})
).values()
);
for (const model of normalizedModels) {
for (const model of models) {
const index = ctx.gfx.layer.getReorderedIndex(model, type);
// block should be updated in transaction
@@ -33,7 +33,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
@@ -2,24 +2,16 @@ import { type Color, ColorScheme } from '@blocksuite/affine-model';
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
import { requestConnectedFrame } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import {
Bound,
getBoundWithRotation,
type IBound,
intersects,
} from '@blocksuite/global/gfx';
import type { IBound } from '@blocksuite/global/gfx';
import { getBoundWithRotation, intersects } from '@blocksuite/global/gfx';
import type { BlockStdScope } from '@blocksuite/std';
import type {
GfxCompatibleInterface,
GfxController,
GfxLocalElementModel,
GridManager,
LayerManager,
SurfaceBlockModel,
Viewport,
} from '@blocksuite/std/gfx';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { effect } from '@preact/signals-core';
import last from 'lodash-es/last';
import { Subject } from 'rxjs';
@@ -48,82 +40,11 @@ type RendererOptions = {
surfaceModel: SurfaceBlockModel;
};
export type CanvasRenderPassMetrics = {
overlayCount: number;
placeholderElementCount: number;
renderByBoundCallCount: number;
renderedElementCount: number;
visibleElementCount: number;
};
export type CanvasMemorySnapshot = {
bytes: number;
datasetLayerId: string | null;
height: number;
kind: 'main' | 'stacking';
width: number;
zIndex: string;
};
export type CanvasRendererDebugMetrics = {
canvasLayerCount: number;
canvasMemoryBytes: number;
canvasMemorySnapshots: CanvasMemorySnapshot[];
canvasMemoryMegabytes: number;
canvasPixelCount: number;
coalescedRefreshCount: number;
dirtyLayerRenderCount: number;
fallbackElementCount: number;
lastRenderDurationMs: number;
lastRenderMetrics: CanvasRenderPassMetrics;
maxRenderDurationMs: number;
pooledStackingCanvasCount: number;
refreshCount: number;
renderCount: number;
stackingCanvasCount: number;
totalLayerCount: number;
totalRenderDurationMs: number;
visibleStackingCanvasCount: number;
};
type MutableCanvasRendererDebugMetrics = Omit<
CanvasRendererDebugMetrics,
| 'canvasLayerCount'
| 'canvasMemoryBytes'
| 'canvasMemoryMegabytes'
| 'canvasPixelCount'
| 'canvasMemorySnapshots'
| 'pooledStackingCanvasCount'
| 'stackingCanvasCount'
| 'totalLayerCount'
| 'visibleStackingCanvasCount'
>;
type RenderPassStats = CanvasRenderPassMetrics;
type StackingCanvasState = {
bound: Bound | null;
layerId: string | null;
};
type RefreshTarget =
| { type: 'all' }
| { type: 'main' }
| { type: 'element'; element: SurfaceElementModel | GfxLocalElementModel }
| {
type: 'elements';
elements: Array<SurfaceElementModel | GfxLocalElementModel>;
};
const STACKING_CANVAS_PADDING = 32;
export class CanvasRenderer {
private _container!: HTMLElement;
private readonly _disposables = new DisposableGroup();
private readonly _gfx: GfxController;
private readonly _turboEnabled: () => boolean;
private readonly _overlays = new Set<Overlay>();
@@ -132,37 +53,6 @@ export class CanvasRenderer {
private _stackingCanvas: HTMLCanvasElement[] = [];
private readonly _stackingCanvasPool: HTMLCanvasElement[] = [];
private readonly _stackingCanvasState = new WeakMap<
HTMLCanvasElement,
StackingCanvasState
>();
private readonly _dirtyStackingCanvasIndexes = new Set<number>();
private _mainCanvasDirty = true;
private _needsFullRender = true;
private _debugMetrics: MutableCanvasRendererDebugMetrics = {
refreshCount: 0,
coalescedRefreshCount: 0,
renderCount: 0,
totalRenderDurationMs: 0,
lastRenderDurationMs: 0,
maxRenderDurationMs: 0,
lastRenderMetrics: {
renderByBoundCallCount: 0,
visibleElementCount: 0,
renderedElementCount: 0,
placeholderElementCount: 0,
overlayCount: 0,
},
dirtyLayerRenderCount: 0,
fallbackElementCount: 0,
};
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
@@ -199,7 +89,6 @@ export class CanvasRenderer {
this.layerManager = options.layerManager;
this.grid = options.gridManager;
this.provider = options.provider ?? {};
this._gfx = this.std.get(GfxControllerIdentifier);
this._turboEnabled = () => {
const featureFlagService = options.std.get(FeatureFlagService);
@@ -243,199 +132,15 @@ export class CanvasRenderer {
};
}
private _applyStackingCanvasLayout(
canvas: HTMLCanvasElement,
bound: Bound | null,
dpr = window.devicePixelRatio
) {
const state =
this._stackingCanvasState.get(canvas) ??
({
bound: null,
layerId: canvas.dataset.layerId ?? null,
} satisfies StackingCanvasState);
if (!bound || bound.w <= 0 || bound.h <= 0) {
canvas.style.display = 'none';
canvas.style.left = '0px';
canvas.style.top = '0px';
canvas.style.width = '0px';
canvas.style.height = '0px';
canvas.style.transform = '';
canvas.width = 0;
canvas.height = 0;
state.bound = null;
state.layerId = canvas.dataset.layerId ?? null;
this._stackingCanvasState.set(canvas, state);
return;
}
const { viewportBounds, zoom, viewScale } = this.viewport;
const width = bound.w * zoom;
const height = bound.h * zoom;
const left = (bound.x - viewportBounds.x) * zoom;
const top = (bound.y - viewportBounds.y) * zoom;
const actualWidth = Math.max(1, Math.ceil(width * dpr));
const actualHeight = Math.max(1, Math.ceil(height * dpr));
const transform = `translate(${left}px, ${top}px) scale(${1 / viewScale})`;
if (canvas.style.display !== 'block') {
canvas.style.display = 'block';
}
if (canvas.style.left !== '0px') {
canvas.style.left = '0px';
}
if (canvas.style.top !== '0px') {
canvas.style.top = '0px';
}
if (canvas.style.width !== `${width}px`) {
canvas.style.width = `${width}px`;
}
if (canvas.style.height !== `${height}px`) {
canvas.style.height = `${height}px`;
}
if (canvas.style.transform !== transform) {
canvas.style.transform = transform;
}
if (canvas.style.transformOrigin !== 'top left') {
canvas.style.transformOrigin = 'top left';
}
if (canvas.width !== actualWidth) {
canvas.width = actualWidth;
}
if (canvas.height !== actualHeight) {
canvas.height = actualHeight;
}
state.bound = bound;
state.layerId = canvas.dataset.layerId ?? null;
this._stackingCanvasState.set(canvas, state);
}
private _clampBoundToViewport(bound: Bound, viewportBounds: Bound) {
const minX = Math.max(bound.x, viewportBounds.x);
const minY = Math.max(bound.y, viewportBounds.y);
const maxX = Math.min(bound.maxX, viewportBounds.maxX);
const maxY = Math.min(bound.maxY, viewportBounds.maxY);
if (maxX <= minX || maxY <= minY) {
return null;
}
return new Bound(minX, minY, maxX - minX, maxY - minY);
}
private _createCanvasForLayer(
onCreated?: (canvas: HTMLCanvasElement) => void
) {
const reused = this._stackingCanvasPool.pop();
if (reused) {
return reused;
}
const created = document.createElement('canvas');
onCreated?.(created);
return created;
}
private _findLayerIndexByElement(
element: SurfaceElementModel | GfxLocalElementModel
) {
const canvasLayers = this.layerManager.getCanvasLayers();
const index = canvasLayers.findIndex(layer =>
layer.elements.some(layerElement => layerElement.id === element.id)
);
return index === -1 ? null : index;
}
private _getLayerRenderBound(
elements: SurfaceElementModel[],
viewportBounds: Bound
) {
let layerBound: Bound | null = null;
for (const element of elements) {
const display = (element.display ?? true) && !element.hidden;
if (!display) {
continue;
}
const elementBound = Bound.from(getBoundWithRotation(element));
if (!intersects(elementBound, viewportBounds)) {
continue;
}
layerBound = layerBound ? layerBound.unite(elementBound) : elementBound;
}
if (!layerBound) {
return null;
}
return this._clampBoundToViewport(
layerBound.expand(STACKING_CANVAS_PADDING),
viewportBounds
);
}
private _getResolvedStackingCanvasBound(
canvas: HTMLCanvasElement,
bound: Bound | null
) {
if (!bound || !this._gfx.tool.dragging$.peek()) {
return bound;
}
const previousBound = this._stackingCanvasState.get(canvas)?.bound;
return previousBound ? previousBound.unite(bound) : bound;
}
private _invalidate(target: RefreshTarget = { type: 'all' }) {
if (target.type === 'all') {
this._needsFullRender = true;
this._mainCanvasDirty = true;
this._dirtyStackingCanvasIndexes.clear();
return;
}
if (this._needsFullRender) {
return;
}
if (target.type === 'main') {
this._mainCanvasDirty = true;
return;
}
const elements =
target.type === 'element' ? [target.element] : target.elements;
for (const element of elements) {
const layerIndex = this._findLayerIndexByElement(element);
if (layerIndex === null || layerIndex >= this._stackingCanvas.length) {
this._mainCanvasDirty = true;
continue;
}
this._dirtyStackingCanvasIndexes.add(layerIndex);
}
}
private _resetPooledCanvas(canvas: HTMLCanvasElement) {
canvas.dataset.layerId = '';
this._applyStackingCanvasLayout(canvas, null);
}
private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) {
const layer = this.layerManager;
const updateStackingCanvasSize = (canvases: HTMLCanvasElement[]) => {
this._stackingCanvas = canvases;
const sizeUpdater = this._canvasSizeUpdater();
canvases.filter(sizeUpdater.filter).forEach(sizeUpdater.update);
};
const updateStackingCanvas = () => {
/**
* we already have a main canvas, so the last layer should be skipped
@@ -454,7 +159,11 @@ export class CanvasRenderer {
const created = i < currentCanvases.length;
const canvas = created
? currentCanvases[i]
: this._createCanvasForLayer(onCreated);
: document.createElement('canvas');
if (!created) {
onCreated?.(canvas);
}
canvas.dataset.layerId = `[${layer.indexes[0]}--${layer.indexes[1]}]`;
canvas.style.zIndex = layer.zIndex.toString();
@@ -462,6 +171,7 @@ export class CanvasRenderer {
}
this._stackingCanvas = canvases;
updateStackingCanvasSize(canvases);
if (currentCanvases.length !== canvases.length) {
const diff = canvases.length - currentCanvases.length;
@@ -479,16 +189,12 @@ export class CanvasRenderer {
payload.added = canvases.slice(-diff);
} else {
payload.removed = currentCanvases.slice(diff);
payload.removed.forEach(canvas => {
this._resetPooledCanvas(canvas);
this._stackingCanvasPool.push(canvas);
});
}
this.stackingCanvasUpdated.next(payload);
}
this.refresh({ type: 'all' });
this.refresh();
};
this._disposables.add(
@@ -505,7 +211,7 @@ export class CanvasRenderer {
this._disposables.add(
this.viewport.viewportUpdated.subscribe(() => {
this.refresh({ type: 'all' });
this.refresh();
})
);
@@ -516,6 +222,7 @@ export class CanvasRenderer {
sizeUpdatedRafId = null;
this._resetSize();
this._render();
this.refresh();
}, this._container);
})
);
@@ -526,212 +233,69 @@ export class CanvasRenderer {
if (this.usePlaceholder !== shouldRenderPlaceholders) {
this.usePlaceholder = shouldRenderPlaceholders;
this.refresh({ type: 'all' });
this.refresh();
}
})
);
let wasDragging = false;
this._disposables.add(
effect(() => {
const isDragging = this._gfx.tool.dragging$.value;
if (wasDragging && !isDragging) {
this.refresh({ type: 'all' });
}
wasDragging = isDragging;
})
);
this.usePlaceholder = false;
}
private _createRenderPassStats(): RenderPassStats {
return {
renderByBoundCallCount: 0,
visibleElementCount: 0,
renderedElementCount: 0,
placeholderElementCount: 0,
overlayCount: 0,
};
}
private _getCanvasMemorySnapshots(): CanvasMemorySnapshot[] {
return [this.canvas, ...this._stackingCanvas].map((canvas, index) => {
return {
kind: index === 0 ? 'main' : 'stacking',
width: canvas.width,
height: canvas.height,
bytes: canvas.width * canvas.height * 4,
zIndex: canvas.style.zIndex,
datasetLayerId: canvas.dataset.layerId ?? null,
};
});
}
private _render() {
const renderStart = performance.now();
const { viewportBounds, zoom } = this.viewport;
const { ctx } = this;
const dpr = window.devicePixelRatio;
const scale = zoom * dpr;
const matrix = new DOMMatrix().scaleSelf(scale);
const renderStats = this._createRenderPassStats();
const fullRender = this._needsFullRender;
const stackingIndexesToRender = fullRender
? this._stackingCanvas.map((_, idx) => idx)
: [...this._dirtyStackingCanvasIndexes];
/**
* if a layer does not have a corresponding canvas
* its element will be add to this array and drawing on the
* main canvas
*/
let fallbackElement: SurfaceElementModel[] = [];
const allCanvasLayers = this.layerManager.getCanvasLayers();
const viewportBound = Bound.from(viewportBounds);
for (const idx of stackingIndexesToRender) {
const layer = allCanvasLayers[idx];
this.layerManager.getCanvasLayers().forEach((layer, idx) => {
if (!this._stackingCanvas[idx]) {
fallbackElement = fallbackElement.concat(layer.elements);
return;
}
const canvas = this._stackingCanvas[idx];
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
const rc = new RoughCanvas(ctx.canvas);
if (!layer || !canvas) {
continue;
}
const layerRenderBound = this._getLayerRenderBound(
layer.elements,
viewportBound
);
const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound(
canvas,
layerRenderBound
);
this._applyStackingCanvasLayout(canvas, resolvedLayerRenderBound);
if (
!resolvedLayerRenderBound ||
canvas.width === 0 ||
canvas.height === 0
) {
continue;
}
const layerCtx = canvas.getContext('2d') as CanvasRenderingContext2D;
const layerRc = new RoughCanvas(layerCtx.canvas);
layerCtx.clearRect(0, 0, canvas.width, canvas.height);
layerCtx.save();
layerCtx.setTransform(matrix);
this._renderByBound(
layerCtx,
matrix,
layerRc,
resolvedLayerRenderBound,
layer.elements,
false,
renderStats
);
}
if (fullRender || this._mainCanvasDirty) {
allCanvasLayers.forEach((layer, idx) => {
if (!this._stackingCanvas[idx]) {
fallbackElement = fallbackElement.concat(layer.elements);
}
});
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.setTransform(matrix);
this._renderByBound(
ctx,
matrix,
new RoughCanvas(ctx.canvas),
viewportBounds,
fallbackElement,
true,
renderStats
);
}
this._renderByBound(ctx, matrix, rc, viewportBounds, layer.elements);
});
const canvasMemorySnapshots = this._getCanvasMemorySnapshots();
const canvasMemoryBytes = canvasMemorySnapshots.reduce(
(sum, snapshot) => sum + snapshot.bytes,
0
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.save();
ctx.setTransform(matrix);
this._renderByBound(
ctx,
matrix,
new RoughCanvas(ctx.canvas),
viewportBounds,
fallbackElement,
true
);
const layerTypes = this.layerManager.layers.map(layer => layer.type);
const renderDurationMs = performance.now() - renderStart;
this._debugMetrics.renderCount += 1;
this._debugMetrics.totalRenderDurationMs += renderDurationMs;
this._debugMetrics.lastRenderDurationMs = renderDurationMs;
this._debugMetrics.maxRenderDurationMs = Math.max(
this._debugMetrics.maxRenderDurationMs,
renderDurationMs
);
this._debugMetrics.lastRenderMetrics = renderStats;
this._debugMetrics.fallbackElementCount = fallbackElement.length;
this._debugMetrics.dirtyLayerRenderCount = stackingIndexesToRender.length;
this._lastDebugSnapshot = {
canvasMemorySnapshots,
canvasMemoryBytes,
canvasPixelCount: canvasMemorySnapshots.reduce(
(sum, snapshot) => sum + snapshot.width * snapshot.height,
0
),
stackingCanvasCount: this._stackingCanvas.length,
canvasLayerCount: layerTypes.filter(type => type === 'canvas').length,
totalLayerCount: layerTypes.length,
pooledStackingCanvasCount: this._stackingCanvasPool.length,
visibleStackingCanvasCount: this._stackingCanvas.filter(
canvas => canvas.width > 0 && canvas.height > 0
).length,
};
this._needsFullRender = false;
this._mainCanvasDirty = false;
this._dirtyStackingCanvasIndexes.clear();
}
private _lastDebugSnapshot: Pick<
CanvasRendererDebugMetrics,
| 'canvasMemoryBytes'
| 'canvasMemorySnapshots'
| 'canvasPixelCount'
| 'canvasLayerCount'
| 'pooledStackingCanvasCount'
| 'stackingCanvasCount'
| 'totalLayerCount'
| 'visibleStackingCanvasCount'
> = {
canvasMemoryBytes: 0,
canvasMemorySnapshots: [],
canvasPixelCount: 0,
canvasLayerCount: 0,
pooledStackingCanvasCount: 0,
stackingCanvasCount: 0,
totalLayerCount: 0,
visibleStackingCanvasCount: 0,
};
private _renderByBound(
ctx: CanvasRenderingContext2D | null,
matrix: DOMMatrix,
rc: RoughCanvas,
bound: IBound,
surfaceElements?: SurfaceElementModel[],
overLay: boolean = false,
renderStats?: RenderPassStats
overLay: boolean = false
) {
if (!ctx) return;
renderStats && (renderStats.renderByBoundCallCount += 1);
const elements =
surfaceElements ??
(this.grid.search(bound, {
@@ -741,12 +305,10 @@ export class CanvasRenderer {
for (const element of elements) {
const display = (element.display ?? true) && !element.hidden;
if (display && intersects(getBoundWithRotation(element), bound)) {
renderStats && (renderStats.visibleElementCount += 1);
if (
this.usePlaceholder &&
!(element as GfxCompatibleInterface).forceFullRender
) {
renderStats && (renderStats.placeholderElementCount += 1);
ctx.save();
ctx.fillStyle = 'rgba(200, 200, 200, 0.5)';
const drawX = element.x - bound.x;
@@ -754,7 +316,6 @@ export class CanvasRenderer {
ctx.fillRect(drawX, drawY, element.w, element.h);
ctx.restore();
} else {
renderStats && (renderStats.renderedElementCount += 1);
ctx.save();
const renderFn = this.std.getOptional<ElementRenderer>(
ElementRendererIdentifier(element.type)
@@ -772,7 +333,6 @@ export class CanvasRenderer {
}
if (overLay) {
renderStats && (renderStats.overlayCount += this._overlays.size);
for (const overlay of this._overlays) {
ctx.save();
ctx.translate(-bound.x, -bound.y);
@@ -788,38 +348,33 @@ export class CanvasRenderer {
const sizeUpdater = this._canvasSizeUpdater();
sizeUpdater.update(this.canvas);
this._invalidate({ type: 'all' });
this._stackingCanvas.forEach(sizeUpdater.update);
this.refresh();
}
private _watchSurface(surfaceModel: SurfaceBlockModel) {
this._disposables.add(
surfaceModel.elementAdded.subscribe(() => this.refresh({ type: 'all' }))
surfaceModel.elementAdded.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.elementRemoved.subscribe(() => this.refresh({ type: 'all' }))
surfaceModel.elementRemoved.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.localElementAdded.subscribe(() =>
this.refresh({ type: 'all' })
)
surfaceModel.localElementAdded.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.localElementDeleted.subscribe(() =>
this.refresh({ type: 'all' })
)
surfaceModel.localElementDeleted.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.localElementUpdated.subscribe(({ model }) => {
this.refresh({ type: 'element', element: model });
})
surfaceModel.localElementUpdated.subscribe(() => this.refresh())
);
this._disposables.add(
surfaceModel.elementUpdated.subscribe(payload => {
// ignore externalXYWH update cause it's updated by the renderer
if (payload.props['externalXYWH']) return;
const element = surfaceModel.getElementById(payload.id);
this.refresh(element ? { type: 'element', element } : { type: 'all' });
this.refresh();
})
);
}
@@ -827,7 +382,7 @@ export class CanvasRenderer {
addOverlay(overlay: Overlay) {
overlay.setRenderer(this);
this._overlays.add(overlay);
this.refresh({ type: 'main' });
this.refresh();
}
/**
@@ -839,7 +394,7 @@ export class CanvasRenderer {
container.append(this.canvas);
this._resetSize();
this.refresh({ type: 'all' });
this.refresh();
}
dispose(): void {
@@ -898,46 +453,8 @@ export class CanvasRenderer {
return this.provider.getPropertyValue?.(property) ?? '';
}
getDebugMetrics(): CanvasRendererDebugMetrics {
return {
...this._debugMetrics,
...this._lastDebugSnapshot,
canvasMemoryMegabytes:
this._lastDebugSnapshot.canvasMemoryBytes / 1024 / 1024,
};
}
resetDebugMetrics() {
this._debugMetrics = {
refreshCount: 0,
coalescedRefreshCount: 0,
renderCount: 0,
totalRenderDurationMs: 0,
lastRenderDurationMs: 0,
maxRenderDurationMs: 0,
lastRenderMetrics: this._createRenderPassStats(),
dirtyLayerRenderCount: 0,
fallbackElementCount: 0,
};
this._lastDebugSnapshot = {
canvasMemoryBytes: 0,
canvasMemorySnapshots: [],
canvasPixelCount: 0,
canvasLayerCount: 0,
pooledStackingCanvasCount: 0,
stackingCanvasCount: 0,
totalLayerCount: 0,
visibleStackingCanvasCount: 0,
};
}
refresh(target: RefreshTarget = { type: 'all' }) {
this._debugMetrics.refreshCount += 1;
this._invalidate(target);
if (this._refreshRafId !== null) {
this._debugMetrics.coalescedRefreshCount += 1;
return;
}
refresh() {
if (this._refreshRafId !== null) return;
this._refreshRafId = requestConnectedFrame(() => {
this._refreshRafId = null;
@@ -952,6 +469,6 @@ export class CanvasRenderer {
overlay.setRenderer(null);
this._overlays.delete(overlay);
this.refresh({ type: 'main' });
this.refresh();
}
}
@@ -354,37 +354,30 @@ export class DomRenderer {
this._disposables.add(
surfaceModel.elementAdded.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
this._markViewportDirty();
this.refresh();
})
);
this._disposables.add(
surfaceModel.elementRemoved.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
this._markViewportDirty();
this.refresh();
})
);
this._disposables.add(
surfaceModel.localElementAdded.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_ADDED);
this._markViewportDirty();
this.refresh();
})
);
this._disposables.add(
surfaceModel.localElementDeleted.subscribe(payload => {
this._markElementDirty(payload.id, UpdateType.ELEMENT_REMOVED);
this._markViewportDirty();
this.refresh();
})
);
this._disposables.add(
surfaceModel.localElementUpdated.subscribe(payload => {
this._markElementDirty(payload.model.id, UpdateType.ELEMENT_UPDATED);
if (payload.props['index'] || payload.props['groupId']) {
this._markViewportDirty();
}
this.refresh();
})
);
@@ -394,9 +387,6 @@ export class DomRenderer {
// ignore externalXYWH update cause it's updated by the renderer
if (payload.props['externalXYWH']) return;
this._markElementDirty(payload.id, UpdateType.ELEMENT_UPDATED);
if (payload.props['index'] || payload.props['childIds']) {
this._markViewportDirty();
}
this.refresh();
})
);
+1 -1
View File
@@ -19,7 +19,7 @@
"@blocksuite/sync": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@lottiefiles/dotlottie-wc": "^0.9.4",
"@lottiefiles/dotlottie-wc": "^0.5.0",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@types/hast": "^3.0.4",
@@ -1,8 +1,4 @@
import {
getHostName,
isValidUrl,
normalizeUrl,
} from '@blocksuite/affine-shared/utils';
import { getHostName } from '@blocksuite/affine-shared/utils';
import { PropTypes, requiredProperties } from '@blocksuite/std';
import { css, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
@@ -48,27 +44,15 @@ export class LinkPreview extends LitElement {
override render() {
const { url } = this;
const normalizedUrl = normalizeUrl(url);
const safeUrl =
normalizedUrl && isValidUrl(normalizedUrl) ? normalizedUrl : null;
const hostName = getHostName(safeUrl ?? url);
if (!safeUrl) {
return html`
<span class="affine-link-preview">
<span>${hostName}</span>
</span>
`;
}
return html`
<a
class="affine-link-preview"
rel="noopener noreferrer"
target="_blank"
href=${safeUrl}
href=${url}
>
<span>${hostName}</span>
<span>${getHostName(url)}</span>
</a>
`;
}
+1 -1
View File
@@ -32,7 +32,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
+1 -1
View File
@@ -15,7 +15,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts"
@@ -8,7 +8,7 @@ export default defineConfig({
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
coverage: {
provider: 'istanbul',
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/ext-loader',
},
@@ -5,8 +5,6 @@ import {
import type { BrushElementModel } from '@blocksuite/affine-model';
import { DefaultTheme } from '@blocksuite/affine-model';
import { renderBrushLikeDom } from './shared';
export const BrushDomRendererExtension = DomElementRendererExtension(
'brush',
(
@@ -14,11 +12,58 @@ export const BrushDomRendererExtension = DomElementRendererExtension(
domElement: HTMLElement,
renderer: DomRenderer
) => {
renderBrushLikeDom({
model,
domElement,
renderer,
color: renderer.getColorValue(model.color, DefaultTheme.black, true),
});
const { zoom } = renderer.viewport;
const [, , w, h] = model.deserializedXYWH;
// Early return if invalid dimensions
if (w <= 0 || h <= 0) {
return;
}
// Early return if no commands
if (!model.commands) {
return;
}
// Clear previous content
domElement.innerHTML = '';
// Get color value
const color = renderer.getColorValue(model.color, DefaultTheme.black, true);
// Create SVG element
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.position = 'absolute';
svg.style.left = '0';
svg.style.top = '0';
svg.style.width = `${w * zoom}px`;
svg.style.height = `${h * zoom}px`;
svg.style.overflow = 'visible';
svg.style.pointerEvents = 'none';
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
// Apply rotation transform
if (model.rotate !== 0) {
svg.style.transform = `rotate(${model.rotate}deg)`;
svg.style.transformOrigin = 'center';
}
// Create path element for the brush stroke
const pathElement = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
);
pathElement.setAttribute('d', model.commands);
pathElement.setAttribute('fill', color);
pathElement.setAttribute('stroke', 'none');
svg.append(pathElement);
domElement.replaceChildren(svg);
// Set element size and position
domElement.style.width = `${w * zoom}px`;
domElement.style.height = `${h * zoom}px`;
domElement.style.overflow = 'visible';
domElement.style.pointerEvents = 'none';
}
);
@@ -5,8 +5,6 @@ import {
import type { HighlighterElementModel } from '@blocksuite/affine-model';
import { DefaultTheme } from '@blocksuite/affine-model';
import { renderBrushLikeDom } from './shared';
export const HighlighterDomRendererExtension = DomElementRendererExtension(
'highlighter',
(
@@ -14,15 +12,62 @@ export const HighlighterDomRendererExtension = DomElementRendererExtension(
domElement: HTMLElement,
renderer: DomRenderer
) => {
renderBrushLikeDom({
model,
domElement,
renderer,
color: renderer.getColorValue(
model.color,
DefaultTheme.hightlighterColor,
true
),
});
const { zoom } = renderer.viewport;
const [, , w, h] = model.deserializedXYWH;
// Early return if invalid dimensions
if (w <= 0 || h <= 0) {
return;
}
// Early return if no commands
if (!model.commands) {
return;
}
// Clear previous content
domElement.innerHTML = '';
// Get color value
const color = renderer.getColorValue(
model.color,
DefaultTheme.hightlighterColor,
true
);
// Create SVG element
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.position = 'absolute';
svg.style.left = '0';
svg.style.top = '0';
svg.style.width = `${w * zoom}px`;
svg.style.height = `${h * zoom}px`;
svg.style.overflow = 'visible';
svg.style.pointerEvents = 'none';
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
// Apply rotation transform
if (model.rotate !== 0) {
svg.style.transform = `rotate(${model.rotate}deg)`;
svg.style.transformOrigin = 'center';
}
// Create path element for the highlighter stroke
const pathElement = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
);
pathElement.setAttribute('d', model.commands);
pathElement.setAttribute('fill', color);
pathElement.setAttribute('stroke', 'none');
svg.append(pathElement);
domElement.replaceChildren(svg);
// Set element size and position
domElement.style.width = `${w * zoom}px`;
domElement.style.height = `${h * zoom}px`;
domElement.style.overflow = 'visible';
domElement.style.pointerEvents = 'none';
}
);
@@ -1,82 +0,0 @@
import type { DomRenderer } from '@blocksuite/affine-block-surface';
import type {
BrushElementModel,
HighlighterElementModel,
} from '@blocksuite/affine-model';
const SVG_NS = 'http://www.w3.org/2000/svg';
type BrushLikeModel = BrushElementModel | HighlighterElementModel;
type RetainedBrushDom = {
path: SVGPathElement;
svg: SVGSVGElement;
};
const retainedBrushDom = new WeakMap<HTMLElement, RetainedBrushDom>();
function clearBrushLikeDom(domElement: HTMLElement) {
retainedBrushDom.delete(domElement);
domElement.replaceChildren();
}
function getRetainedBrushDom(domElement: HTMLElement) {
const existing = retainedBrushDom.get(domElement);
if (existing) {
return existing;
}
const svg = document.createElementNS(SVG_NS, 'svg');
svg.style.position = 'absolute';
svg.style.left = '0';
svg.style.top = '0';
svg.style.overflow = 'visible';
svg.style.pointerEvents = 'none';
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('stroke', 'none');
svg.append(path);
const retained = { svg, path };
retainedBrushDom.set(domElement, retained);
domElement.replaceChildren(svg);
return retained;
}
export function renderBrushLikeDom({
color,
domElement,
model,
renderer,
}: {
color: string;
domElement: HTMLElement;
model: BrushLikeModel;
renderer: DomRenderer;
}) {
const { zoom } = renderer.viewport;
const [, , w, h] = model.deserializedXYWH;
if (w <= 0 || h <= 0 || !model.commands) {
clearBrushLikeDom(domElement);
return;
}
const { path, svg } = getRetainedBrushDom(domElement);
svg.style.width = `${w * zoom}px`;
svg.style.height = `${h * zoom}px`;
svg.style.transform = model.rotate === 0 ? '' : `rotate(${model.rotate}deg)`;
svg.style.transformOrigin = model.rotate === 0 ? '' : 'center';
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
path.setAttribute('d', model.commands);
path.setAttribute('fill', color);
domElement.style.width = `${w * zoom}px`;
domElement.style.height = `${h * zoom}px`;
domElement.style.overflow = 'visible';
domElement.style.pointerEvents = 'none';
}
@@ -14,8 +14,6 @@ import { PointLocation, SVGPathBuilder } from '@blocksuite/global/gfx';
import { isConnectorWithLabel } from '../connector-manager';
import { DEFAULT_ARROW_SIZE } from './utils';
const SVG_NS = 'http://www.w3.org/2000/svg';
interface PathBounds {
minX: number;
minY: number;
@@ -23,15 +21,6 @@ interface PathBounds {
maxY: number;
}
type RetainedConnectorDom = {
defs: SVGDefsElement;
label: HTMLDivElement | null;
path: SVGPathElement;
svg: SVGSVGElement;
};
const retainedConnectorDom = new WeakMap<HTMLElement, RetainedConnectorDom>();
function calculatePathBounds(path: PointLocation[]): PathBounds {
if (path.length === 0) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
@@ -92,7 +81,10 @@ function createArrowMarker(
strokeWidth: number,
isStart: boolean = false
): SVGMarkerElement {
const marker = document.createElementNS(SVG_NS, 'marker');
const marker = document.createElementNS(
'http://www.w3.org/2000/svg',
'marker'
);
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
marker.id = id;
@@ -106,7 +98,10 @@ function createArrowMarker(
switch (style) {
case 'Arrow': {
const path = document.createElementNS(SVG_NS, 'path');
const path = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
);
path.setAttribute(
'd',
isStart ? 'M 20 5 L 10 10 L 20 15 Z' : 'M 0 5 L 10 10 L 0 15 Z'
@@ -117,7 +112,10 @@ function createArrowMarker(
break;
}
case 'Triangle': {
const path = document.createElementNS(SVG_NS, 'path');
const path = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
);
path.setAttribute(
'd',
isStart ? 'M 20 7 L 12 10 L 20 13 Z' : 'M 0 7 L 8 10 L 0 13 Z'
@@ -128,7 +126,10 @@ function createArrowMarker(
break;
}
case 'Circle': {
const circle = document.createElementNS(SVG_NS, 'circle');
const circle = document.createElementNS(
'http://www.w3.org/2000/svg',
'circle'
);
circle.setAttribute('cx', '10');
circle.setAttribute('cy', '10');
circle.setAttribute('r', '4');
@@ -138,7 +139,10 @@ function createArrowMarker(
break;
}
case 'Diamond': {
const path = document.createElementNS(SVG_NS, 'path');
const path = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
);
path.setAttribute('d', 'M 10 6 L 14 10 L 10 14 L 6 10 Z');
path.setAttribute('fill', color);
path.setAttribute('stroke', color);
@@ -150,64 +154,13 @@ function createArrowMarker(
return marker;
}
function clearRetainedConnectorDom(element: HTMLElement) {
retainedConnectorDom.delete(element);
element.replaceChildren();
}
function getRetainedConnectorDom(element: HTMLElement): RetainedConnectorDom {
const existing = retainedConnectorDom.get(element);
if (existing) {
return existing;
}
const svg = document.createElementNS(SVG_NS, 'svg');
svg.style.position = 'absolute';
svg.style.overflow = 'visible';
svg.style.pointerEvents = 'none';
const defs = document.createElementNS(SVG_NS, 'defs');
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.append(defs, path);
element.replaceChildren(svg);
const retained = {
svg,
defs,
path,
label: null,
};
retainedConnectorDom.set(element, retained);
return retained;
}
function getOrCreateLabelElement(retained: RetainedConnectorDom) {
if (retained.label) {
return retained.label;
}
const label = document.createElement('div');
retained.svg.insertAdjacentElement('afterend', label);
retained.label = label;
return label;
}
function renderConnectorLabel(
model: ConnectorElementModel,
retained: RetainedConnectorDom,
container: HTMLElement,
renderer: DomRenderer,
zoom: number
) {
if (!isConnectorWithLabel(model) || !model.labelXYWH) {
retained.label?.remove();
retained.label = null;
return;
}
@@ -223,7 +176,8 @@ function renderConnectorLabel(
},
} = model;
const labelElement = getOrCreateLabelElement(retained);
// Create label element
const labelElement = document.createElement('div');
labelElement.style.position = 'absolute';
labelElement.style.left = `${lx * zoom}px`;
labelElement.style.top = `${ly * zoom}px`;
@@ -256,7 +210,11 @@ function renderConnectorLabel(
labelElement.style.wordWrap = 'break-word';
// Add text content
labelElement.textContent = model.text ? model.text.toString() : '';
if (model.text) {
labelElement.textContent = model.text.toString();
}
container.append(labelElement);
}
/**
@@ -283,13 +241,14 @@ export const connectorBaseDomRenderer = (
stroke,
} = model;
// Clear previous content
element.innerHTML = '';
// Early return if no path points
if (!points || points.length < 2) {
clearRetainedConnectorDom(element);
return;
}
const retained = getRetainedConnectorDom(element);
// Calculate bounds for the SVG viewBox
const pathBounds = calculatePathBounds(points);
const padding = Math.max(strokeWidth * 2, 20); // Add padding for arrows
@@ -298,7 +257,8 @@ export const connectorBaseDomRenderer = (
const offsetX = pathBounds.minX - padding;
const offsetY = pathBounds.minY - padding;
const { defs, path, svg } = retained;
// Create SVG element
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.position = 'absolute';
svg.style.left = `${offsetX * zoom}px`;
svg.style.top = `${offsetY * zoom}px`;
@@ -308,43 +268,49 @@ export const connectorBaseDomRenderer = (
svg.style.pointerEvents = 'none';
svg.setAttribute('viewBox', `0 0 ${svgWidth / zoom} ${svgHeight / zoom}`);
// Create defs for markers
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
svg.append(defs);
const strokeColor = renderer.getColorValue(
stroke,
DefaultTheme.connectorColor,
true
);
const markers: SVGMarkerElement[] = [];
// Create markers for endpoints
let startMarkerId = '';
let endMarkerId = '';
if (frontEndpointStyle !== 'None') {
startMarkerId = `start-marker-${model.id}`;
markers.push(
createArrowMarker(
startMarkerId,
frontEndpointStyle,
strokeColor,
strokeWidth,
true
)
const startMarker = createArrowMarker(
startMarkerId,
frontEndpointStyle,
strokeColor,
strokeWidth,
true
);
defs.append(startMarker);
}
if (rearEndpointStyle !== 'None') {
endMarkerId = `end-marker-${model.id}`;
markers.push(
createArrowMarker(
endMarkerId,
rearEndpointStyle,
strokeColor,
strokeWidth,
false
)
const endMarker = createArrowMarker(
endMarkerId,
rearEndpointStyle,
strokeColor,
strokeWidth,
false
);
defs.append(endMarker);
}
defs.replaceChildren(...markers);
// Create path element
const pathElement = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
);
// Adjust points relative to the SVG coordinate system
const adjustedPoints = points.map(point => {
@@ -368,25 +334,29 @@ export const connectorBaseDomRenderer = (
});
const pathData = createConnectorPath(adjustedPoints, mode);
path.setAttribute('d', pathData);
path.setAttribute('stroke', strokeColor);
path.setAttribute('stroke-width', String(strokeWidth));
pathElement.setAttribute('d', pathData);
pathElement.setAttribute('stroke', strokeColor);
pathElement.setAttribute('stroke-width', String(strokeWidth));
pathElement.setAttribute('fill', 'none');
pathElement.setAttribute('stroke-linecap', 'round');
pathElement.setAttribute('stroke-linejoin', 'round');
// Apply stroke style
if (strokeStyle === 'dash') {
path.setAttribute('stroke-dasharray', '12,12');
} else {
path.removeAttribute('stroke-dasharray');
pathElement.setAttribute('stroke-dasharray', '12,12');
}
// Apply markers
if (startMarkerId) {
path.setAttribute('marker-start', `url(#${startMarkerId})`);
} else {
path.removeAttribute('marker-start');
pathElement.setAttribute('marker-start', `url(#${startMarkerId})`);
}
if (endMarkerId) {
path.setAttribute('marker-end', `url(#${endMarkerId})`);
} else {
path.removeAttribute('marker-end');
pathElement.setAttribute('marker-end', `url(#${endMarkerId})`);
}
svg.append(pathElement);
element.append(svg);
// Set element size and position
element.style.width = `${model.w * zoom}px`;
element.style.height = `${model.h * zoom}px`;
@@ -400,11 +370,7 @@ export const connectorDomRenderer = (
renderer: DomRenderer
): void => {
connectorBaseDomRenderer(model, element, renderer);
const retained = retainedConnectorDom.get(element);
if (!retained) return;
renderConnectorLabel(model, retained, renderer, renderer.viewport.zoom);
renderConnectorLabel(model, element, renderer, renderer.viewport.zoom);
};
/**
+1 -1
View File
@@ -34,7 +34,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
+1 -1
View File
@@ -32,7 +32,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
@@ -6,37 +6,6 @@ import { SVGShapeBuilder } from '@blocksuite/global/gfx';
import { manageClassNames, setStyles } from './utils';
const SVG_NS = 'http://www.w3.org/2000/svg';
type RetainedShapeDom = {
polygon: SVGPolygonElement | null;
svg: SVGSVGElement | null;
text: HTMLDivElement | null;
};
type RetainedShapeSvg = {
polygon: SVGPolygonElement;
svg: SVGSVGElement;
};
const retainedShapeDom = new WeakMap<HTMLElement, RetainedShapeDom>();
function getRetainedShapeDom(element: HTMLElement): RetainedShapeDom {
const existing = retainedShapeDom.get(element);
if (existing) {
return existing;
}
const retained = {
svg: null,
polygon: null,
text: null,
};
retainedShapeDom.set(element, retained);
return retained;
}
function applyShapeSpecificStyles(
model: ShapeElementModel,
element: HTMLElement,
@@ -45,6 +14,10 @@ function applyShapeSpecificStyles(
// Reset properties that might be set by different shape types
element.style.removeProperty('clip-path');
element.style.removeProperty('border-radius');
// Clear DOM for shapes that don't use SVG, or if type changes from SVG-based to non-SVG-based
if (model.shapeType !== 'diamond' && model.shapeType !== 'triangle') {
while (element.firstChild) element.firstChild.remove();
}
switch (model.shapeType) {
case 'rect': {
@@ -69,54 +42,6 @@ function applyShapeSpecificStyles(
// No 'else' needed to clear styles, as they are reset at the beginning of the function.
}
function getOrCreateSvg(
retained: RetainedShapeDom,
element: HTMLElement
): RetainedShapeSvg {
if (retained.svg && retained.polygon) {
return {
svg: retained.svg,
polygon: retained.polygon,
};
}
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('preserveAspectRatio', 'none');
const polygon = document.createElementNS(SVG_NS, 'polygon');
svg.append(polygon);
retained.svg = svg;
retained.polygon = polygon;
element.prepend(svg);
return { svg, polygon };
}
function removeSvg(retained: RetainedShapeDom) {
retained.svg?.remove();
retained.svg = null;
retained.polygon = null;
}
function getOrCreateText(retained: RetainedShapeDom, element: HTMLElement) {
if (retained.text) {
return retained.text;
}
const text = document.createElement('div');
retained.text = text;
element.append(text);
return text;
}
function removeText(retained: RetainedShapeDom) {
retained.text?.remove();
retained.text = null;
}
function applyBorderStyles(
model: ShapeElementModel,
element: HTMLElement,
@@ -174,7 +99,8 @@ export const shapeDomRenderer = (
const { zoom } = renderer.viewport;
const unscaledWidth = model.w;
const unscaledHeight = model.h;
const retained = getRetainedShapeDom(element);
const newChildren: Element[] = [];
const fillColor = renderer.getColorValue(
model.fillColor,
@@ -198,7 +124,6 @@ export const shapeDomRenderer = (
// For diamond and triangle, fill and border are handled by inline SVG
element.style.border = 'none'; // Ensure no standard CSS border interferes
element.style.backgroundColor = 'transparent'; // Host element is transparent
const { polygon, svg } = getOrCreateSvg(retained, element);
const strokeW = model.strokeWidth;
@@ -230,30 +155,37 @@ export const shapeDomRenderer = (
// Determine fill color
const finalFillColor = model.filled ? fillColor : 'transparent';
// Build SVG safely with DOM-API
const SVG_NS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', '100%');
svg.setAttribute('height', '100%');
svg.setAttribute('viewBox', `0 0 ${unscaledWidth} ${unscaledHeight}`);
svg.setAttribute('preserveAspectRatio', 'none');
const polygon = document.createElementNS(SVG_NS, 'polygon');
polygon.setAttribute('points', svgPoints);
polygon.setAttribute('fill', finalFillColor);
polygon.setAttribute('stroke', finalStrokeColor);
polygon.setAttribute('stroke-width', String(strokeW));
if (finalStrokeDasharray !== 'none') {
polygon.setAttribute('stroke-dasharray', finalStrokeDasharray);
} else {
polygon.removeAttribute('stroke-dasharray');
}
svg.append(polygon);
newChildren.push(svg);
} else {
// Standard rendering for other shapes (e.g., rect, ellipse)
removeSvg(retained);
// innerHTML was already cleared by applyShapeSpecificStyles if necessary
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border
}
if (model.textDisplay && model.text) {
const str = model.text.toString();
const textElement = getOrCreateText(retained, element);
const textElement = document.createElement('div');
if (isRTL(str)) {
textElement.dir = 'rtl';
} else {
textElement.removeAttribute('dir');
}
textElement.style.position = 'absolute';
textElement.style.inset = '0';
@@ -278,10 +210,12 @@ export const shapeDomRenderer = (
true
);
textElement.textContent = str;
} else {
removeText(retained);
newChildren.push(textElement);
}
// Replace existing children to avoid memory leaks
element.replaceChildren(...newChildren);
applyTransformStyles(model, element);
manageClassNames(model, element);
@@ -29,7 +29,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
@@ -34,8 +34,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
@@ -4,7 +4,6 @@ import type { FootNote } from '@blocksuite/affine-model';
import { CitationProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { isValidUrl, normalizeUrl } from '@blocksuite/affine-shared/utils';
import { WithDisposable } from '@blocksuite/global/lit';
import {
BlockSelection,
@@ -153,9 +152,7 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
};
private readonly _handleUrlReference = (url: string) => {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl || !isValidUrl(normalizedUrl)) return;
window.open(normalizedUrl, '_blank', 'noopener,noreferrer');
window.open(url, '_blank');
};
private readonly _updateFootnoteAttributes = (footnote: FootNote) => {
@@ -1,4 +1,3 @@
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
export default defineConfig({
@@ -9,9 +8,10 @@ export default defineConfig({
browser: {
enabled: true,
headless: true,
instances: [{ browser: 'chromium' }],
provider: playwright(),
name: 'chromium',
provider: 'playwright',
isolate: false,
providerOptions: {},
},
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
@@ -177,11 +177,6 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
override getNearestPoint(point: IVec): IVec {
const { mode, absolutePath: path } = this;
if (path.length === 0) {
const { x, y } = this;
return [x, y];
}
if (mode === ConnectorMode.Straight) {
const first = path[0];
const last = path[path.length - 1];
@@ -218,10 +213,6 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
h = bounds.h;
}
if (path.length === 0) {
return 0.5;
}
point[0] = Vec.clamp(point[0], x, x + w);
point[1] = Vec.clamp(point[1], y, y + h);
@@ -267,10 +258,6 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
h = bounds.h;
}
if (path.length === 0) {
return [x + w / 2, y + h / 2];
}
if (mode === ConnectorMode.Orthogonal) {
const points = path.map<IVec>(p => [p[0], p[1]]);
const point = Polyline.pointAt(points, offsetDistance);
@@ -313,10 +300,6 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel<ConnectorEle
const { mode, strokeWidth, absolutePath: path } = this;
if (path.length === 0) {
return false;
}
const point =
mode === ConnectorMode.Curve
? getBezierNearestPoint(getBezierParameters(path), currentPoint)
+1 -1
View File
@@ -74,7 +74,7 @@
],
"devDependencies": {
"@types/pdfmake": "^0.2.12",
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"version": "0.26.3"
}
@@ -1,108 +0,0 @@
import {
beforeEach,
describe,
expect,
it,
type MockInstance,
vi,
} from 'vitest';
import * as PointToRangeUtils from '../../utils/dom/point-to-range';
import { handleNativeRangeAtPoint } from '../../utils/dom/point-to-range';
describe('test handleNativeRangeAtPoint', () => {
let caretRangeFromPointSpy: MockInstance<
(clientX: number, clientY: number) => Range | null
>;
let resetNativeSelectionSpy: MockInstance<(range: Range | null) => void>;
beforeEach(() => {
caretRangeFromPointSpy = vi.spyOn(
PointToRangeUtils.api,
'caretRangeFromPoint'
);
resetNativeSelectionSpy = vi.spyOn(
PointToRangeUtils.api,
'resetNativeSelection'
);
});
it('does nothing if caretRangeFromPoint returns null', () => {
caretRangeFromPointSpy.mockReturnValue(null);
handleNativeRangeAtPoint(10, 10);
expect(resetNativeSelectionSpy).not.toHaveBeenCalled();
});
it('keeps range untouched if startContainer is a Text node', () => {
const div = document.createElement('div');
div.textContent = 'hello';
const text = div.firstChild!;
const range = document.createRange();
range.setStart(text, 2);
range.collapse(true);
caretRangeFromPointSpy.mockReturnValue(range);
handleNativeRangeAtPoint(10, 10);
expect(range.startContainer).toBe(text);
expect(range.startOffset).toBe(2);
expect(resetNativeSelectionSpy).toHaveBeenCalled();
});
it('moves caret into direct text child when clicking element', () => {
const div = document.createElement('div');
div.append('hello');
const range = document.createRange();
range.setStart(div, 1);
range.collapse(true);
caretRangeFromPointSpy.mockReturnValue(range);
handleNativeRangeAtPoint(10, 10);
expect(range.startContainer.nodeType).toBe(Node.TEXT_NODE);
expect(range.startContainer.textContent).toBe('hello');
expect(range.startOffset).toBe(5);
expect(resetNativeSelectionSpy).toHaveBeenCalled();
});
it('moves caret to last meaningful text inside nested element', () => {
const div = document.createElement('div');
div.innerHTML = `<span>a</span><span><em>b</em>c</span>`;
const range = document.createRange();
range.setStart(div, 2);
range.collapse(true);
caretRangeFromPointSpy.mockReturnValue(range);
handleNativeRangeAtPoint(10, 10);
expect(range.startContainer.nodeType).toBe(Node.TEXT_NODE);
expect(range.startContainer.textContent).toBe('c');
expect(range.startOffset).toBe(1);
expect(resetNativeSelectionSpy).toHaveBeenCalled();
});
it('falls back to searching startContainer when offset element has no text', () => {
const div = document.createElement('div');
div.innerHTML = `<span></span><span>ok</span>`;
const range = document.createRange();
range.setStart(div, 1);
range.collapse(true);
caretRangeFromPointSpy.mockReturnValue(range);
handleNativeRangeAtPoint(10, 10);
expect(range.startContainer.textContent).toBe('ok');
expect(range.startOffset).toBe(2);
expect(resetNativeSelectionSpy).toHaveBeenCalled();
});
});
@@ -88,73 +88,11 @@ export function getCurrentNativeRange(selection = window.getSelection()) {
return selection.getRangeAt(0);
}
// functions need to be mocked in unit-test
export const api = {
caretRangeFromPoint,
resetNativeSelection,
};
export function handleNativeRangeAtPoint(x: number, y: number) {
const range = api.caretRangeFromPoint(x, y);
if (range) {
normalizeCaretRange(range);
}
const range = caretRangeFromPoint(x, y);
const startContainer = range?.startContainer;
// click on rich text
if (startContainer instanceof Node) {
api.resetNativeSelection(range);
}
}
function lastMeaningfulTextNode(node: Node) {
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
return node.textContent && node.textContent?.trim().length > 0
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
let last = null;
while (walker.nextNode()) {
last = walker.currentNode;
}
return last;
}
function normalizeCaretRange(range: Range) {
let { startContainer, startOffset } = range;
if (startContainer.nodeType === Node.TEXT_NODE) return;
// Try to find text in the element at `startOffset`
const offsetEl =
startOffset > 0
? startContainer.childNodes[startOffset - 1]
: startContainer.childNodes[0];
if (offsetEl) {
if (offsetEl.nodeType === Node.TEXT_NODE) {
range.setStart(
offsetEl,
startOffset > 0 ? (offsetEl.textContent?.length ?? 0) : 0
);
range.collapse(true);
return;
}
const text = lastMeaningfulTextNode(offsetEl);
if (text) {
range.setStart(text, text.textContent?.length ?? 0);
range.collapse(true);
return;
}
}
// Fallback, try to find text in startContainer
const text = lastMeaningfulTextNode(startContainer);
if (text) {
range.setStart(text, text.textContent?.length ?? 0);
range.collapse(true);
return;
resetNativeSelection(range);
}
}
@@ -24,11 +24,6 @@ const toURL = (str: string) => {
}
};
const hasAllowedScheme = (url: URL) => {
const protocol = url.protocol.slice(0, -1).toLowerCase();
return ALLOWED_SCHEMES.has(protocol);
};
function resolveURL(str: string, baseUrl: string, padded = false) {
const url = toURL(str);
if (!url) return null;
@@ -66,7 +61,6 @@ export function normalizeUrl(str: string) {
// Formatted
if (url) {
if (!hasAllowedScheme(url)) return '';
if (!str.endsWith('/') && url.href.endsWith('/')) {
return url.href.substring(0, url.href.length - 1);
}
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineConfig({
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 1000,
coverage: {
provider: 'istanbul', // or 'istanbul'
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/affine-shared',
},
+1 -1
View File
@@ -62,7 +62,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"version": "0.26.3"
}
@@ -1,22 +0,0 @@
import { describe, expect, test } from 'vitest';
import { getBezierParameters } from '../gfx/curve.js';
import { PointLocation } from '../gfx/model/index.js';
describe('getBezierParameters', () => {
test('should handle empty path', () => {
expect(() => getBezierParameters([])).not.toThrow();
expect(getBezierParameters([])).toEqual([
new PointLocation(),
new PointLocation(),
new PointLocation(),
new PointLocation(),
]);
});
test('should handle single-point path', () => {
const point = new PointLocation([10, 20]);
expect(getBezierParameters([point])).toEqual([point, point, point, point]);
});
});
@@ -142,11 +142,6 @@ export function getBezierNearestPoint(
export function getBezierParameters(
points: PointLocation[]
): BezierCurveParameters {
if (points.length === 0) {
const point = new PointLocation();
return [point, point, point, point];
}
// Fallback for degenerate Bezier curve (all points are at the same position)
if (points.length === 1) {
const point = points[0];
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
coverage: {
provider: 'istanbul',
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/global',
},
+1 -2
View File
@@ -33,8 +33,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@vitest/browser-playwright": "^4.0.18",
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
+11 -2
View File
@@ -596,7 +596,7 @@ export class LayerManager extends GfxExtension {
private _updateLayer(
element: GfxModel | GfxLocalElementModel,
props?: Record<string, unknown>,
_oldValues?: Record<string, unknown>
oldValues?: Record<string, unknown>
) {
const modelType = this._getModelType(element);
const isLocalElem = element instanceof GfxLocalElementModel;
@@ -613,7 +613,16 @@ export class LayerManager extends GfxExtension {
};
if (shouldUpdateGroupChildren) {
this._reset();
const group = element as GfxModel & GfxGroupCompatibleInterface;
const oldChildIds = childIdsChanged
? Array.isArray(oldValues?.['childIds'])
? (oldValues['childIds'] as string[])
: this._groupChildSnapshot.get(group.id)
: undefined;
const relatedElements = this._getRelatedGroupElements(group, oldChildIds);
this._refreshElementsInLayer(relatedElements);
this._syncGroupChildSnapshot(group);
return true;
}
@@ -31,13 +31,6 @@ function updateTransform(element: GfxBlockComponent) {
element.style.transform = element.getCSSTransform();
}
function updateZIndex(element: GfxBlockComponent) {
const zIndex = element.toZIndex();
if (element.style.zIndex !== zIndex) {
element.style.zIndex = zIndex;
}
}
function updateBlockVisibility(view: GfxBlockComponent) {
if (view.transformState$.value === 'active') {
view.style.visibility = 'visible';
@@ -65,22 +58,14 @@ function handleGfxConnection(instance: GfxBlockComponent) {
instance.store.slots.blockUpdated.subscribe(({ type, id }) => {
if (id === instance.model.id && type === 'update') {
updateTransform(instance);
updateZIndex(instance);
}
})
);
instance.disposables.add(
instance.gfx.layer.slots.layerUpdated.subscribe(() => {
updateZIndex(instance);
})
);
instance.disposables.add(
effect(() => {
updateBlockVisibility(instance);
updateTransform(instance);
updateZIndex(instance);
})
);
}
@@ -120,23 +105,17 @@ export abstract class GfxBlockComponent<
onBoxSelected(_: BoxSelectionContext) {}
getCSSScaleVal(): number {
const viewport = this.gfx.viewport;
const { zoom, viewScale } = viewport;
return zoom / viewScale;
}
getCSSTransform() {
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom, viewScale } = viewport;
const { translateX, translateY, zoom } = viewport;
const bound = Bound.deserialize(this.model.xywh);
const scaledX = (bound.x * zoom) / viewScale;
const scaledY = (bound.y * zoom) / viewScale;
const scaledX = bound.x * zoom;
const scaledY = bound.y * zoom;
const deltaX = scaledX - bound.x;
const deltaY = scaledY - bound.y;
return `translate(${translateX / viewScale + deltaX}px, ${translateY / viewScale + deltaY}px) scale(${this.getCSSScaleVal()})`;
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
}
getRenderingRect() {
@@ -240,12 +219,18 @@ export function toGfxBlockComponent<
handleGfxConnection(this);
}
getCSSScaleVal(): number {
return GfxBlockComponent.prototype.getCSSScaleVal.call(this);
}
// eslint-disable-next-line sonarjs/no-identical-functions
getCSSTransform() {
return GfxBlockComponent.prototype.getCSSTransform.call(this);
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom } = viewport;
const bound = Bound.deserialize(this.model.xywh);
const scaledX = bound.x * zoom;
const scaledY = bound.y * zoom;
const deltaX = scaledX - bound.x;
const deltaY = scaledY - bound.y;
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
}
// eslint-disable-next-line sonarjs/no-identical-functions
+4 -4
View File
@@ -1,4 +1,3 @@
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
export default defineConfig({
@@ -9,14 +8,15 @@ export default defineConfig({
browser: {
enabled: true,
headless: true,
instances: [{ browser: 'chromium' }],
provider: playwright(),
name: 'chromium',
provider: 'playwright',
isolate: false,
providerOptions: {},
},
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
coverage: {
provider: 'istanbul',
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/std',
},
+1 -1
View File
@@ -29,7 +29,7 @@
"devDependencies": {
"@types/lodash.clonedeep": "^4.5.9",
"@types/lodash.merge": "^4.6.9",
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
+9 -5
View File
@@ -7,11 +7,15 @@ export * from './transformer';
export { type IdGenerator, nanoid, uuidv4 } from './utils/id-generator';
export * from './yjs';
const env = (typeof globalThis !== 'undefined'
? globalThis
: typeof window !== 'undefined'
? window
: {}) as unknown as Record<string, boolean>;
const env = (
typeof globalThis !== 'undefined'
? globalThis
: typeof window !== 'undefined'
? window
: typeof global !== 'undefined'
? global
: {}
) as Record<string, boolean>;
const importIdentifier = '__ $BLOCKSUITE_STORE$ __';
if (env[importIdentifier] === true) {
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
coverage: {
provider: 'istanbul',
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/store',
},
+1 -1
View File
@@ -19,7 +19,7 @@
"y-protocols": "^1.0.6"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"peerDependencies": {
"yjs": "*"
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
coverage: {
provider: 'istanbul',
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/sync',
},
+3 -4
View File
@@ -6,7 +6,7 @@
"dev": "vite",
"build": "tsc",
"test:unit": "vitest --browser.headless --run",
"test:debug": "PWDEBUG=1 npx vitest --browser.headless=false"
"test:debug": "PWDEBUG=1 npx vitest"
},
"sideEffects": false,
"keywords": [],
@@ -17,7 +17,7 @@
"@blocksuite/icons": "^2.2.17",
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.3",
"@lottiefiles/dotlottie-wc": "^0.9.4",
"@lottiefiles/dotlottie-wc": "^0.5.0",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@vanilla-extract/css": "^1.17.0",
@@ -41,11 +41,10 @@
],
"devDependencies": {
"@vanilla-extract/vite-plugin": "^5.0.0",
"@vitest/browser-playwright": "^4.0.18",
"vite": "^7.2.7",
"vite-plugin-istanbul": "^7.2.1",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"version": "0.26.3"
}
@@ -6,7 +6,6 @@ import type {
import { ungroupCommand } from '@blocksuite/affine/gfx/group';
import type {
GroupElementModel,
MindmapElementModel,
NoteBlockModel,
} from '@blocksuite/affine/model';
import { generateKeyBetween } from '@blocksuite/affine/std/gfx';
@@ -254,40 +253,6 @@ test('blocks should rerender when their z-index changed', async () => {
assertBlocksContent();
});
test('block host z-index should update after reordering', async () => {
const backId = addNote(doc);
const frontId = addNote(doc);
await wait();
const getBlockHost = (id: string) =>
document.querySelector<HTMLElement>(
`affine-edgeless-root gfx-viewport > [data-block-id="${id}"]`
);
const backHost = getBlockHost(backId);
const frontHost = getBlockHost(frontId);
expect(backHost).not.toBeNull();
expect(frontHost).not.toBeNull();
expect(Number(backHost!.style.zIndex)).toBeLessThan(
Number(frontHost!.style.zIndex)
);
service.crud.updateElement(backId, {
index: service.layer.getReorderedIndex(
service.crud.getElementById(backId)!,
'front'
),
});
await wait();
expect(Number(backHost!.style.zIndex)).toBeGreaterThan(
Number(frontHost!.style.zIndex)
);
});
describe('layer reorder functionality', () => {
let ids: string[] = [];
@@ -463,17 +428,14 @@ describe('group related functionality', () => {
const elements = [
service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[0,0,100,100]',
})!,
addNote(doc),
service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[120,0,100,100]',
})!,
addNote(doc),
service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[240,0,100,100]',
})!,
];
@@ -566,35 +528,6 @@ describe('group related functionality', () => {
expect(service.layer.layers[1].elements[0]).toBe(group);
});
test("change mindmap index should update its nodes' layer", async () => {
const noteId = addNote(doc);
const mindmapId = service.crud.addElement('mindmap', {
children: {
text: 'root',
children: [{ text: 'child' }],
},
})!;
await wait();
const note = service.crud.getElementById(noteId)!;
const mindmap = service.crud.getElementById(
mindmapId
)! as MindmapElementModel;
const root = mindmap.tree.element;
expect(service.layer.getZIndex(root)).toBeGreaterThan(
service.layer.getZIndex(note)
);
mindmap.index = service.layer.getReorderedIndex(mindmap, 'back');
await wait();
expect(service.layer.getZIndex(root)).toBeLessThan(
service.layer.getZIndex(note)
);
});
test('should keep relative index order of elements after group, ungroup, undo, redo', () => {
const edgeless = getDocRootBlock(doc, editor, 'edgeless');
const elementIds = [
@@ -836,7 +769,6 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[0,0,100,100]',
})!;
addNote(doc);
@@ -845,7 +777,6 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
service.crud.addElement('shape', {
shapeType: 'rect',
xywh: '[120,0,100,100]',
})!;
editor.mode = 'page';
@@ -861,10 +792,10 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
'.indexable-canvas'
)[0] as HTMLCanvasElement;
expect(indexedCanvas.width).toBeLessThanOrEqual(
expect(indexedCanvas.width).toBe(
(surface.renderer as CanvasRenderer).canvas.width
);
expect(indexedCanvas.height).toBeLessThanOrEqual(
expect(indexedCanvas.height).toBe(
(surface.renderer as CanvasRenderer).canvas.height
);
expect(indexedCanvas.width).not.toBe(0);
+6 -4
View File
@@ -1,5 +1,4 @@
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
export default defineConfig(_configEnv =>
@@ -19,13 +18,13 @@ export default defineConfig(_configEnv =>
retry: process.env.CI === 'true' ? 3 : 0,
browser: {
enabled: true,
headless: true,
headless: process.env.CI === 'true',
instances: [
{ browser: 'chromium' },
{ browser: 'firefox' },
{ browser: 'webkit' },
],
provider: playwright(),
provider: 'playwright',
isolate: false,
viewport: {
width: 1024,
@@ -33,13 +32,16 @@ export default defineConfig(_configEnv =>
},
},
coverage: {
provider: 'istanbul',
provider: 'istanbul', // or 'c8'
reporter: ['lcov'],
reportsDirectory: '../../.coverage/integration-test',
},
deps: {
interopDefault: true,
},
testTransformMode: {
web: ['src/__tests__/**/*.spec.ts'],
},
},
})
);
+6 -6
View File
@@ -22,7 +22,7 @@
"af": "r affine.ts",
"dev": "yarn affine dev",
"build": "yarn affine build",
"lint:eslint": "cross-env NODE_OPTIONS=\"--max-old-space-size=16384\" eslint --report-unused-disable-directives-severity=off . --cache",
"lint:eslint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint --report-unused-disable-directives-severity=off . --cache",
"lint:eslint:fix": "yarn lint:eslint --fix --fix-type problem,suggestion,layout",
"lint:prettier": "prettier --ignore-unknown --cache --check .",
"lint:prettier:fix": "prettier --ignore-unknown --cache --write .",
@@ -56,7 +56,7 @@
"@faker-js/faker": "^10.1.0",
"@istanbuljs/schema": "^0.1.3",
"@magic-works/i18n-codegen": "^0.6.1",
"@playwright/test": "=1.58.2",
"@playwright/test": "=1.52.0",
"@smarttools/eslint-plugin-rxjs": "^1.0.8",
"@taplo/cli": "^0.7.0",
"@toeverything/infra": "workspace:*",
@@ -64,9 +64,9 @@
"@types/node": "^22.0.0",
"@typescript-eslint/parser": "^8.55.0",
"@vanilla-extract/vite-plugin": "^5.0.0",
"@vitest/browser": "^4.0.18",
"@vitest/coverage-istanbul": "^4.0.18",
"@vitest/ui": "^4.0.18",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-istanbul": "^3.2.4",
"@vitest/ui": "^3.2.4",
"cross-env": "^10.1.0",
"electron": "^39.0.0",
"eslint": "^9.39.2",
@@ -90,7 +90,7 @@
"typescript-eslint": "^8.55.0",
"unplugin-swc": "^1.5.9",
"vite": "^7.2.7",
"vitest": "^4.0.18"
"vitest": "^3.2.4"
},
"packageManager": "yarn@4.12.0",
"resolutions": {
-7
View File
@@ -14,20 +14,13 @@ affine_common = { workspace = true, features = [
"napi",
"ydoc-loader",
] }
anyhow = { workspace = true }
chrono = { workspace = true }
file-format = { workspace = true }
image = { workspace = true }
infer = { workspace = true }
libwebp-sys = { workspace = true }
little_exif = { workspace = true }
llm_adapter = { workspace = true }
mp4parse = { workspace = true }
napi = { workspace = true, features = ["async"] }
napi-derive = { workspace = true }
rand = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha3 = { workspace = true }
tiktoken-rs = { workspace = true }
v_htmlescape = { workspace = true }
-12
View File
@@ -1,9 +1,5 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
export declare class LlmStreamHandle {
abort(): void
}
export declare class Tokenizer {
count(content: string, allowedSpecial?: Array<string> | undefined | null): number
}
@@ -50,10 +46,6 @@ export declare function getMime(input: Uint8Array): string
export declare function htmlSanitize(input: string): string
export declare function llmDispatch(protocol: string, backendConfigJson: string, requestJson: string): string
export declare function llmDispatchStream(protocol: string, backendConfigJson: string, requestJson: string, callback: ((err: Error | null, arg: string) => void)): LlmStreamHandle
/**
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
* result binary.
@@ -83,8 +75,6 @@ export interface NativeCrawlResult {
export interface NativeMarkdownResult {
title: string
markdown: string
knownUnsupportedBlocks: Array<string>
unknownBlocks: Array<string>
}
export interface NativePageDocContent {
@@ -112,8 +102,6 @@ export declare function parsePageDoc(docBin: Buffer, maxSummaryLength?: number |
export declare function parseWorkspaceDoc(docBin: Buffer): NativeWorkspaceDocContent | null
export declare function processImage(input: Buffer, maxEdge: number, keepExif: boolean): Promise<Buffer>
export declare function readAllDocIdsFromRootDoc(docBin: Buffer, includeTrash?: boolean | undefined | null): Array<string>
/**
-4
View File
@@ -9,8 +9,6 @@ use napi_derive::napi;
pub struct NativeMarkdownResult {
pub title: String,
pub markdown: String,
pub known_unsupported_blocks: Vec<String>,
pub unknown_blocks: Vec<String>,
}
impl From<MarkdownResult> for NativeMarkdownResult {
@@ -18,8 +16,6 @@ impl From<MarkdownResult> for NativeMarkdownResult {
Self {
title: result.title,
markdown: result.markdown,
known_unsupported_blocks: result.known_unsupported_blocks,
unknown_blocks: result.unknown_blocks,
}
}
}
-353
View File
@@ -1,353 +0,0 @@
use std::io::Cursor;
use anyhow::{Context, Result as AnyResult, bail};
use image::{
AnimationDecoder, DynamicImage, ImageDecoder, ImageFormat, ImageReader,
codecs::{gif::GifDecoder, png::PngDecoder, webp::WebPDecoder},
imageops::FilterType,
metadata::Orientation,
};
use libwebp_sys::{
WEBP_MUX_ABI_VERSION, WebPData, WebPDataClear, WebPDataInit, WebPEncodeRGBA, WebPFree, WebPMuxAssemble,
WebPMuxCreateInternal, WebPMuxDelete, WebPMuxError, WebPMuxSetChunk,
};
use little_exif::{exif_tag::ExifTag, filetype::FileExtension, metadata::Metadata};
use napi::{
Env, Error, Result, Status, Task,
bindgen_prelude::{AsyncTask, Buffer},
};
use napi_derive::napi;
const WEBP_QUALITY: f32 = 80.0;
const MAX_IMAGE_DIMENSION: u32 = 16_384;
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
pub struct AsyncProcessImageTask {
input: Vec<u8>,
max_edge: u32,
keep_exif: bool,
}
#[napi]
impl Task for AsyncProcessImageTask {
type Output = Vec<u8>;
type JsValue = Buffer;
fn compute(&mut self) -> Result<Self::Output> {
process_image_inner(&self.input, self.max_edge, self.keep_exif)
.map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
}
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output.into())
}
}
#[napi]
pub fn process_image(input: Buffer, max_edge: u32, keep_exif: bool) -> AsyncTask<AsyncProcessImageTask> {
AsyncTask::new(AsyncProcessImageTask {
input: input.to_vec(),
max_edge,
keep_exif,
})
}
fn process_image_inner(input: &[u8], max_edge: u32, keep_exif: bool) -> AnyResult<Vec<u8>> {
if max_edge == 0 {
bail!("max_edge must be greater than 0");
}
let format = image::guess_format(input).context("unsupported image format")?;
let (width, height) = read_dimensions(input, format)?;
validate_dimensions(width, height)?;
let mut image = decode_image(input, format)?;
let orientation = read_orientation(input, format)?;
image.apply_orientation(orientation);
if image.width().max(image.height()) > max_edge {
image = image.resize(max_edge, max_edge, FilterType::Lanczos3);
}
let mut output = encode_webp_lossy(&image.into_rgba8())?;
if keep_exif {
preserve_exif(input, format, &mut output)?;
}
Ok(output)
}
fn read_dimensions(input: &[u8], format: ImageFormat) -> AnyResult<(u32, u32)> {
ImageReader::with_format(Cursor::new(input), format)
.into_dimensions()
.context("failed to decode image")
}
fn validate_dimensions(width: u32, height: u32) -> AnyResult<()> {
if width == 0 || height == 0 {
bail!("failed to decode image");
}
if width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION {
bail!("image dimensions exceed limit");
}
if u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS {
bail!("image pixel count exceeds limit");
}
Ok(())
}
fn decode_image(input: &[u8], format: ImageFormat) -> AnyResult<DynamicImage> {
Ok(match format {
ImageFormat::Gif => {
let decoder = GifDecoder::new(Cursor::new(input)).context("failed to decode image")?;
let frame = decoder
.into_frames()
.next()
.transpose()
.context("failed to decode image")?
.context("image does not contain any frames")?;
DynamicImage::ImageRgba8(frame.into_buffer())
}
ImageFormat::Png => {
let decoder = PngDecoder::new(Cursor::new(input)).context("failed to decode image")?;
if decoder.is_apng().context("failed to decode image")? {
let frame = decoder
.apng()
.context("failed to decode image")?
.into_frames()
.next()
.transpose()
.context("failed to decode image")?
.context("image does not contain any frames")?;
DynamicImage::ImageRgba8(frame.into_buffer())
} else {
DynamicImage::from_decoder(decoder).context("failed to decode image")?
}
}
ImageFormat::WebP => {
let decoder = WebPDecoder::new(Cursor::new(input)).context("failed to decode image")?;
let frame = decoder
.into_frames()
.next()
.transpose()
.context("failed to decode image")?
.context("image does not contain any frames")?;
DynamicImage::ImageRgba8(frame.into_buffer())
}
_ => {
let reader = ImageReader::with_format(Cursor::new(input), format);
let decoder = reader.into_decoder().context("failed to decode image")?;
DynamicImage::from_decoder(decoder).context("failed to decode image")?
}
})
}
fn read_orientation(input: &[u8], format: ImageFormat) -> AnyResult<Orientation> {
Ok(match format {
ImageFormat::Gif => GifDecoder::new(Cursor::new(input))
.context("failed to decode image")?
.orientation()
.context("failed to decode image")?,
ImageFormat::Png => PngDecoder::new(Cursor::new(input))
.context("failed to decode image")?
.orientation()
.context("failed to decode image")?,
ImageFormat::WebP => WebPDecoder::new(Cursor::new(input))
.context("failed to decode image")?
.orientation()
.context("failed to decode image")?,
_ => ImageReader::with_format(Cursor::new(input), format)
.into_decoder()
.context("failed to decode image")?
.orientation()
.context("failed to decode image")?,
})
}
fn encode_webp_lossy(image: &image::RgbaImage) -> AnyResult<Vec<u8>> {
let width = i32::try_from(image.width()).context("image width is too large")?;
let height = i32::try_from(image.height()).context("image height is too large")?;
let stride = width.checked_mul(4).context("image width is too large")?;
let mut output = std::ptr::null_mut();
let encoded_len = unsafe { WebPEncodeRGBA(image.as_ptr(), width, height, stride, WEBP_QUALITY, &mut output) };
if output.is_null() || encoded_len == 0 {
bail!("failed to encode webp");
}
let encoded = unsafe { std::slice::from_raw_parts(output, encoded_len) }.to_vec();
unsafe {
WebPFree(output.cast());
}
Ok(encoded)
}
fn preserve_exif(input: &[u8], format: ImageFormat, output: &mut Vec<u8>) -> AnyResult<()> {
let Some(file_type) = map_exif_file_type(format) else {
return Ok(());
};
let input = input.to_vec();
let Ok(mut metadata) = Metadata::new_from_vec(&input, file_type) else {
return Ok(());
};
metadata.remove_tag(ExifTag::Orientation(vec![1]));
if !metadata.get_ifds().iter().any(|ifd| !ifd.get_tags().is_empty()) {
return Ok(());
}
let encoded_metadata = metadata.encode().context("failed to preserve exif metadata")?;
let source = WebPData {
bytes: output.as_ptr(),
size: output.len(),
};
let exif = WebPData {
bytes: encoded_metadata.as_ptr(),
size: encoded_metadata.len(),
};
let mut assembled = WebPData::default();
let mux = unsafe { WebPMuxCreateInternal(&source, 1, WEBP_MUX_ABI_VERSION as _) };
if mux.is_null() {
bail!("failed to preserve exif metadata");
}
let encoded = (|| -> AnyResult<Vec<u8>> {
if unsafe { WebPMuxSetChunk(mux, c"EXIF".as_ptr(), &exif, 1) } != WebPMuxError::WEBP_MUX_OK {
bail!("failed to preserve exif metadata");
}
WebPDataInit(&mut assembled);
if unsafe { WebPMuxAssemble(mux, &mut assembled) } != WebPMuxError::WEBP_MUX_OK {
bail!("failed to preserve exif metadata");
}
Ok(unsafe { std::slice::from_raw_parts(assembled.bytes, assembled.size) }.to_vec())
})();
unsafe {
WebPDataClear(&mut assembled);
WebPMuxDelete(mux);
}
*output = encoded?;
Ok(())
}
fn map_exif_file_type(format: ImageFormat) -> Option<FileExtension> {
match format {
ImageFormat::Jpeg => Some(FileExtension::JPEG),
ImageFormat::Png => Some(FileExtension::PNG { as_zTXt_chunk: true }),
ImageFormat::Tiff => Some(FileExtension::TIFF),
ImageFormat::WebP => Some(FileExtension::WEBP),
_ => None,
}
}
#[cfg(test)]
mod tests {
use image::{ExtendedColorType, GenericImageView, ImageEncoder, codecs::png::PngEncoder};
use super::*;
fn encode_png(width: u32, height: u32) -> Vec<u8> {
let image = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 0, 255]));
let mut encoded = Vec::new();
PngEncoder::new(&mut encoded)
.write_image(image.as_raw(), width, height, ExtendedColorType::Rgba8)
.unwrap();
encoded
}
fn encode_bmp_header(width: u32, height: u32) -> Vec<u8> {
let mut encoded = Vec::with_capacity(54);
encoded.extend_from_slice(b"BM");
encoded.extend_from_slice(&(54u32).to_le_bytes());
encoded.extend_from_slice(&0u16.to_le_bytes());
encoded.extend_from_slice(&0u16.to_le_bytes());
encoded.extend_from_slice(&(54u32).to_le_bytes());
encoded.extend_from_slice(&(40u32).to_le_bytes());
encoded.extend_from_slice(&(width as i32).to_le_bytes());
encoded.extend_from_slice(&(height as i32).to_le_bytes());
encoded.extend_from_slice(&1u16.to_le_bytes());
encoded.extend_from_slice(&24u16.to_le_bytes());
encoded.extend_from_slice(&0u32.to_le_bytes());
encoded.extend_from_slice(&0u32.to_le_bytes());
encoded.extend_from_slice(&0u32.to_le_bytes());
encoded.extend_from_slice(&0u32.to_le_bytes());
encoded.extend_from_slice(&0u32.to_le_bytes());
encoded.extend_from_slice(&0u32.to_le_bytes());
encoded
}
#[test]
fn process_image_keeps_small_dimensions() {
let png = encode_png(8, 6);
let output = process_image_inner(&png, 512, false).unwrap();
let format = image::guess_format(&output).unwrap();
assert_eq!(format, ImageFormat::WebP);
let decoded = image::load_from_memory(&output).unwrap();
assert_eq!(decoded.dimensions(), (8, 6));
}
#[test]
fn process_image_scales_down_large_dimensions() {
let png = encode_png(1024, 256);
let output = process_image_inner(&png, 512, false).unwrap();
let decoded = image::load_from_memory(&output).unwrap();
assert_eq!(decoded.dimensions(), (512, 128));
}
#[test]
fn process_image_preserves_exif_without_orientation() {
let png = encode_png(8, 8);
let mut png_with_exif = png.clone();
let mut metadata = Metadata::new();
metadata.set_tag(ExifTag::ImageDescription("copilot".to_string()));
metadata.set_tag(ExifTag::Orientation(vec![6]));
metadata
.write_to_vec(&mut png_with_exif, FileExtension::PNG { as_zTXt_chunk: true })
.unwrap();
let output = process_image_inner(&png_with_exif, 512, true).unwrap();
let decoded_metadata = Metadata::new_from_vec(&output, FileExtension::WEBP).unwrap();
assert!(
decoded_metadata
.get_tag(&ExifTag::ImageDescription(String::new()))
.next()
.is_some()
);
assert!(
decoded_metadata
.get_tag(&ExifTag::Orientation(vec![1]))
.next()
.is_none()
);
}
#[test]
fn process_image_rejects_invalid_input() {
let error = process_image_inner(b"not-an-image", 512, false).unwrap_err();
assert_eq!(error.to_string(), "unsupported image format");
}
#[test]
fn process_image_rejects_images_over_dimension_limit_before_decode() {
let bmp = encode_bmp_header(MAX_IMAGE_DIMENSION + 1, 1);
let error = process_image_inner(&bmp, 512, false).unwrap_err();
assert_eq!(error.to_string(), "image dimensions exceed limit");
}
}
-2
View File
@@ -7,8 +7,6 @@ pub mod doc_loader;
pub mod file_type;
pub mod hashcash;
pub mod html_sanitize;
pub mod image;
pub mod llm;
pub mod tiktoken;
use affine_common::napi_utils::map_napi_err;
-339
View File
@@ -1,339 +0,0 @@
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use llm_adapter::{
backend::{
BackendConfig, BackendError, BackendProtocol, ReqwestHttpClient, dispatch_request, dispatch_stream_events_with,
},
core::{CoreRequest, StreamEvent},
middleware::{
MiddlewareConfig, PipelineContext, RequestMiddleware, StreamMiddleware, citation_indexing, clamp_max_tokens,
normalize_messages, run_request_middleware_chain, run_stream_middleware_chain, stream_event_normalize,
tool_schema_rewrite,
},
};
use napi::{
Error, Result, Status,
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
};
use serde::Deserialize;
pub const STREAM_END_MARKER: &str = "__AFFINE_LLM_STREAM_END__";
const STREAM_ABORTED_REASON: &str = "__AFFINE_LLM_STREAM_ABORTED__";
const STREAM_CALLBACK_DISPATCH_FAILED_REASON: &str = "__AFFINE_LLM_STREAM_CALLBACK_DISPATCH_FAILED__";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
struct LlmMiddlewarePayload {
request: Vec<String>,
stream: Vec<String>,
config: MiddlewareConfig,
}
#[derive(Debug, Clone, Deserialize)]
struct LlmDispatchPayload {
#[serde(flatten)]
request: CoreRequest,
#[serde(default)]
middleware: LlmMiddlewarePayload,
}
#[napi]
pub struct LlmStreamHandle {
aborted: Arc<AtomicBool>,
}
#[napi]
impl LlmStreamHandle {
#[napi]
pub fn abort(&self) {
self.aborted.store(true, Ordering::SeqCst);
}
}
#[napi(catch_unwind)]
pub fn llm_dispatch(protocol: String, backend_config_json: String, request_json: String) -> Result<String> {
let protocol = parse_protocol(&protocol)?;
let config: BackendConfig = serde_json::from_str(&backend_config_json).map_err(map_json_error)?;
let payload: LlmDispatchPayload = serde_json::from_str(&request_json).map_err(map_json_error)?;
let request = apply_request_middlewares(payload.request, &payload.middleware)?;
let response =
dispatch_request(&ReqwestHttpClient::default(), &config, protocol, &request).map_err(map_backend_error)?;
serde_json::to_string(&response).map_err(map_json_error)
}
#[napi(catch_unwind)]
pub fn llm_dispatch_stream(
protocol: String,
backend_config_json: String,
request_json: String,
callback: ThreadsafeFunction<String, ()>,
) -> Result<LlmStreamHandle> {
let protocol = parse_protocol(&protocol)?;
let config: BackendConfig = serde_json::from_str(&backend_config_json).map_err(map_json_error)?;
let payload: LlmDispatchPayload = serde_json::from_str(&request_json).map_err(map_json_error)?;
let request = apply_request_middlewares(payload.request, &payload.middleware)?;
let middleware = payload.middleware.clone();
let aborted = Arc::new(AtomicBool::new(false));
let aborted_in_worker = aborted.clone();
std::thread::spawn(move || {
let chain = match resolve_stream_chain(&middleware.stream) {
Ok(chain) => chain,
Err(error) => {
emit_error_event(&callback, error.reason.clone(), "middleware_error");
let _ = callback.call(
Ok(STREAM_END_MARKER.to_string()),
ThreadsafeFunctionCallMode::NonBlocking,
);
return;
}
};
let mut pipeline = StreamPipeline::new(chain, middleware.config.clone());
let mut aborted_by_user = false;
let mut callback_dispatch_failed = false;
let result = dispatch_stream_events_with(&ReqwestHttpClient::default(), &config, protocol, &request, |event| {
if aborted_in_worker.load(Ordering::Relaxed) {
aborted_by_user = true;
return Err(BackendError::Http(STREAM_ABORTED_REASON.to_string()));
}
for event in pipeline.process(event) {
let status = emit_stream_event(&callback, &event);
if status != Status::Ok {
callback_dispatch_failed = true;
return Err(BackendError::Http(format!(
"{STREAM_CALLBACK_DISPATCH_FAILED_REASON}:{status}"
)));
}
}
Ok(())
});
if !aborted_by_user {
for event in pipeline.finish() {
if aborted_in_worker.load(Ordering::Relaxed) {
aborted_by_user = true;
break;
}
if emit_stream_event(&callback, &event) != Status::Ok {
callback_dispatch_failed = true;
break;
}
}
}
if let Err(error) = result
&& !aborted_by_user
&& !callback_dispatch_failed
&& !is_abort_error(&error)
&& !is_callback_dispatch_failed_error(&error)
{
emit_error_event(&callback, error.to_string(), "dispatch_error");
}
if !callback_dispatch_failed {
let _ = callback.call(
Ok(STREAM_END_MARKER.to_string()),
ThreadsafeFunctionCallMode::NonBlocking,
);
}
});
Ok(LlmStreamHandle { aborted })
}
fn apply_request_middlewares(request: CoreRequest, middleware: &LlmMiddlewarePayload) -> Result<CoreRequest> {
let chain = resolve_request_chain(&middleware.request)?;
Ok(run_request_middleware_chain(request, &middleware.config, &chain))
}
#[derive(Clone)]
struct StreamPipeline {
chain: Vec<StreamMiddleware>,
config: MiddlewareConfig,
context: PipelineContext,
}
impl StreamPipeline {
fn new(chain: Vec<StreamMiddleware>, config: MiddlewareConfig) -> Self {
Self {
chain,
config,
context: PipelineContext::default(),
}
}
fn process(&mut self, event: StreamEvent) -> Vec<StreamEvent> {
run_stream_middleware_chain(event, &mut self.context, &self.config, &self.chain)
}
fn finish(&mut self) -> Vec<StreamEvent> {
self.context.flush_pending_deltas();
self.context.drain_queued_events()
}
}
fn emit_stream_event(callback: &ThreadsafeFunction<String, ()>, event: &StreamEvent) -> Status {
let value = serde_json::to_string(event).unwrap_or_else(|error| {
serde_json::json!({
"type": "error",
"message": format!("failed to serialize stream event: {error}"),
})
.to_string()
});
callback.call(Ok(value), ThreadsafeFunctionCallMode::NonBlocking)
}
fn emit_error_event(callback: &ThreadsafeFunction<String, ()>, message: String, code: &str) {
let error_event = serde_json::to_string(&StreamEvent::Error {
message: message.clone(),
code: Some(code.to_string()),
})
.unwrap_or_else(|_| {
serde_json::json!({
"type": "error",
"message": message,
"code": code,
})
.to_string()
});
let _ = callback.call(Ok(error_event), ThreadsafeFunctionCallMode::NonBlocking);
}
fn is_abort_error(error: &BackendError) -> bool {
matches!(
error,
BackendError::Http(reason) if reason == STREAM_ABORTED_REASON
)
}
fn is_callback_dispatch_failed_error(error: &BackendError) -> bool {
matches!(
error,
BackendError::Http(reason) if reason.starts_with(STREAM_CALLBACK_DISPATCH_FAILED_REASON)
)
}
fn resolve_request_chain(request: &[String]) -> Result<Vec<RequestMiddleware>> {
if request.is_empty() {
return Ok(vec![normalize_messages, tool_schema_rewrite]);
}
request
.iter()
.map(|name| match name.as_str() {
"normalize_messages" => Ok(normalize_messages as RequestMiddleware),
"clamp_max_tokens" => Ok(clamp_max_tokens as RequestMiddleware),
"tool_schema_rewrite" => Ok(tool_schema_rewrite as RequestMiddleware),
_ => Err(Error::new(
Status::InvalidArg,
format!("Unsupported request middleware: {name}"),
)),
})
.collect()
}
fn resolve_stream_chain(stream: &[String]) -> Result<Vec<StreamMiddleware>> {
if stream.is_empty() {
return Ok(vec![stream_event_normalize, citation_indexing]);
}
stream
.iter()
.map(|name| match name.as_str() {
"stream_event_normalize" => Ok(stream_event_normalize as StreamMiddleware),
"citation_indexing" => Ok(citation_indexing as StreamMiddleware),
_ => Err(Error::new(
Status::InvalidArg,
format!("Unsupported stream middleware: {name}"),
)),
})
.collect()
}
fn parse_protocol(protocol: &str) -> Result<BackendProtocol> {
match protocol {
"openai_chat" | "openai-chat" | "openai_chat_completions" | "chat-completions" | "chat_completions" => {
Ok(BackendProtocol::OpenaiChatCompletions)
}
"openai_responses" | "openai-responses" | "responses" => Ok(BackendProtocol::OpenaiResponses),
"anthropic" | "anthropic_messages" | "anthropic-messages" => Ok(BackendProtocol::AnthropicMessages),
other => Err(Error::new(
Status::InvalidArg,
format!("Unsupported llm backend protocol: {other}"),
)),
}
}
fn map_json_error(error: serde_json::Error) -> Error {
Error::new(Status::InvalidArg, format!("Invalid JSON payload: {error}"))
}
fn map_backend_error(error: BackendError) -> Error {
Error::new(Status::GenericFailure, error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_parse_supported_protocol_aliases() {
assert!(parse_protocol("openai_chat").is_ok());
assert!(parse_protocol("chat-completions").is_ok());
assert!(parse_protocol("responses").is_ok());
assert!(parse_protocol("anthropic").is_ok());
}
#[test]
fn should_reject_unsupported_protocol() {
let error = parse_protocol("unknown").unwrap_err();
assert_eq!(error.status, Status::InvalidArg);
assert!(error.reason.contains("Unsupported llm backend protocol"));
}
#[test]
fn llm_dispatch_should_reject_invalid_backend_json() {
let error = llm_dispatch("openai_chat".to_string(), "{".to_string(), "{}".to_string()).unwrap_err();
assert_eq!(error.status, Status::InvalidArg);
assert!(error.reason.contains("Invalid JSON payload"));
}
#[test]
fn map_json_error_should_use_invalid_arg_status() {
let parse_error = serde_json::from_str::<serde_json::Value>("{").unwrap_err();
let error = map_json_error(parse_error);
assert_eq!(error.status, Status::InvalidArg);
assert!(error.reason.contains("Invalid JSON payload"));
}
#[test]
fn resolve_request_chain_should_support_clamp_max_tokens() {
let chain = resolve_request_chain(&["normalize_messages".to_string(), "clamp_max_tokens".to_string()]).unwrap();
assert_eq!(chain.len(), 2);
}
#[test]
fn resolve_request_chain_should_reject_unknown_middleware() {
let error = resolve_request_chain(&["unknown".to_string()]).unwrap_err();
assert_eq!(error.status, Status::InvalidArg);
assert!(error.reason.contains("Unsupported request middleware"));
}
#[test]
fn resolve_stream_chain_should_reject_unknown_middleware() {
let error = resolve_stream_chain(&["unknown".to_string()]).unwrap_err();
assert_eq!(error.status, Status::InvalidArg);
assert!(error.reason.contains("Unsupported stream middleware"));
}
}
-1
View File
@@ -6,7 +6,6 @@
# MAILER_HOST=127.0.0.1
# MAILER_PORT=1025
# MAILER_SERVERNAME="mail.example.com"
# MAILER_SENDER="noreply@toeverything.info"
# MAILER_USER="noreply@toeverything.info"
# MAILER_PASSWORD="affine"
+26 -17
View File
@@ -4,14 +4,17 @@
"version": "0.26.3",
"description": "Affine Node.js server",
"type": "module",
"bin": {
"run-test": "./scripts/run-test.ts"
},
"scripts": {
"build": "affine bundle -p @affine/server",
"dev": "nodemon ./src/index.ts",
"dev:mail": "email dev -d src/mails",
"test": "ava --concurrency 1 --serial",
"test:copilot": "ava \"src/__tests__/copilot/copilot-*.spec.ts\"",
"test:copilot": "ava \"src/__tests__/copilot-*.spec.ts\"",
"test:coverage": "c8 ava --concurrency 1 --serial",
"test:copilot:coverage": "c8 ava --timeout=5m \"src/__tests__/copilot/copilot-*.spec.ts\"",
"test:copilot:coverage": "c8 ava --timeout=5m \"src/__tests__/copilot-*.spec.ts\"",
"e2e": "cross-env TEST_MODE=e2e ava --serial",
"e2e:coverage": "cross-env TEST_MODE=e2e c8 ava --serial",
"data-migration": "cross-env NODE_ENV=development SERVER_FLAVOR=script r ./src/index.ts",
@@ -25,14 +28,19 @@
"dependencies": {
"@affine/s3-compat": "workspace:*",
"@affine/server-native": "workspace:*",
"@ai-sdk/google": "^3.0.46",
"@ai-sdk/google-vertex": "^4.0.83",
"@ai-sdk/anthropic": "^2.0.54",
"@ai-sdk/google": "^2.0.45",
"@ai-sdk/google-vertex": "^3.0.88",
"@ai-sdk/openai": "^2.0.80",
"@ai-sdk/openai-compatible": "^1.0.28",
"@ai-sdk/perplexity": "^2.0.21",
"@apollo/server": "^4.13.0",
"@fal-ai/serverless-client": "^0.15.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google-cloud/opentelemetry-resource-util": "^3.0.0",
"@nestjs-cls/transactional": "^3.2.0",
"@nestjs-cls/transactional-adapter-prisma": "^1.3.4",
"@modelcontextprotocol/sdk": "^1.26.0",
"@nestjs-cls/transactional": "^2.7.0",
"@nestjs-cls/transactional-adapter-prisma": "^1.2.24",
"@nestjs/apollo": "^13.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.0.21",
@@ -47,18 +55,18 @@
"@node-rs/crc32": "^1.10.6",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.2.0",
"@opentelemetry/exporter-prometheus": "^0.212.0",
"@opentelemetry/exporter-prometheus": "^0.211.0",
"@opentelemetry/exporter-zipkin": "^2.2.0",
"@opentelemetry/host-metrics": "^0.38.0",
"@opentelemetry/instrumentation": "^0.212.0",
"@opentelemetry/instrumentation-graphql": "^0.60.0",
"@opentelemetry/instrumentation-http": "^0.212.0",
"@opentelemetry/instrumentation-ioredis": "^0.60.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.58.0",
"@opentelemetry/instrumentation-socket.io": "^0.59.0",
"@opentelemetry/instrumentation": "^0.211.0",
"@opentelemetry/instrumentation-graphql": "^0.58.0",
"@opentelemetry/instrumentation-http": "^0.211.0",
"@opentelemetry/instrumentation-ioredis": "^0.59.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.57.0",
"@opentelemetry/instrumentation-socket.io": "^0.57.0",
"@opentelemetry/resources": "^2.2.0",
"@opentelemetry/sdk-metrics": "^2.2.0",
"@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/sdk-node": "^0.211.0",
"@opentelemetry/sdk-trace-node": "^2.2.0",
"@opentelemetry/semantic-conventions": "^1.38.0",
"@prisma/client": "^6.6.0",
@@ -66,7 +74,7 @@
"@queuedash/api": "^3.16.0",
"@react-email/components": "^0.5.7",
"@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.118",
"ai": "^5.0.118",
"bullmq": "^5.40.2",
"cookie-parser": "^1.4.7",
"cross-env": "^10.1.0",
@@ -118,6 +126,7 @@
"@faker-js/faker": "^10.1.0",
"@nestjs/swagger": "^11.2.0",
"@nestjs/testing": "patch:@nestjs/testing@npm%3A10.4.15#~/.yarn/patches/@nestjs-testing-npm-10.4.15-d591a1705a.patch",
"@react-email/preview-server": "^4.3.2",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.1",
"@types/express-serve-static-core": "^5.0.6",
@@ -133,8 +142,8 @@
"@types/react-dom": "^19.0.2",
"@types/semver": "^7.5.8",
"@types/sinon": "^21.0.0",
"@types/supertest": "^7.0.0",
"ava": "^7.0.0",
"@types/supertest": "^6.0.2",
"ava": "^6.4.0",
"c8": "^10.1.3",
"nodemon": "^3.1.14",
"react-email": "^4.3.2",
@@ -12,12 +12,12 @@ Generated by [AVA](https://avajs.dev).
{
messages: [
{
content: 'generate text to text stream',
content: 'generate text to text',
role: 'assistant',
},
],
pinned: false,
tokens: 10,
tokens: 8,
},
]
@@ -27,12 +27,12 @@ Generated by [AVA](https://avajs.dev).
{
messages: [
{
content: 'generate text to text stream',
content: 'generate text to text',
role: 'assistant',
},
],
pinned: false,
tokens: 10,
tokens: 8,
},
]
@@ -43,9 +43,7 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 5
Buffer @Uint8Array [
89504e47 0d0a1a0a 0000000d 49484452 00000001 00000001 08040000 00b51c0c
02000000 0b494441 5478da63 fcff1f00 03030200 efa37c9f 00000000 49454e44
ae426082
66616b65 20696d61 6765
]
## should preview link
@@ -4,31 +4,31 @@ import type { ExecutionContext, TestFn } from 'ava';
import ava from 'ava';
import { z } from 'zod';
import { ServerFeature, ServerService } from '../../core';
import { AuthService } from '../../core/auth';
import { QuotaModule } from '../../core/quota';
import { Models } from '../../models';
import { CopilotModule } from '../../plugins/copilot';
import { prompts, PromptService } from '../../plugins/copilot/prompt';
import { ServerFeature, ServerService } from '../core';
import { AuthService } from '../core/auth';
import { QuotaModule } from '../core/quota';
import { Models } from '../models';
import { CopilotModule } from '../plugins/copilot';
import { prompts, PromptService } from '../plugins/copilot/prompt';
import {
CopilotProviderFactory,
CopilotProviderType,
StreamObject,
StreamObjectSchema,
} from '../../plugins/copilot/providers';
import { TranscriptionResponseSchema } from '../../plugins/copilot/transcript/types';
} from '../plugins/copilot/providers';
import { TranscriptionResponseSchema } from '../plugins/copilot/transcript/types';
import {
CopilotChatTextExecutor,
CopilotWorkflowService,
GraphExecutorState,
} from '../../plugins/copilot/workflow';
} from '../plugins/copilot/workflow';
import {
CopilotChatImageExecutor,
CopilotCheckHtmlExecutor,
CopilotCheckJsonExecutor,
} from '../../plugins/copilot/workflow/executor';
import { createTestingModule, TestingModule } from '../utils';
import { TestAssets } from '../utils/copilot';
} from '../plugins/copilot/workflow/executor';
import { createTestingModule, TestingModule } from './utils';
import { TestAssets } from './utils/copilot';
type Tester = {
auth: AuthService;
@@ -118,6 +118,7 @@ test.serial.before(async t => {
enabled: true,
scenarios: {
image: 'flux-1/schnell',
rerank: 'gpt-5-mini',
complex_text_generation: 'gpt-5-mini',
coding: 'gpt-5-mini',
quick_decision_making: 'gpt-5-mini',
@@ -930,8 +931,8 @@ test(
t.log('Rerank scores:', scores);
t.is(
scores.filter(s => s > 0.5).length,
5,
'should have 5 related chunks'
4,
'should have 4 related chunks'
);
});
}
@@ -6,26 +6,25 @@ import type { TestFn } from 'ava';
import ava from 'ava';
import Sinon from 'sinon';
import { AppModule } from '../../app.module';
import { JobQueue } from '../../base';
import { ConfigModule } from '../../base/config';
import { AuthService } from '../../core/auth';
import { DocReader } from '../../core/doc';
import { CopilotContextService } from '../../plugins/copilot/context';
import { AppModule } from '../app.module';
import { JobQueue } from '../base';
import { ConfigModule } from '../base/config';
import { AuthService } from '../core/auth';
import { DocReader } from '../core/doc';
import { CopilotContextService } from '../plugins/copilot/context';
import {
CopilotEmbeddingJob,
MockEmbeddingClient,
} from '../../plugins/copilot/embedding';
import { ChatMessageCache } from '../../plugins/copilot/message';
import { prompts, PromptService } from '../../plugins/copilot/prompt';
} from '../plugins/copilot/embedding';
import { prompts, PromptService } from '../plugins/copilot/prompt';
import {
CopilotProviderFactory,
CopilotProviderType,
GeminiGenerativeProvider,
OpenAIProvider,
} from '../../plugins/copilot/providers';
import { CopilotStorage } from '../../plugins/copilot/storage';
import { MockCopilotProvider } from '../mocks';
} from '../plugins/copilot/providers';
import { CopilotStorage } from '../plugins/copilot/storage';
import { MockCopilotProvider } from './mocks';
import {
acceptInviteById,
createTestingApp,
@@ -34,7 +33,7 @@ import {
smallestPng,
TestingApp,
TestUser,
} from '../utils';
} from './utils';
import {
addContextDoc,
addContextFile,
@@ -68,7 +67,7 @@ import {
textToEventStream,
unsplashSearch,
updateCopilotSession,
} from '../utils/copilot';
} from './utils/copilot';
const test = ava as TestFn<{
auth: AuthService;
@@ -417,7 +416,6 @@ test('should be able to use test provider', async t => {
test('should create message correctly', async t => {
const { app } = t.context;
const messageCache = app.get(ChatMessageCache);
{
const { id } = await createWorkspace(app);
@@ -465,19 +463,6 @@ test('should create message correctly', async t => {
new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })
);
t.truthy(messageId, 'should be able to create message with blob');
const message = await messageCache.get(messageId);
const attachment = message?.attachments?.[0] as
| { attachment: string; mimeType: string }
| undefined;
const payload = Buffer.from(
attachment?.attachment.split(',').at(1) || '',
'base64'
);
t.is(attachment?.mimeType, 'image/webp');
t.is(payload.subarray(0, 4).toString('ascii'), 'RIFF');
t.is(payload.subarray(8, 12).toString('ascii'), 'WEBP');
}
// with attachments
@@ -528,11 +513,7 @@ test('should be able to chat with api', async t => {
);
const messageId = await createCopilotMessage(app, sessionId);
const ret = await chatWithText(app, sessionId, messageId);
t.is(
ret,
'generate text to text stream',
'should be able to chat with text'
);
t.is(ret, 'generate text to text', 'should be able to chat with text');
const ret2 = await chatWithTextStream(app, sessionId, messageId);
t.is(
@@ -676,7 +657,7 @@ test('should be able to retry with api', async t => {
const histories = await getHistories(app, { workspaceId: id, docId });
t.deepEqual(
histories.map(h => h.messages.map(m => m.content)),
[['generate text to text stream', 'generate text to text stream']],
[['generate text to text', 'generate text to text']],
'should be able to list history'
);
}
@@ -813,7 +794,7 @@ test('should be able to list history', async t => {
const histories = await getHistories(app, { workspaceId, docId });
t.deepEqual(
histories.map(h => h.messages.map(m => m.content)),
[['hello', 'generate text to text stream']],
[['hello', 'generate text to text']],
'should be able to list history'
);
}
@@ -826,7 +807,7 @@ test('should be able to list history', async t => {
});
t.deepEqual(
histories.map(h => h.messages.map(m => m.content)),
[['generate text to text stream', 'hello']],
[['generate text to text', 'hello']],
'should be able to list history'
);
}
@@ -877,7 +858,7 @@ test('should reject request that user have not permission', async t => {
const histories = await getHistories(app, { workspaceId, docId });
t.deepEqual(
histories.map(h => h.messages.map(m => m.content)),
[['generate text to text stream']],
[['generate text to text']],
'should able to list history'
);
@@ -8,38 +8,38 @@ import ava from 'ava';
import { nanoid } from 'nanoid';
import Sinon from 'sinon';
import { EventBus, JobQueue } from '../../base';
import { ConfigModule } from '../../base/config';
import { AuthService } from '../../core/auth';
import { QuotaModule } from '../../core/quota';
import { StorageModule, WorkspaceBlobStorage } from '../../core/storage';
import { EventBus, JobQueue } from '../base';
import { ConfigModule } from '../base/config';
import { AuthService } from '../core/auth';
import { QuotaModule } from '../core/quota';
import { StorageModule, WorkspaceBlobStorage } from '../core/storage';
import {
ContextCategories,
CopilotSessionModel,
WorkspaceModel,
} from '../../models';
import { CopilotModule } from '../../plugins/copilot';
import { CopilotContextService } from '../../plugins/copilot/context';
import { CopilotCronJobs } from '../../plugins/copilot/cron';
} from '../models';
import { CopilotModule } from '../plugins/copilot';
import { CopilotContextService } from '../plugins/copilot/context';
import { CopilotCronJobs } from '../plugins/copilot/cron';
import {
CopilotEmbeddingJob,
MockEmbeddingClient,
} from '../../plugins/copilot/embedding';
import { prompts, PromptService } from '../../plugins/copilot/prompt';
} from '../plugins/copilot/embedding';
import { prompts, PromptService } from '../plugins/copilot/prompt';
import {
CopilotProviderFactory,
CopilotProviderType,
ModelInputType,
ModelOutputType,
OpenAIProvider,
} from '../../plugins/copilot/providers';
} from '../plugins/copilot/providers';
import {
CitationParser,
TextStreamParser,
} from '../../plugins/copilot/providers/utils';
import { ChatSessionService } from '../../plugins/copilot/session';
import { CopilotStorage } from '../../plugins/copilot/storage';
import { CopilotTranscriptionService } from '../../plugins/copilot/transcript';
} from '../plugins/copilot/providers/utils';
import { ChatSessionService } from '../plugins/copilot/session';
import { CopilotStorage } from '../plugins/copilot/storage';
import { CopilotTranscriptionService } from '../plugins/copilot/transcript';
import {
CopilotChatTextExecutor,
CopilotWorkflowService,
@@ -48,7 +48,7 @@ import {
WorkflowGraphExecutor,
type WorkflowNodeData,
WorkflowNodeType,
} from '../../plugins/copilot/workflow';
} from '../plugins/copilot/workflow';
import {
CopilotChatImageExecutor,
CopilotCheckHtmlExecutor,
@@ -56,16 +56,16 @@ import {
getWorkflowExecutor,
NodeExecuteState,
NodeExecutorType,
} from '../../plugins/copilot/workflow/executor';
import { AutoRegisteredWorkflowExecutor } from '../../plugins/copilot/workflow/executor/utils';
import { WorkflowGraphList } from '../../plugins/copilot/workflow/graph';
import { CopilotWorkspaceService } from '../../plugins/copilot/workspace';
import { PaymentModule } from '../../plugins/payment';
import { SubscriptionService } from '../../plugins/payment/service';
import { SubscriptionStatus } from '../../plugins/payment/types';
import { MockCopilotProvider } from '../mocks';
import { createTestingModule, TestingModule } from '../utils';
import { WorkflowTestCases } from '../utils/copilot';
} from '../plugins/copilot/workflow/executor';
import { AutoRegisteredWorkflowExecutor } from '../plugins/copilot/workflow/executor/utils';
import { WorkflowGraphList } from '../plugins/copilot/workflow/graph';
import { CopilotWorkspaceService } from '../plugins/copilot/workspace';
import { PaymentModule } from '../plugins/payment';
import { SubscriptionService } from '../plugins/payment/service';
import { SubscriptionStatus } from '../plugins/payment/types';
import { MockCopilotProvider } from './mocks';
import { createTestingModule, TestingModule } from './utils';
import { WorkflowTestCases } from './utils/copilot';
type Context = {
auth: AuthService;
@@ -364,21 +364,6 @@ test('should be able to manage chat session', async t => {
});
t.is(newSessionId, sessionId, 'should get same session id');
}
// should create a fresh session when reuseLatestChat is explicitly disabled
{
const newSessionId = await session.create({
userId,
promptName,
...commonParams,
reuseLatestChat: false,
});
t.not(
newSessionId,
sessionId,
'should create new session id when reuseLatestChat is false'
);
}
});
test('should be able to update chat session prompt', async t => {
@@ -896,26 +881,6 @@ test('should be able to get provider', async t => {
}
});
test('should resolve provider by prefixed model id', async t => {
const { factory } = t.context;
const provider = await factory.getProviderByModel('openai-default/test');
t.truthy(provider, 'should resolve prefixed model id');
t.is(provider?.type, CopilotProviderType.OpenAI);
const result = await provider?.text({ modelId: 'openai-default/test' }, [
{ role: 'user', content: 'hello' },
]);
t.is(result, 'generate text to text');
});
test('should fallback to null when prefixed provider id does not exist', async t => {
const { factory } = t.context;
const provider = await factory.getProviderByModel('unknown/test');
t.is(provider, null);
});
// ==================== workflow ====================
// this test used to preview the final result of the workflow
@@ -2098,23 +2063,25 @@ test('should handle copilot cron jobs correctly', async t => {
});
test('should resolve model correctly based on subscription status and prompt config', async t => {
const { prompt, session, subscription } = t.context;
const { db, session, subscription } = t.context;
// 1) Seed a prompt that has optionalModels and proModels in config
const promptName = 'resolve-model-test';
await prompt.set(
promptName,
'gemini-2.5-flash',
[{ role: 'system', content: 'test' }],
{ proModels: ['gemini-2.5-pro', 'claude-sonnet-4-5@20250929'] },
{
await db.aiPrompt.create({
data: {
name: promptName,
model: 'gemini-2.5-flash',
messages: {
create: [{ idx: 0, role: 'system', content: 'test' }],
},
config: { proModels: ['gemini-2.5-pro', 'claude-sonnet-4-5@20250929'] },
optionalModels: [
'gemini-2.5-flash',
'gemini-2.5-pro',
'claude-sonnet-4-5@20250929',
],
}
);
},
});
// 2) Create a chat session with this prompt
const sessionId = await session.create({
@@ -2139,16 +2106,6 @@ test('should resolve model correctly based on subscription status and prompt con
const model1 = await s.resolveModel(false, 'gemini-2.5-pro');
t.snapshot(model1, 'should honor requested pro model');
const model1WithPrefix = await s.resolveModel(
false,
'openai-default/gemini-2.5-pro'
);
t.is(
model1WithPrefix,
'openai-default/gemini-2.5-pro',
'should honor requested prefixed pro model'
);
const model2 = await s.resolveModel(false, 'not-in-optional');
t.snapshot(model2, 'should fallback to default model');
}
@@ -2162,16 +2119,6 @@ test('should resolve model correctly based on subscription status and prompt con
'should fallback to default model when requesting pro model during trialing'
);
const model3WithPrefix = await s.resolveModel(
true,
'openai-default/gemini-2.5-pro'
);
t.is(
model3WithPrefix,
'gemini-2.5-flash',
'should fallback to default model when requesting prefixed pro model during trialing'
);
const model4 = await s.resolveModel(true, 'gemini-2.5-flash');
t.snapshot(model4, 'should honor requested non-pro model during trialing');
@@ -2194,16 +2141,6 @@ test('should resolve model correctly based on subscription status and prompt con
const model7 = await s.resolveModel(true, 'claude-sonnet-4-5@20250929');
t.snapshot(model7, 'should honor requested pro model during active');
const model7WithPrefix = await s.resolveModel(
true,
'openai-default/claude-sonnet-4-5@20250929'
);
t.is(
model7WithPrefix,
'openai-default/claude-sonnet-4-5@20250929',
'should honor requested prefixed pro model during active'
);
const model8 = await s.resolveModel(true, 'not-in-optional');
t.snapshot(
model8,
@@ -1,210 +0,0 @@
import test from 'ava';
import { z } from 'zod';
import type { NativeLlmRequest, NativeLlmStreamEvent } from '../../native';
import {
buildNativeRequest,
NativeProviderAdapter,
} from '../../plugins/copilot/providers/native';
const mockDispatch = () =>
(async function* (): AsyncIterableIterator<NativeLlmStreamEvent> {
yield { type: 'text_delta', text: 'Use [^1] now' };
yield { type: 'citation', index: 1, url: 'https://affine.pro' };
yield { type: 'done', finish_reason: 'stop' };
})();
test('NativeProviderAdapter streamText should append citation footnotes', async t => {
const adapter = new NativeProviderAdapter(mockDispatch, {}, 3);
const chunks: string[] = [];
for await (const chunk of adapter.streamText({
model: 'gpt-5-mini',
stream: true,
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})) {
chunks.push(chunk);
}
const text = chunks.join('');
t.true(text.includes('Use [^1] now'));
t.true(
text.includes('[^1]: {"type":"url","url":"https%3A%2F%2Faffine.pro"}')
);
});
test('NativeProviderAdapter streamObject should append citation footnotes', async t => {
const adapter = new NativeProviderAdapter(mockDispatch, {}, 3);
const chunks = [];
for await (const chunk of adapter.streamObject({
model: 'gpt-5-mini',
stream: true,
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})) {
chunks.push(chunk);
}
t.deepEqual(
chunks.map(chunk => chunk.type),
['text-delta', 'text-delta']
);
const text = chunks
.filter(chunk => chunk.type === 'text-delta')
.map(chunk => chunk.textDelta)
.join('');
t.true(text.includes('Use [^1] now'));
t.true(
text.includes('[^1]: {"type":"url","url":"https%3A%2F%2Faffine.pro"}')
);
});
test('NativeProviderAdapter streamObject should append fallback attachment footnotes', async t => {
const dispatch = () =>
(async function* (): AsyncIterableIterator<NativeLlmStreamEvent> {
yield {
type: 'tool_result',
call_id: 'call_1',
name: 'blob_read',
arguments: { blob_id: 'blob_1' },
output: {
blobId: 'blob_1',
fileName: 'a.txt',
fileType: 'text/plain',
content: 'A',
},
};
yield {
type: 'tool_result',
call_id: 'call_2',
name: 'blob_read',
arguments: { blob_id: 'blob_2' },
output: {
blobId: 'blob_2',
fileName: 'b.txt',
fileType: 'text/plain',
content: 'B',
},
};
yield { type: 'text_delta', text: 'Answer from files.' };
yield { type: 'done', finish_reason: 'stop' };
})();
const adapter = new NativeProviderAdapter(dispatch, {}, 3);
const chunks = [];
for await (const chunk of adapter.streamObject({
model: 'gpt-5-mini',
stream: true,
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})) {
chunks.push(chunk);
}
const text = chunks
.filter(chunk => chunk.type === 'text-delta')
.map(chunk => chunk.textDelta)
.join('');
t.true(text.includes('Answer from files.'));
t.true(text.includes('[^1][^2]'));
t.true(
text.includes(
'[^1]: {"type":"attachment","blobId":"blob_1","fileName":"a.txt","fileType":"text/plain"}'
)
);
t.true(
text.includes(
'[^2]: {"type":"attachment","blobId":"blob_2","fileName":"b.txt","fileType":"text/plain"}'
)
);
});
test('NativeProviderAdapter streamObject should map tool and text events', async t => {
let round = 0;
const dispatch = (_request: NativeLlmRequest) =>
(async function* (): AsyncIterableIterator<NativeLlmStreamEvent> {
round += 1;
if (round === 1) {
yield {
type: 'tool_call',
call_id: 'call_1',
name: 'doc_read',
arguments: { doc_id: 'a1' },
};
yield { type: 'done', finish_reason: 'tool_calls' };
return;
}
yield { type: 'text_delta', text: 'ok' };
yield { type: 'done', finish_reason: 'stop' };
})();
const adapter = new NativeProviderAdapter(
dispatch,
{
doc_read: {
inputSchema: z.object({ doc_id: z.string() }),
execute: async () => ({ markdown: '# a1' }),
},
},
4
);
const events = [];
for await (const event of adapter.streamObject({
model: 'gpt-5-mini',
stream: true,
messages: [{ role: 'user', content: [{ type: 'text', text: 'read' }] }],
})) {
events.push(event);
}
t.deepEqual(
events.map(event => event.type),
['tool-call', 'tool-result', 'text-delta']
);
t.deepEqual(events[0], {
type: 'tool-call',
toolCallId: 'call_1',
toolName: 'doc_read',
args: { doc_id: 'a1' },
});
});
test('buildNativeRequest should include rust middleware from profile', async t => {
const { request } = await buildNativeRequest({
model: 'gpt-5-mini',
messages: [{ role: 'user', content: 'hello' }],
tools: {},
middleware: {
rust: {
request: ['normalize_messages', 'clamp_max_tokens'],
stream: ['stream_event_normalize', 'citation_indexing'],
},
node: {
text: ['callout'],
},
},
});
t.deepEqual(request.middleware, {
request: ['normalize_messages', 'clamp_max_tokens'],
stream: ['stream_event_normalize', 'citation_indexing'],
});
});
test('NativeProviderAdapter streamText should skip citation footnotes when disabled', async t => {
const adapter = new NativeProviderAdapter(mockDispatch, {}, 3, {
nodeTextMiddleware: ['callout'],
});
const chunks: string[] = [];
for await (const chunk of adapter.streamText({
model: 'gpt-5-mini',
stream: true,
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})) {
chunks.push(chunk);
}
const text = chunks.join('');
t.true(text.includes('Use [^1] now'));
t.false(
text.includes('[^1]: {"type":"url","url":"https%3A%2F%2Faffine.pro"}')
);
});
@@ -1,56 +0,0 @@
import test from 'ava';
import { resolveProviderMiddleware } from '../../plugins/copilot/providers/provider-middleware';
import { buildProviderRegistry } from '../../plugins/copilot/providers/provider-registry';
import { CopilotProviderType } from '../../plugins/copilot/providers/types';
test('resolveProviderMiddleware should include anthropic defaults', t => {
const middleware = resolveProviderMiddleware(CopilotProviderType.Anthropic);
t.deepEqual(middleware.rust?.request, [
'normalize_messages',
'tool_schema_rewrite',
]);
t.deepEqual(middleware.rust?.stream, [
'stream_event_normalize',
'citation_indexing',
]);
t.deepEqual(middleware.node?.text, ['citation_footnote', 'callout']);
});
test('resolveProviderMiddleware should merge defaults and overrides', t => {
const middleware = resolveProviderMiddleware(CopilotProviderType.OpenAI, {
rust: { request: ['clamp_max_tokens'] },
node: { text: ['thinking_format'] },
});
t.deepEqual(middleware.rust?.request, [
'normalize_messages',
'clamp_max_tokens',
]);
t.deepEqual(middleware.node?.text, [
'citation_footnote',
'callout',
'thinking_format',
]);
});
test('buildProviderRegistry should normalize profile middleware defaults', t => {
const registry = buildProviderRegistry({
profiles: [
{
id: 'openai-main',
type: CopilotProviderType.OpenAI,
config: { apiKey: '1' },
},
],
});
const profile = registry.profiles.get('openai-main');
t.truthy(profile);
t.deepEqual(profile?.middleware.rust?.stream, [
'stream_event_normalize',
'citation_indexing',
]);
t.deepEqual(profile?.middleware.node?.text, ['citation_footnote', 'callout']);
});
@@ -1,139 +0,0 @@
import test from 'ava';
import { ProviderMiddlewareConfig } from '../../plugins/copilot/config';
import { normalizeOpenAIOptionsForModel } from '../../plugins/copilot/providers/openai';
import { CopilotProvider } from '../../plugins/copilot/providers/provider';
import { normalizeRerankModel } from '../../plugins/copilot/providers/rerank';
import {
CopilotProviderType,
ModelInputType,
ModelOutputType,
} from '../../plugins/copilot/providers/types';
class TestOpenAIProvider extends CopilotProvider<{ apiKey: string }> {
readonly type = CopilotProviderType.OpenAI;
readonly models = [
{
id: 'gpt-5-mini',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Text],
defaultForOutputType: true,
},
],
},
];
configured() {
return true;
}
async text(_cond: any, _messages: any[], _options?: any) {
return '';
}
async *streamText(_cond: any, _messages: any[], _options?: any) {
yield '';
}
exposeMetricLabels() {
return this.metricLabels('gpt-5-mini');
}
exposeMiddleware() {
return this.getActiveProviderMiddleware();
}
}
function createProvider(profileMiddleware?: ProviderMiddlewareConfig) {
const provider = new TestOpenAIProvider();
(provider as any).AFFiNEConfig = {
copilot: {
providers: {
profiles: [
{
id: 'openai-main',
type: CopilotProviderType.OpenAI,
config: { apiKey: 'test' },
middleware: profileMiddleware,
},
],
defaults: {},
openai: { apiKey: 'legacy' },
},
},
};
return provider;
}
test('metricLabels should include active provider id', t => {
const provider = createProvider();
const labels = provider.runWithProfile('openai-main', () =>
provider.exposeMetricLabels()
);
t.is(labels.providerId, 'openai-main');
});
test('getActiveProviderMiddleware should merge defaults with profile override', t => {
const provider = createProvider({
rust: { request: ['clamp_max_tokens'] },
node: { text: ['thinking_format'] },
});
const middleware = provider.runWithProfile('openai-main', () =>
provider.exposeMiddleware()
);
t.deepEqual(middleware.rust?.request, [
'normalize_messages',
'clamp_max_tokens',
]);
t.deepEqual(middleware.rust?.stream, [
'stream_event_normalize',
'citation_indexing',
]);
t.deepEqual(middleware.node?.text, [
'citation_footnote',
'callout',
'thinking_format',
]);
});
test('normalizeOpenAIOptionsForModel should drop sampling knobs for gpt-5.2', t => {
t.deepEqual(
normalizeOpenAIOptionsForModel(
{
temperature: 0.7,
topP: 0.8,
presencePenalty: 0.2,
frequencyPenalty: 0.1,
maxTokens: 128,
},
'gpt-5.4'
),
{ maxTokens: 128 }
);
});
test('normalizeOpenAIOptionsForModel should keep options for gpt-4.1', t => {
t.deepEqual(
normalizeOpenAIOptionsForModel(
{ temperature: 0.7, topP: 0.8, maxTokens: 128 },
'gpt-4.1'
),
{ temperature: 0.7, topP: 0.8, maxTokens: 128 }
);
});
test('normalizeOpenAIRerankModel should keep supported rerank models', t => {
t.is(normalizeRerankModel('gpt-4.1'), 'gpt-4.1');
t.is(normalizeRerankModel('gpt-4.1-mini'), 'gpt-4.1-mini');
t.is(normalizeRerankModel('gpt-5.2'), 'gpt-5.2');
});
test('normalizeOpenAIRerankModel should fall back for unsupported models', t => {
t.is(normalizeRerankModel('gpt-5-mini'), 'gpt-5.2');
t.is(normalizeRerankModel('gemini-2.5-flash'), 'gpt-5.2');
t.is(normalizeRerankModel(undefined), 'gpt-5.2');
});
@@ -1,168 +0,0 @@
import test from 'ava';
import {
buildProviderRegistry,
resolveModel,
stripProviderPrefix,
} from '../../plugins/copilot/providers/provider-registry';
import {
CopilotProviderType,
ModelOutputType,
} from '../../plugins/copilot/providers/types';
test('buildProviderRegistry should keep explicit profile over legacy compatibility profile', t => {
const registry = buildProviderRegistry({
profiles: [
{
id: 'openai-default',
type: CopilotProviderType.OpenAI,
priority: 100,
config: { apiKey: 'new' },
},
],
openai: { apiKey: 'legacy' },
});
const profile = registry.profiles.get('openai-default');
t.truthy(profile);
t.deepEqual(profile?.config, { apiKey: 'new' });
});
test('buildProviderRegistry should reject duplicated profile ids', t => {
const error = t.throws(() =>
buildProviderRegistry({
profiles: [
{
id: 'openai-main',
type: CopilotProviderType.OpenAI,
config: { apiKey: '1' },
},
{
id: 'openai-main',
type: CopilotProviderType.OpenAI,
config: { apiKey: '2' },
},
],
})
) as Error;
t.truthy(error);
t.regex(error.message, /Duplicated copilot provider profile id/);
});
test('buildProviderRegistry should reject defaults that reference unknown providers', t => {
const error = t.throws(() =>
buildProviderRegistry({
profiles: [
{
id: 'openai-main',
type: CopilotProviderType.OpenAI,
config: { apiKey: '1' },
},
],
defaults: {
fallback: 'unknown-provider',
},
})
) as Error;
t.truthy(error);
t.regex(error.message, /defaults references unknown providerId/);
});
test('resolveModel should support explicit provider prefix and keep slash models untouched', t => {
const registry = buildProviderRegistry({
profiles: [
{
id: 'openai-main',
type: CopilotProviderType.OpenAI,
config: { apiKey: '1' },
},
{
id: 'fal-main',
type: CopilotProviderType.FAL,
config: { apiKey: '2' },
},
],
});
const prefixed = resolveModel({
registry,
modelId: 'openai-main/gpt-5-mini',
});
t.deepEqual(prefixed, {
rawModelId: 'openai-main/gpt-5-mini',
modelId: 'gpt-5-mini',
explicitProviderId: 'openai-main',
candidateProviderIds: ['openai-main'],
});
const slashModel = resolveModel({
registry,
modelId: 'lora/image-to-image',
});
t.is(slashModel.modelId, 'lora/image-to-image');
t.false(slashModel.candidateProviderIds.includes('lora'));
});
test('resolveModel should follow defaults -> fallback -> order and apply filters', t => {
const registry = buildProviderRegistry({
profiles: [
{
id: 'openai-main',
type: CopilotProviderType.OpenAI,
priority: 10,
config: { apiKey: '1' },
},
{
id: 'anthropic-main',
type: CopilotProviderType.Anthropic,
priority: 5,
config: { apiKey: '2' },
},
{
id: 'fal-main',
type: CopilotProviderType.FAL,
priority: 1,
config: { apiKey: '3' },
},
],
defaults: {
[ModelOutputType.Text]: 'anthropic-main',
fallback: 'openai-main',
},
});
const routed = resolveModel({
registry,
outputType: ModelOutputType.Text,
preferredProviderIds: ['openai-main', 'fal-main'],
});
t.deepEqual(routed.candidateProviderIds, ['openai-main', 'fal-main']);
});
test('stripProviderPrefix should only strip matched provider prefix', t => {
const registry = buildProviderRegistry({
profiles: [
{
id: 'openai-main',
type: CopilotProviderType.OpenAI,
config: { apiKey: '1' },
},
],
});
t.is(
stripProviderPrefix(registry, 'openai-main', 'openai-main/gpt-5-mini'),
'gpt-5-mini'
);
t.is(
stripProviderPrefix(registry, 'openai-main', 'another-main/gpt-5-mini'),
'another-main/gpt-5-mini'
);
t.is(
stripProviderPrefix(registry, 'openai-main', 'gpt-5-mini'),
'gpt-5-mini'
);
});
@@ -1,134 +0,0 @@
import test from 'ava';
import { z } from 'zod';
import { NativeLlmRequest, NativeLlmStreamEvent } from '../../native';
import {
ToolCallAccumulator,
ToolCallLoop,
ToolSchemaExtractor,
} from '../../plugins/copilot/providers/loop';
test('ToolCallAccumulator should merge deltas and complete tool call', t => {
const accumulator = new ToolCallAccumulator();
accumulator.feedDelta({
type: 'tool_call_delta',
call_id: 'call_1',
name: 'doc_read',
arguments_delta: '{"doc_id":"',
});
accumulator.feedDelta({
type: 'tool_call_delta',
call_id: 'call_1',
arguments_delta: 'a1"}',
});
const completed = accumulator.complete({
type: 'tool_call',
call_id: 'call_1',
name: 'doc_read',
arguments: { doc_id: 'a1' },
});
t.deepEqual(completed, {
id: 'call_1',
name: 'doc_read',
args: { doc_id: 'a1' },
thought: undefined,
});
});
test('ToolSchemaExtractor should convert zod schema to json schema', t => {
const toolSet = {
doc_read: {
description: 'Read doc',
inputSchema: z.object({
doc_id: z.string(),
limit: z.number().optional(),
}),
execute: async () => ({}),
},
};
const extracted = ToolSchemaExtractor.extract(toolSet);
t.deepEqual(extracted, [
{
name: 'doc_read',
description: 'Read doc',
parameters: {
type: 'object',
properties: {
doc_id: { type: 'string' },
limit: { type: 'number' },
},
additionalProperties: false,
required: ['doc_id'],
},
},
]);
});
test('ToolCallLoop should execute tool call and continue to next round', async t => {
const dispatchRequests: NativeLlmRequest[] = [];
const dispatch = (request: NativeLlmRequest) => {
dispatchRequests.push(request);
const round = dispatchRequests.length;
return (async function* (): AsyncIterableIterator<NativeLlmStreamEvent> {
if (round === 1) {
yield {
type: 'tool_call_delta',
call_id: 'call_1',
name: 'doc_read',
arguments_delta: '{"doc_id":"a1"}',
};
yield {
type: 'tool_call',
call_id: 'call_1',
name: 'doc_read',
arguments: { doc_id: 'a1' },
};
yield { type: 'done', finish_reason: 'tool_calls' };
return;
}
yield { type: 'text_delta', text: 'done' };
yield { type: 'done', finish_reason: 'stop' };
})();
};
let executedArgs: Record<string, unknown> | null = null;
const loop = new ToolCallLoop(
dispatch,
{
doc_read: {
inputSchema: z.object({ doc_id: z.string() }),
execute: async args => {
executedArgs = args;
return { markdown: '# doc' };
},
},
},
4
);
const events: NativeLlmStreamEvent[] = [];
for await (const event of loop.run({
model: 'gpt-5-mini',
stream: true,
messages: [{ role: 'user', content: [{ type: 'text', text: 'read doc' }] }],
})) {
events.push(event);
}
t.deepEqual(executedArgs, { doc_id: 'a1' });
t.true(
dispatchRequests[1]?.messages.some(message => message.role === 'tool')
);
t.deepEqual(
events.map(event => event.type),
['tool_call', 'tool_result', 'text_delta', 'done']
);
});
@@ -1,116 +0,0 @@
import test from 'ava';
import { z } from 'zod';
import {
chatToGPTMessage,
CitationFootnoteFormatter,
CitationParser,
StreamPatternParser,
} from '../../plugins/copilot/providers/utils';
test('CitationFootnoteFormatter should format sorted footnotes from citation events', t => {
const formatter = new CitationFootnoteFormatter();
formatter.consume({
type: 'citation',
index: 2,
url: 'https://example.com/b',
});
formatter.consume({
type: 'citation',
index: 1,
url: 'https://example.com/a',
});
t.is(
formatter.end(),
[
'[^1]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fa"}',
'[^2]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fb"}',
].join('\n')
);
});
test('CitationFootnoteFormatter should overwrite duplicated index with latest url', t => {
const formatter = new CitationFootnoteFormatter();
formatter.consume({
type: 'citation',
index: 1,
url: 'https://example.com/old',
});
formatter.consume({
type: 'citation',
index: 1,
url: 'https://example.com/new',
});
t.is(
formatter.end(),
'[^1]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fnew"}'
);
});
test('StreamPatternParser should keep state across chunks', t => {
const parser = new StreamPatternParser(pattern => {
if (pattern.kind === 'wrappedLink') {
return `[^${pattern.url}]`;
}
if (pattern.kind === 'index') {
return `[#${pattern.value}]`;
}
return `[${pattern.text}](${pattern.url})`;
});
const first = parser.write('ref ([AFFiNE](https://affine.pro');
const second = parser.write(')) and [2]');
t.is(first, 'ref ');
t.is(second, '[^https://affine.pro] and [#2]');
t.is(parser.end(), '');
});
test('CitationParser should convert wrapped links to numbered footnotes', t => {
const parser = new CitationParser();
const output = parser.parse('Use ([AFFiNE](https://affine.pro)) now');
t.is(output, 'Use [^1] now');
t.regex(
parser.end(),
/\[\^1\]: \{"type":"url","url":"https%3A%2F%2Faffine.pro"\}/
);
});
test('chatToGPTMessage should not mutate input and should keep system schema', async t => {
const schema = z.object({
query: z.string(),
});
const messages = [
{
role: 'system' as const,
content: 'You are helper',
params: { schema },
},
{
role: 'user' as const,
content: '',
attachments: ['https://example.com/a.png'],
},
];
const firstRef = messages[0];
const secondRef = messages[1];
const [system, normalized, parsedSchema] = await chatToGPTMessage(
messages,
false
);
t.is(system, 'You are helper');
t.is(parsedSchema, schema);
t.is(messages.length, 2);
t.is(messages[0], firstRef);
t.is(messages[1], secondRef);
t.deepEqual(normalized[0], {
role: 'user',
content: [{ type: 'text', text: '[no content]' }],
});
});
@@ -9,16 +9,6 @@ Generated by [AVA](https://avajs.dev).
> Snapshot 1
{
knownUnsupportedBlocks: [
'RX4CG2zsBk:affine:note',
'S1mkc8zUoU:affine:note',
'yGlBdshAqN:affine:note',
'6lDiuDqZGL:affine:note',
'cauvaHOQmh:affine:note',
'2jwCeO8Yot:affine:note',
'c9MF_JiRgx:affine:note',
'6x7ALjUDjj:affine:surface',
],
markdown: `AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro.␊
@@ -80,9 +70,35 @@ Generated by [AVA](https://avajs.dev).
[](Bookmark,https://affine.pro/)␊
[](Bookmark,https://www.youtube.com/@affinepro)␊
<img␊
src="blob://BFZk3c2ERp-sliRvA7MQ_p3NdkdCLt2Ze0DQ9i21dpA="␊
alt=""␊
width="1302"␊
height="728"␊
/>␊
<img␊
src="blob://HWvCItS78DzPGbwcuaGcfkpVDUvL98IvH5SIK8-AcL8="␊
alt=""␊
width="1463"␊
height="374"␊
/>␊
<img␊
src="blob://ZRKpsBoC88qEMmeiXKXqywfA1rLvWoLa5rpEh9x9Oj0="␊
alt=""␊
width="862"␊
height="1388"␊
/>␊
`,
title: 'Write, Draw, Plan all at Once.',
unknownBlocks: [],
}
## should get doc markdown return null when doc not exists

Some files were not shown because too many files have changed in this diff Show More