mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-03-23 07:40:46 +08:00
Compare commits
24 Commits
v2026.3.2-
...
v2026.3.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f571ddc30 | ||
|
|
13ad1beb10 | ||
|
|
9844ca4d54 | ||
|
|
d7d67841b8 | ||
|
|
29a27b561b | ||
|
|
02744cec00 | ||
|
|
6d710f3bdc | ||
|
|
0b47f92134 | ||
|
|
9c55edeb62 | ||
|
|
9742e9735e | ||
|
|
86d65b2f64 | ||
|
|
f34e25e122 | ||
|
|
b5d5b71f95 | ||
|
|
09fa1a8e4e | ||
|
|
c249011238 | ||
|
|
7f5f7e79df | ||
|
|
fff04395bc | ||
|
|
bbc01533d7 | ||
|
|
e31cca3354 | ||
|
|
11bc333714 | ||
|
|
99b07c2ee1 | ||
|
|
fc9b99cd17 | ||
|
|
2137f68871 | ||
|
|
75efa854bf |
@@ -19,3 +19,8 @@ rustflags = [
|
||||
# pthread_key_create() destructors and segfault after a DSO unloading
|
||||
[target.'cfg(all(target_env = "gnu", not(target_os = "windows")))']
|
||||
rustflags = ["-C", "link-args=-Wl,-z,nodelete"]
|
||||
|
||||
# Temporary local llm_adapter override.
|
||||
# Uncomment when verifying AFFiNE against the sibling llm_adapter workspace.
|
||||
# [patch.crates-io]
|
||||
# llm_adapter = { path = "../llm_adapter" }
|
||||
|
||||
@@ -197,8 +197,8 @@
|
||||
"properties": {
|
||||
"SMTP.name": {
|
||||
"type": "string",
|
||||
"description": "Name of the email server (e.g. your domain name)\n@default \"AFFiNE Server\"\n@environment `MAILER_SERVERNAME`",
|
||||
"default": "AFFiNE Server"
|
||||
"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": ""
|
||||
},
|
||||
"SMTP.host": {
|
||||
"type": "string",
|
||||
@@ -237,8 +237,8 @@
|
||||
},
|
||||
"fallbackSMTP.name": {
|
||||
"type": "string",
|
||||
"description": "Name of the fallback email server (e.g. your domain name)\n@default \"AFFiNE Server\"",
|
||||
"default": "AFFiNE Server"
|
||||
"description": "Hostname used for fallback SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.\n@default \"\"",
|
||||
"default": ""
|
||||
},
|
||||
"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\",\"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\"}}",
|
||||
"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\"}}",
|
||||
"default": {
|
||||
"override_enabled": false,
|
||||
"scenarios": {
|
||||
@@ -979,9 +979,8 @@
|
||||
"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",
|
||||
"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"
|
||||
|
||||
4
.github/helm/affine/charts/front/values.yaml
vendored
4
.github/helm/affine/charts/front/values.yaml
vendored
@@ -31,10 +31,10 @@ podSecurityContext:
|
||||
resources:
|
||||
limits:
|
||||
cpu: '1'
|
||||
memory: 4Gi
|
||||
memory: 6Gi
|
||||
requests:
|
||||
cpu: '1'
|
||||
memory: 2Gi
|
||||
memory: 4Gi
|
||||
|
||||
probe:
|
||||
initialDelaySeconds: 20
|
||||
|
||||
4
.github/workflows/build-test.yml
vendored
4
.github/workflows/build-test.yml
vendored
@@ -226,7 +226,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup Node.js
|
||||
@@ -356,7 +356,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup Node.js
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
|
||||
- name: Signing By Apple Developer ID
|
||||
if: ${{ inputs.platform == 'darwin' && inputs.apple_codesign }}
|
||||
uses: apple-actions/import-codesign-certs@v5
|
||||
uses: apple-actions/import-codesign-certs@v6
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
|
||||
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
|
||||
|
||||
2
.github/workflows/release-mobile.yml
vendored
2
.github/workflows/release-mobile.yml
vendored
@@ -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@v5
|
||||
uses: apple-actions/import-codesign-certs@v6
|
||||
id: import-codesign-certs
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.CERTIFICATES_P12_MOBILE }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -48,6 +48,7 @@ testem.log
|
||||
/typings
|
||||
tsconfig.tsbuildinfo
|
||||
.context
|
||||
/*.md
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
|
||||
656
Cargo.lock
generated
656
Cargo.lock
generated
@@ -178,10 +178,15 @@ name = "affine_server_native"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"affine_common",
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"file-format",
|
||||
"image",
|
||||
"infer",
|
||||
"libwebp-sys",
|
||||
"little_exif",
|
||||
"llm_adapter",
|
||||
"matroska",
|
||||
"mimalloc",
|
||||
"mp4parse",
|
||||
"napi",
|
||||
@@ -235,6 +240,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -461,12 +481,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "auto_enums"
|
||||
version = "0.8.7"
|
||||
@@ -485,28 +499,6 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.37.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -630,6 +622,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitstream-io"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "680575de65ce8b916b82a447458b94a48776707d9c2681a9d8da351c06886a1f"
|
||||
dependencies = [
|
||||
"core2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -672,6 +673,27 @@ version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "473976d7a8620bb1e06dcdd184407c2363fe4fec8e983ee03ed9197222634a31"
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -707,6 +729,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -936,13 +964,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
@@ -1482,12 +1507,6 @@ version = "0.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "ecb"
|
||||
version = "0.1.2"
|
||||
@@ -1608,6 +1627,15 @@ dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "file-format"
|
||||
version = "0.28.0"
|
||||
@@ -1710,12 +1738,6 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
@@ -1880,11 +1902,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2067,95 +2097,12 @@ dependencies = [
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body-util"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.64"
|
||||
@@ -2328,6 +2275,34 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "include-flate"
|
||||
version = "0.3.1"
|
||||
@@ -2406,22 +2381,6 @@ dependencies = [
|
||||
"leaky-cow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
@@ -2675,6 +2634,17 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libwebp-sys"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "375ca3fbd6d89769361c5d505c9da676eb4128ee471b9fd763144d377a2d30e6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"glob",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
@@ -2688,16 +2658,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||
|
||||
[[package]]
|
||||
name = "llm_adapter"
|
||||
version = "0.1.1"
|
||||
name = "little_exif"
|
||||
version = "0.6.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8dd9a548766bccf8b636695e8d514edee672d180e96a16ab932c971783b4e353"
|
||||
checksum = "21eeb58b22d31be8dc5c625004fcd4b9b385cd3c05df575f523bcca382c51122"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"crc",
|
||||
"log",
|
||||
"miniz_oxide",
|
||||
"paste",
|
||||
"quick-xml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llm_adapter"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e98485dda5180cc89b993a001688bed93307be6bd8fedcde445b69bbca4f554d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2765,12 +2749,6 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -2830,6 +2808,16 @@ dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matroska"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fde85cd7fb5cf875c4a46fac0cbd6567d413bea2538cef6788e3a0e52a902b45"
|
||||
dependencies = [
|
||||
"bitstream-io",
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -2899,6 +2887,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mp4parse"
|
||||
version = "0.17.0"
|
||||
@@ -3262,12 +3260,6 @@ version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "5.1.0"
|
||||
@@ -3317,6 +3309,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "path-ext"
|
||||
version = "0.1.2"
|
||||
@@ -3563,6 +3561,19 @@ dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pom"
|
||||
version = "1.1.0"
|
||||
@@ -3704,6 +3715,12 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
@@ -3711,59 +3728,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.17",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3954,45 +3930,6 @@ version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -4133,7 +4070,7 @@ version = "0.23.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4142,62 +4079,21 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -4216,7 +4112,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error",
|
||||
"quick-error 1.2.3",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
@@ -4236,15 +4132,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
@@ -4293,29 +4180,6 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -5041,15 +4905,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
@@ -5241,16 +5096,6 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
@@ -5301,51 +5146,6 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -5548,12 +5348,6 @@ dependencies = [
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "type1-encoding-parser"
|
||||
version = "0.1.0"
|
||||
@@ -5778,6 +5572,35 @@ version = "0.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"ureq-proto",
|
||||
"utf-8",
|
||||
"webpki-roots 1.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -5874,15 +5697,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -5972,25 +5786,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
@@ -6771,3 +6566,18 @@ dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@@ -40,14 +40,24 @@ 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"
|
||||
llm_adapter = "0.1.1"
|
||||
libwebp-sys = "0.14.2"
|
||||
little_exif = "0.6.23"
|
||||
llm_adapter = { version = "0.1.3", default-features = false }
|
||||
log = "0.4"
|
||||
loom = { version = "0.7", features = ["checkpoint"] }
|
||||
lru = "0.16"
|
||||
matroska = "0.30"
|
||||
memory-indexer = "0.3.0"
|
||||
mimalloc = "0.1"
|
||||
mp4parse = "0.17"
|
||||
|
||||
@@ -23,4 +23,6 @@ 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.
|
||||
|
||||
@@ -300,6 +300,6 @@
|
||||
"devDependencies": {
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"msw": "^2.12.4",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 1000,
|
||||
coverage: {
|
||||
provider: 'istanbul', // or 'c8'
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/blocksuite-affine',
|
||||
},
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -8,10 +9,9 @@ export default defineConfig({
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
name: 'chromium',
|
||||
provider: 'playwright',
|
||||
instances: [{ browser: 'chromium' }],
|
||||
provider: playwright(),
|
||||
isolate: false,
|
||||
providerOptions: {},
|
||||
},
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
|
||||
@@ -45,8 +45,10 @@ 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(tokens[vElement.lineIndex]);
|
||||
const lineTokens = structuredClone(line);
|
||||
if (lineTokens.length === 0) return plainContent;
|
||||
|
||||
const startOffset = vElement.startOffset;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -221,6 +221,12 @@ 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;
|
||||
@@ -255,7 +261,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
|
||||
const style = {
|
||||
borderRadius: borderRadius + 'px',
|
||||
transform: `scale(${scale})`,
|
||||
};
|
||||
|
||||
const extra = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
FrameBlockModel,
|
||||
ImageBlockModel,
|
||||
isExternalEmbedModel,
|
||||
MindmapElementModel,
|
||||
NoteBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
@@ -401,7 +402,17 @@ function reorderElements(
|
||||
) {
|
||||
if (!models.length) return;
|
||||
|
||||
for (const model of models) {
|
||||
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) {
|
||||
const index = ctx.gfx.layer.getReorderedIndex(model, type);
|
||||
|
||||
// block should be updated in transaction
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -2,16 +2,24 @@ 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 type { IBound } from '@blocksuite/global/gfx';
|
||||
import { getBoundWithRotation, intersects } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
Bound,
|
||||
getBoundWithRotation,
|
||||
type IBound,
|
||||
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';
|
||||
|
||||
@@ -40,11 +48,82 @@ 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>();
|
||||
@@ -53,6 +132,37 @@ 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;
|
||||
@@ -89,6 +199,7 @@ 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);
|
||||
@@ -132,15 +243,199 @@ 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
|
||||
@@ -159,11 +454,7 @@ export class CanvasRenderer {
|
||||
const created = i < currentCanvases.length;
|
||||
const canvas = created
|
||||
? currentCanvases[i]
|
||||
: document.createElement('canvas');
|
||||
|
||||
if (!created) {
|
||||
onCreated?.(canvas);
|
||||
}
|
||||
: this._createCanvasForLayer(onCreated);
|
||||
|
||||
canvas.dataset.layerId = `[${layer.indexes[0]}--${layer.indexes[1]}]`;
|
||||
canvas.style.zIndex = layer.zIndex.toString();
|
||||
@@ -171,7 +462,6 @@ export class CanvasRenderer {
|
||||
}
|
||||
|
||||
this._stackingCanvas = canvases;
|
||||
updateStackingCanvasSize(canvases);
|
||||
|
||||
if (currentCanvases.length !== canvases.length) {
|
||||
const diff = canvases.length - currentCanvases.length;
|
||||
@@ -189,12 +479,16 @@ 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();
|
||||
this.refresh({ type: 'all' });
|
||||
};
|
||||
|
||||
this._disposables.add(
|
||||
@@ -211,7 +505,7 @@ export class CanvasRenderer {
|
||||
|
||||
this._disposables.add(
|
||||
this.viewport.viewportUpdated.subscribe(() => {
|
||||
this.refresh();
|
||||
this.refresh({ type: 'all' });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -222,7 +516,6 @@ export class CanvasRenderer {
|
||||
sizeUpdatedRafId = null;
|
||||
this._resetSize();
|
||||
this._render();
|
||||
this.refresh();
|
||||
}, this._container);
|
||||
})
|
||||
);
|
||||
@@ -233,69 +526,212 @@ export class CanvasRenderer {
|
||||
|
||||
if (this.usePlaceholder !== shouldRenderPlaceholders) {
|
||||
this.usePlaceholder = shouldRenderPlaceholders;
|
||||
this.refresh();
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
this.layerManager.getCanvasLayers().forEach((layer, idx) => {
|
||||
if (!this._stackingCanvas[idx]) {
|
||||
fallbackElement = fallbackElement.concat(layer.elements);
|
||||
return;
|
||||
for (const idx of stackingIndexesToRender) {
|
||||
const layer = allCanvasLayers[idx];
|
||||
const canvas = this._stackingCanvas[idx];
|
||||
|
||||
if (!layer || !canvas) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canvas = this._stackingCanvas[idx];
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
const rc = new RoughCanvas(ctx.canvas);
|
||||
const layerRenderBound = this._getLayerRenderBound(
|
||||
layer.elements,
|
||||
viewportBound
|
||||
);
|
||||
const resolvedLayerRenderBound = this._getResolvedStackingCanvasBound(
|
||||
canvas,
|
||||
layerRenderBound
|
||||
);
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
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.save();
|
||||
ctx.setTransform(matrix);
|
||||
|
||||
this._renderByBound(ctx, matrix, rc, viewportBounds, layer.elements);
|
||||
});
|
||||
this._renderByBound(
|
||||
ctx,
|
||||
matrix,
|
||||
new RoughCanvas(ctx.canvas),
|
||||
viewportBounds,
|
||||
fallbackElement,
|
||||
true,
|
||||
renderStats
|
||||
);
|
||||
}
|
||||
|
||||
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 canvasMemorySnapshots = this._getCanvasMemorySnapshots();
|
||||
const canvasMemoryBytes = canvasMemorySnapshots.reduce(
|
||||
(sum, snapshot) => sum + snapshot.bytes,
|
||||
0
|
||||
);
|
||||
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
|
||||
overLay: boolean = false,
|
||||
renderStats?: RenderPassStats
|
||||
) {
|
||||
if (!ctx) return;
|
||||
|
||||
renderStats && (renderStats.renderByBoundCallCount += 1);
|
||||
|
||||
const elements =
|
||||
surfaceElements ??
|
||||
(this.grid.search(bound, {
|
||||
@@ -305,10 +741,12 @@ 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;
|
||||
@@ -316,6 +754,7 @@ 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)
|
||||
@@ -333,6 +772,7 @@ 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);
|
||||
@@ -348,33 +788,38 @@ export class CanvasRenderer {
|
||||
const sizeUpdater = this._canvasSizeUpdater();
|
||||
|
||||
sizeUpdater.update(this.canvas);
|
||||
|
||||
this._stackingCanvas.forEach(sizeUpdater.update);
|
||||
this.refresh();
|
||||
this._invalidate({ type: 'all' });
|
||||
}
|
||||
|
||||
private _watchSurface(surfaceModel: SurfaceBlockModel) {
|
||||
this._disposables.add(
|
||||
surfaceModel.elementAdded.subscribe(() => this.refresh())
|
||||
surfaceModel.elementAdded.subscribe(() => this.refresh({ type: 'all' }))
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.elementRemoved.subscribe(() => this.refresh())
|
||||
surfaceModel.elementRemoved.subscribe(() => this.refresh({ type: 'all' }))
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementAdded.subscribe(() => this.refresh())
|
||||
surfaceModel.localElementAdded.subscribe(() =>
|
||||
this.refresh({ type: 'all' })
|
||||
)
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementDeleted.subscribe(() => this.refresh())
|
||||
surfaceModel.localElementDeleted.subscribe(() =>
|
||||
this.refresh({ type: 'all' })
|
||||
)
|
||||
);
|
||||
this._disposables.add(
|
||||
surfaceModel.localElementUpdated.subscribe(() => this.refresh())
|
||||
surfaceModel.localElementUpdated.subscribe(({ model }) => {
|
||||
this.refresh({ type: 'element', element: model });
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
surfaceModel.elementUpdated.subscribe(payload => {
|
||||
// ignore externalXYWH update cause it's updated by the renderer
|
||||
if (payload.props['externalXYWH']) return;
|
||||
this.refresh();
|
||||
const element = surfaceModel.getElementById(payload.id);
|
||||
this.refresh(element ? { type: 'element', element } : { type: 'all' });
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -382,7 +827,7 @@ export class CanvasRenderer {
|
||||
addOverlay(overlay: Overlay) {
|
||||
overlay.setRenderer(this);
|
||||
this._overlays.add(overlay);
|
||||
this.refresh();
|
||||
this.refresh({ type: 'main' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -394,7 +839,7 @@ export class CanvasRenderer {
|
||||
container.append(this.canvas);
|
||||
|
||||
this._resetSize();
|
||||
this.refresh();
|
||||
this.refresh({ type: 'all' });
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -453,8 +898,46 @@ export class CanvasRenderer {
|
||||
return this.provider.getPropertyValue?.(property) ?? '';
|
||||
}
|
||||
|
||||
refresh() {
|
||||
if (this._refreshRafId !== null) return;
|
||||
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;
|
||||
}
|
||||
|
||||
this._refreshRafId = requestConnectedFrame(() => {
|
||||
this._refreshRafId = null;
|
||||
@@ -469,6 +952,6 @@ export class CanvasRenderer {
|
||||
|
||||
overlay.setRenderer(null);
|
||||
this._overlays.delete(overlay);
|
||||
this.refresh();
|
||||
this.refresh({ type: 'main' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,30 +354,37 @@ 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();
|
||||
})
|
||||
);
|
||||
@@ -387,6 +394,9 @@ 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();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
coverage: {
|
||||
provider: 'istanbul', // or 'c8'
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/ext-loader',
|
||||
},
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
import type { BrushElementModel } from '@blocksuite/affine-model';
|
||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||
|
||||
import { renderBrushLikeDom } from './shared';
|
||||
|
||||
export const BrushDomRendererExtension = DomElementRendererExtension(
|
||||
'brush',
|
||||
(
|
||||
@@ -12,58 +14,11 @@ export const BrushDomRendererExtension = DomElementRendererExtension(
|
||||
domElement: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
) => {
|
||||
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';
|
||||
renderBrushLikeDom({
|
||||
model,
|
||||
domElement,
|
||||
renderer,
|
||||
color: renderer.getColorValue(model.color, DefaultTheme.black, true),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
import type { HighlighterElementModel } from '@blocksuite/affine-model';
|
||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||
|
||||
import { renderBrushLikeDom } from './shared';
|
||||
|
||||
export const HighlighterDomRendererExtension = DomElementRendererExtension(
|
||||
'highlighter',
|
||||
(
|
||||
@@ -12,62 +14,15 @@ export const HighlighterDomRendererExtension = DomElementRendererExtension(
|
||||
domElement: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
) => {
|
||||
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';
|
||||
renderBrushLikeDom({
|
||||
model,
|
||||
domElement,
|
||||
renderer,
|
||||
color: renderer.getColorValue(
|
||||
model.color,
|
||||
DefaultTheme.hightlighterColor,
|
||||
true
|
||||
),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
82
blocksuite/affine/gfx/brush/src/renderer/dom/shared.ts
Normal file
82
blocksuite/affine/gfx/brush/src/renderer/dom/shared.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
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,6 +14,8 @@ 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;
|
||||
@@ -21,6 +23,15 @@ 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 };
|
||||
@@ -81,10 +92,7 @@ function createArrowMarker(
|
||||
strokeWidth: number,
|
||||
isStart: boolean = false
|
||||
): SVGMarkerElement {
|
||||
const marker = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'marker'
|
||||
);
|
||||
const marker = document.createElementNS(SVG_NS, 'marker');
|
||||
const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2);
|
||||
|
||||
marker.id = id;
|
||||
@@ -98,10 +106,7 @@ function createArrowMarker(
|
||||
|
||||
switch (style) {
|
||||
case 'Arrow': {
|
||||
const path = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
);
|
||||
const path = document.createElementNS(SVG_NS, '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'
|
||||
@@ -112,10 +117,7 @@ function createArrowMarker(
|
||||
break;
|
||||
}
|
||||
case 'Triangle': {
|
||||
const path = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
);
|
||||
const path = document.createElementNS(SVG_NS, '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'
|
||||
@@ -126,10 +128,7 @@ function createArrowMarker(
|
||||
break;
|
||||
}
|
||||
case 'Circle': {
|
||||
const circle = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'circle'
|
||||
);
|
||||
const circle = document.createElementNS(SVG_NS, 'circle');
|
||||
circle.setAttribute('cx', '10');
|
||||
circle.setAttribute('cy', '10');
|
||||
circle.setAttribute('r', '4');
|
||||
@@ -139,10 +138,7 @@ function createArrowMarker(
|
||||
break;
|
||||
}
|
||||
case 'Diamond': {
|
||||
const path = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
);
|
||||
const path = document.createElementNS(SVG_NS, '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);
|
||||
@@ -154,13 +150,64 @@ 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,
|
||||
container: HTMLElement,
|
||||
retained: RetainedConnectorDom,
|
||||
renderer: DomRenderer,
|
||||
zoom: number
|
||||
) {
|
||||
if (!isConnectorWithLabel(model) || !model.labelXYWH) {
|
||||
retained.label?.remove();
|
||||
retained.label = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -176,8 +223,7 @@ function renderConnectorLabel(
|
||||
},
|
||||
} = model;
|
||||
|
||||
// Create label element
|
||||
const labelElement = document.createElement('div');
|
||||
const labelElement = getOrCreateLabelElement(retained);
|
||||
labelElement.style.position = 'absolute';
|
||||
labelElement.style.left = `${lx * zoom}px`;
|
||||
labelElement.style.top = `${ly * zoom}px`;
|
||||
@@ -210,11 +256,7 @@ function renderConnectorLabel(
|
||||
labelElement.style.wordWrap = 'break-word';
|
||||
|
||||
// Add text content
|
||||
if (model.text) {
|
||||
labelElement.textContent = model.text.toString();
|
||||
}
|
||||
|
||||
container.append(labelElement);
|
||||
labelElement.textContent = model.text ? model.text.toString() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,14 +283,13 @@ 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
|
||||
@@ -257,8 +298,7 @@ export const connectorBaseDomRenderer = (
|
||||
const offsetX = pathBounds.minX - padding;
|
||||
const offsetY = pathBounds.minY - padding;
|
||||
|
||||
// Create SVG element
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
const { defs, path, svg } = retained;
|
||||
svg.style.position = 'absolute';
|
||||
svg.style.left = `${offsetX * zoom}px`;
|
||||
svg.style.top = `${offsetY * zoom}px`;
|
||||
@@ -268,49 +308,43 @@ 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
|
||||
);
|
||||
|
||||
// Create markers for endpoints
|
||||
const markers: SVGMarkerElement[] = [];
|
||||
let startMarkerId = '';
|
||||
let endMarkerId = '';
|
||||
|
||||
if (frontEndpointStyle !== 'None') {
|
||||
startMarkerId = `start-marker-${model.id}`;
|
||||
const startMarker = createArrowMarker(
|
||||
startMarkerId,
|
||||
frontEndpointStyle,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
true
|
||||
markers.push(
|
||||
createArrowMarker(
|
||||
startMarkerId,
|
||||
frontEndpointStyle,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
true
|
||||
)
|
||||
);
|
||||
defs.append(startMarker);
|
||||
}
|
||||
|
||||
if (rearEndpointStyle !== 'None') {
|
||||
endMarkerId = `end-marker-${model.id}`;
|
||||
const endMarker = createArrowMarker(
|
||||
endMarkerId,
|
||||
rearEndpointStyle,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
false
|
||||
markers.push(
|
||||
createArrowMarker(
|
||||
endMarkerId,
|
||||
rearEndpointStyle,
|
||||
strokeColor,
|
||||
strokeWidth,
|
||||
false
|
||||
)
|
||||
);
|
||||
defs.append(endMarker);
|
||||
}
|
||||
|
||||
// Create path element
|
||||
const pathElement = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
);
|
||||
defs.replaceChildren(...markers);
|
||||
|
||||
// Adjust points relative to the SVG coordinate system
|
||||
const adjustedPoints = points.map(point => {
|
||||
@@ -334,29 +368,25 @@ export const connectorBaseDomRenderer = (
|
||||
});
|
||||
|
||||
const pathData = createConnectorPath(adjustedPoints, mode);
|
||||
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
|
||||
path.setAttribute('d', pathData);
|
||||
path.setAttribute('stroke', strokeColor);
|
||||
path.setAttribute('stroke-width', String(strokeWidth));
|
||||
if (strokeStyle === 'dash') {
|
||||
pathElement.setAttribute('stroke-dasharray', '12,12');
|
||||
path.setAttribute('stroke-dasharray', '12,12');
|
||||
} else {
|
||||
path.removeAttribute('stroke-dasharray');
|
||||
}
|
||||
|
||||
// Apply markers
|
||||
if (startMarkerId) {
|
||||
pathElement.setAttribute('marker-start', `url(#${startMarkerId})`);
|
||||
path.setAttribute('marker-start', `url(#${startMarkerId})`);
|
||||
} else {
|
||||
path.removeAttribute('marker-start');
|
||||
}
|
||||
if (endMarkerId) {
|
||||
pathElement.setAttribute('marker-end', `url(#${endMarkerId})`);
|
||||
path.setAttribute('marker-end', `url(#${endMarkerId})`);
|
||||
} else {
|
||||
path.removeAttribute('marker-end');
|
||||
}
|
||||
|
||||
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`;
|
||||
@@ -370,7 +400,11 @@ export const connectorDomRenderer = (
|
||||
renderer: DomRenderer
|
||||
): void => {
|
||||
connectorBaseDomRenderer(model, element, renderer);
|
||||
renderConnectorLabel(model, element, renderer, renderer.viewport.zoom);
|
||||
|
||||
const retained = retainedConnectorDom.get(element);
|
||||
if (!retained) return;
|
||||
|
||||
renderConnectorLabel(model, retained, renderer, renderer.viewport.zoom);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -6,6 +6,37 @@ 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,
|
||||
@@ -14,10 +45,6 @@ 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': {
|
||||
@@ -42,6 +69,54 @@ 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,
|
||||
@@ -99,8 +174,7 @@ export const shapeDomRenderer = (
|
||||
const { zoom } = renderer.viewport;
|
||||
const unscaledWidth = model.w;
|
||||
const unscaledHeight = model.h;
|
||||
|
||||
const newChildren: Element[] = [];
|
||||
const retained = getRetainedShapeDom(element);
|
||||
|
||||
const fillColor = renderer.getColorValue(
|
||||
model.fillColor,
|
||||
@@ -124,6 +198,7 @@ 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;
|
||||
|
||||
@@ -155,37 +230,30 @@ 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)
|
||||
// innerHTML was already cleared by applyShapeSpecificStyles if necessary
|
||||
removeSvg(retained);
|
||||
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 = document.createElement('div');
|
||||
const textElement = getOrCreateText(retained, element);
|
||||
if (isRTL(str)) {
|
||||
textElement.dir = 'rtl';
|
||||
} else {
|
||||
textElement.removeAttribute('dir');
|
||||
}
|
||||
textElement.style.position = 'absolute';
|
||||
textElement.style.inset = '0';
|
||||
@@ -210,12 +278,10 @@ export const shapeDomRenderer = (
|
||||
true
|
||||
);
|
||||
textElement.textContent = str;
|
||||
newChildren.push(textElement);
|
||||
} else {
|
||||
removeText(retained);
|
||||
}
|
||||
|
||||
// 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": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -8,10 +9,9 @@ export default defineConfig({
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
name: 'chromium',
|
||||
provider: 'playwright',
|
||||
instances: [{ browser: 'chromium' }],
|
||||
provider: playwright(),
|
||||
isolate: false,
|
||||
providerOptions: {},
|
||||
},
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
|
||||
@@ -177,6 +177,11 @@ 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];
|
||||
@@ -213,6 +218,10 @@ 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);
|
||||
|
||||
@@ -258,6 +267,10 @@ 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);
|
||||
@@ -300,6 +313,10 @@ 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)
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/pdfmake": "^0.2.12",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"version": "0.26.3"
|
||||
}
|
||||
|
||||
@@ -17,7 +17,14 @@ export async function printToPdf(
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const iframe = document.createElement('iframe');
|
||||
document.body.append(iframe);
|
||||
iframe.style.display = 'none';
|
||||
// Use a hidden but rendering-enabled state instead of display: none
|
||||
Object.assign(iframe.style, {
|
||||
visibility: 'hidden',
|
||||
position: 'absolute',
|
||||
width: '0',
|
||||
height: '0',
|
||||
border: 'none',
|
||||
});
|
||||
iframe.srcdoc = '<!DOCTYPE html>';
|
||||
iframe.onload = async () => {
|
||||
if (!iframe.contentWindow) {
|
||||
@@ -28,6 +35,44 @@ export async function printToPdf(
|
||||
reject(new Error('Root element not defined, unable to print pdf'));
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = iframe.contentWindow.document;
|
||||
|
||||
doc.write(`<!DOCTYPE html><html><head><style>@media print {
|
||||
html, body {
|
||||
height: initial !important;
|
||||
overflow: initial !important;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
color: #000 !important;
|
||||
background: #fff !important;
|
||||
color-scheme: light !important;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
:root, body {
|
||||
--affine-text-primary: #000 !important;
|
||||
--affine-text-secondary: #111 !important;
|
||||
--affine-text-tertiary: #333 !important;
|
||||
--affine-background-primary: #fff !important;
|
||||
--affine-background-secondary: #fff !important;
|
||||
--affine-background-tertiary: #fff !important;
|
||||
}
|
||||
body, [data-theme='dark'] {
|
||||
color: #000 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
body * {
|
||||
color: #000 !important;
|
||||
-webkit-text-fill-color: #000 !important;
|
||||
}
|
||||
:root {
|
||||
--affine-note-shadow-box: none !important;
|
||||
--affine-note-shadow-sticker: none !important;
|
||||
}
|
||||
}</style></head><body></body></html>`);
|
||||
doc.close();
|
||||
iframe.contentWindow.document
|
||||
.write(`<!DOCTYPE html><html><head><style>@media print {
|
||||
html, body {
|
||||
@@ -49,6 +94,9 @@ export async function printToPdf(
|
||||
--affine-background-primary: #fff !important;
|
||||
--affine-background-secondary: #fff !important;
|
||||
--affine-background-tertiary: #fff !important;
|
||||
--affine-background-code-block: #f5f5f5 !important;
|
||||
--affine-quote-color: #e3e3e3 !important;
|
||||
--affine-border-color: #e3e3e3 !important;
|
||||
}
|
||||
body, [data-theme='dark'] {
|
||||
color: #000 !important;
|
||||
@@ -68,7 +116,7 @@ export async function printToPdf(
|
||||
for (const element of document.styleSheets) {
|
||||
try {
|
||||
for (const cssRule of element.cssRules) {
|
||||
const target = iframe.contentWindow.document.styleSheets[0];
|
||||
const target = doc.styleSheets[0];
|
||||
target.insertRule(cssRule.cssText, target.cssRules.length);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -83,12 +131,33 @@ export async function printToPdf(
|
||||
}
|
||||
}
|
||||
|
||||
// Recursive function to find all canvases, including those in shadow roots
|
||||
const findAllCanvases = (root: Node): HTMLCanvasElement[] => {
|
||||
const canvases: HTMLCanvasElement[] = [];
|
||||
const traverse = (node: Node) => {
|
||||
if (node instanceof HTMLCanvasElement) {
|
||||
canvases.push(node);
|
||||
}
|
||||
if (node instanceof HTMLElement || node instanceof ShadowRoot) {
|
||||
node.childNodes.forEach(traverse);
|
||||
}
|
||||
if (node instanceof HTMLElement && node.shadowRoot) {
|
||||
traverse(node.shadowRoot);
|
||||
}
|
||||
};
|
||||
traverse(root);
|
||||
return canvases;
|
||||
};
|
||||
|
||||
// convert all canvas to image
|
||||
const canvasImgObjectUrlMap = new Map<string, string>();
|
||||
const allCanvas = rootElement.getElementsByTagName('canvas');
|
||||
const allCanvas = findAllCanvases(rootElement);
|
||||
let canvasKey = 1;
|
||||
const canvasToKeyMap = new Map<HTMLCanvasElement, string>();
|
||||
|
||||
for (const canvas of allCanvas) {
|
||||
canvas.dataset['printToPdfCanvasKey'] = canvasKey.toString();
|
||||
const key = canvasKey.toString();
|
||||
canvasToKeyMap.set(canvas, key);
|
||||
canvasKey++;
|
||||
const canvasImgObjectUrl = await new Promise<Blob | null>(resolve => {
|
||||
try {
|
||||
@@ -103,20 +172,42 @@ export async function printToPdf(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
canvasImgObjectUrlMap.set(
|
||||
canvas.dataset['printToPdfCanvasKey'],
|
||||
URL.createObjectURL(canvasImgObjectUrl)
|
||||
);
|
||||
canvasImgObjectUrlMap.set(key, URL.createObjectURL(canvasImgObjectUrl));
|
||||
}
|
||||
|
||||
const importedRoot = iframe.contentWindow.document.importNode(
|
||||
rootElement,
|
||||
true
|
||||
) as HTMLDivElement;
|
||||
// Recursive deep clone that flattens Shadow DOM into Light DOM
|
||||
const deepCloneWithShadows = (node: Node): Node => {
|
||||
const clone = doc.importNode(node, false);
|
||||
|
||||
if (
|
||||
clone instanceof HTMLCanvasElement &&
|
||||
node instanceof HTMLCanvasElement
|
||||
) {
|
||||
const key = canvasToKeyMap.get(node);
|
||||
if (key) {
|
||||
clone.dataset['printToPdfCanvasKey'] = key;
|
||||
}
|
||||
}
|
||||
|
||||
const appendChildren = (source: Node) => {
|
||||
source.childNodes.forEach(child => {
|
||||
(clone as Element).append(deepCloneWithShadows(child));
|
||||
});
|
||||
};
|
||||
|
||||
if (node instanceof HTMLElement && node.shadowRoot) {
|
||||
appendChildren(node.shadowRoot);
|
||||
}
|
||||
appendChildren(node);
|
||||
|
||||
return clone;
|
||||
};
|
||||
|
||||
const importedRoot = deepCloneWithShadows(rootElement) as HTMLDivElement;
|
||||
|
||||
// force light theme in print iframe
|
||||
iframe.contentWindow.document.documentElement.dataset.theme = 'light';
|
||||
iframe.contentWindow.document.body.dataset.theme = 'light';
|
||||
doc.documentElement.dataset.theme = 'light';
|
||||
doc.body.dataset.theme = 'light';
|
||||
importedRoot.dataset.theme = 'light';
|
||||
|
||||
// draw saved canvas image to canvas
|
||||
@@ -135,17 +226,67 @@ export async function printToPdf(
|
||||
}
|
||||
}
|
||||
|
||||
// append to iframe and print
|
||||
iframe.contentWindow.document.body.append(importedRoot);
|
||||
// Remove lazy loading from all images and force reload
|
||||
const allImages = importedRoot.querySelectorAll('img');
|
||||
allImages.forEach(img => {
|
||||
img.removeAttribute('loading');
|
||||
const src = img.getAttribute('src');
|
||||
if (src) img.setAttribute('src', src);
|
||||
});
|
||||
|
||||
// append to iframe
|
||||
doc.body.append(importedRoot);
|
||||
|
||||
await options.beforeprint?.(iframe);
|
||||
|
||||
// browser may take some time to load font
|
||||
await new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 1000);
|
||||
});
|
||||
// Robust image waiting logic
|
||||
const waitForImages = async (container: HTMLElement) => {
|
||||
const images: HTMLImageElement[] = [];
|
||||
const view = container.ownerDocument.defaultView;
|
||||
if (!view) return;
|
||||
|
||||
const findImages = (root: Node) => {
|
||||
if (root instanceof view.HTMLImageElement) {
|
||||
images.push(root);
|
||||
}
|
||||
if (
|
||||
root instanceof view.HTMLElement ||
|
||||
root instanceof view.ShadowRoot
|
||||
) {
|
||||
root.childNodes.forEach(findImages);
|
||||
}
|
||||
if (root instanceof view.HTMLElement && root.shadowRoot) {
|
||||
findImages(root.shadowRoot);
|
||||
}
|
||||
};
|
||||
|
||||
findImages(container);
|
||||
|
||||
await Promise.all(
|
||||
images.map(img => {
|
||||
if (img.complete) {
|
||||
if (img.naturalWidth === 0) {
|
||||
console.warn('Image failed to load:', img.src);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
img.onload = resolve;
|
||||
img.onerror = resolve;
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
await waitForImages(importedRoot);
|
||||
|
||||
// browser may take some time to load font or other resources
|
||||
await (doc.fonts?.ready ??
|
||||
new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 1000);
|
||||
}));
|
||||
|
||||
iframe.contentWindow.onafterprint = async () => {
|
||||
iframe.remove();
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 1000,
|
||||
coverage: {
|
||||
provider: 'istanbul', // or 'c8'
|
||||
provider: 'istanbul', // or 'istanbul'
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/affine-shared',
|
||||
},
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"version": "0.26.3"
|
||||
}
|
||||
|
||||
22
blocksuite/framework/global/src/__tests__/curve.unit.spec.ts
Normal file
22
blocksuite/framework/global/src/__tests__/curve.unit.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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,6 +142,11 @@ 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];
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
coverage: {
|
||||
provider: 'istanbul', // or 'c8'
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/global',
|
||||
},
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -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,16 +613,7 @@ export class LayerManager extends GfxExtension {
|
||||
};
|
||||
|
||||
if (shouldUpdateGroupChildren) {
|
||||
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);
|
||||
this._reset();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ 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';
|
||||
@@ -58,14 +65,22 @@ 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);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -105,17 +120,23 @@ 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 } = viewport;
|
||||
const { translateX, translateY, zoom, viewScale } = viewport;
|
||||
const bound = Bound.deserialize(this.model.xywh);
|
||||
|
||||
const scaledX = bound.x * zoom;
|
||||
const scaledY = bound.y * zoom;
|
||||
const scaledX = (bound.x * zoom) / viewScale;
|
||||
const scaledY = (bound.y * zoom) / viewScale;
|
||||
const deltaX = scaledX - bound.x;
|
||||
const deltaY = scaledY - bound.y;
|
||||
|
||||
return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`;
|
||||
return `translate(${translateX / viewScale + deltaX}px, ${translateY / viewScale + deltaY}px) scale(${this.getCSSScaleVal()})`;
|
||||
}
|
||||
|
||||
getRenderingRect() {
|
||||
@@ -219,18 +240,12 @@ export function toGfxBlockComponent<
|
||||
handleGfxConnection(this);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
getCSSScaleVal(): number {
|
||||
return GfxBlockComponent.prototype.getCSSScaleVal.call(this);
|
||||
}
|
||||
|
||||
getCSSTransform() {
|
||||
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})`;
|
||||
return GfxBlockComponent.prototype.getCSSTransform.call(this);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
@@ -8,15 +9,14 @@ export default defineConfig({
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: true,
|
||||
name: 'chromium',
|
||||
provider: 'playwright',
|
||||
instances: [{ browser: 'chromium' }],
|
||||
provider: playwright(),
|
||||
isolate: false,
|
||||
providerOptions: {},
|
||||
},
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
coverage: {
|
||||
provider: 'istanbul', // or 'c8'
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/std',
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"devDependencies": {
|
||||
"@types/lodash.clonedeep": "^4.5.9",
|
||||
"@types/lodash.merge": "^4.6.9",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -7,15 +7,11 @@ 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
|
||||
: typeof global !== 'undefined'
|
||||
? global
|
||||
: {}
|
||||
) as Record<string, boolean>;
|
||||
const env = (typeof globalThis !== 'undefined'
|
||||
? globalThis
|
||||
: typeof window !== 'undefined'
|
||||
? window
|
||||
: {}) as unknown as Record<string, boolean>;
|
||||
const importIdentifier = '__ $BLOCKSUITE_STORE$ __';
|
||||
|
||||
if (env[importIdentifier] === true) {
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
coverage: {
|
||||
provider: 'istanbul', // or 'c8'
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/store',
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"y-protocols": "^1.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "*"
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
include: ['src/__tests__/**/*.unit.spec.ts'],
|
||||
testTimeout: 500,
|
||||
coverage: {
|
||||
provider: 'istanbul', // or 'c8'
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../../.coverage/sync',
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc",
|
||||
"test:unit": "vitest --browser.headless --run",
|
||||
"test:debug": "PWDEBUG=1 npx vitest"
|
||||
"test:debug": "PWDEBUG=1 npx vitest --browser.headless=false"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
@@ -41,10 +41,11 @@
|
||||
],
|
||||
"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": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"version": "0.26.3"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -253,6 +254,40 @@ 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[] = [];
|
||||
|
||||
@@ -428,14 +463,17 @@ 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]',
|
||||
})!,
|
||||
];
|
||||
|
||||
@@ -528,6 +566,35 @@ 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 = [
|
||||
@@ -769,6 +836,7 @@ 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);
|
||||
@@ -777,6 +845,7 @@ 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';
|
||||
@@ -792,10 +861,10 @@ test('indexed canvas should be inserted into edgeless portal when switch to edge
|
||||
'.indexable-canvas'
|
||||
)[0] as HTMLCanvasElement;
|
||||
|
||||
expect(indexedCanvas.width).toBe(
|
||||
expect(indexedCanvas.width).toBeLessThanOrEqual(
|
||||
(surface.renderer as CanvasRenderer).canvas.width
|
||||
);
|
||||
expect(indexedCanvas.height).toBe(
|
||||
expect(indexedCanvas.height).toBeLessThanOrEqual(
|
||||
(surface.renderer as CanvasRenderer).canvas.height
|
||||
);
|
||||
expect(indexedCanvas.width).not.toBe(0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig(_configEnv =>
|
||||
@@ -18,13 +19,13 @@ export default defineConfig(_configEnv =>
|
||||
retry: process.env.CI === 'true' ? 3 : 0,
|
||||
browser: {
|
||||
enabled: true,
|
||||
headless: process.env.CI === 'true',
|
||||
headless: true,
|
||||
instances: [
|
||||
{ browser: 'chromium' },
|
||||
{ browser: 'firefox' },
|
||||
{ browser: 'webkit' },
|
||||
],
|
||||
provider: 'playwright',
|
||||
provider: playwright(),
|
||||
isolate: false,
|
||||
viewport: {
|
||||
width: 1024,
|
||||
@@ -32,16 +33,13 @@ export default defineConfig(_configEnv =>
|
||||
},
|
||||
},
|
||||
coverage: {
|
||||
provider: 'istanbul', // or 'c8'
|
||||
provider: 'istanbul',
|
||||
reporter: ['lcov'],
|
||||
reportsDirectory: '../../.coverage/integration-test',
|
||||
},
|
||||
deps: {
|
||||
interopDefault: true,
|
||||
},
|
||||
testTransformMode: {
|
||||
web: ['src/__tests__/**/*.spec.ts'],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -64,9 +64,9 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@typescript-eslint/parser": "^8.55.0",
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-istanbul": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"@vitest/browser": "^4.0.18",
|
||||
"@vitest/coverage-istanbul": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"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": "^3.2.4"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"packageManager": "yarn@4.12.0",
|
||||
"resolutions": {
|
||||
|
||||
@@ -14,10 +14,17 @@ affine_common = { workspace = true, features = [
|
||||
"napi",
|
||||
"ydoc-loader",
|
||||
] }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
file-format = { workspace = true }
|
||||
image = { workspace = true }
|
||||
infer = { workspace = true }
|
||||
llm_adapter = { workspace = true }
|
||||
libwebp-sys = { workspace = true }
|
||||
little_exif = { workspace = true }
|
||||
llm_adapter = { workspace = true, default-features = false, features = [
|
||||
"ureq-client",
|
||||
] }
|
||||
matroska = { workspace = true }
|
||||
mp4parse = { workspace = true }
|
||||
napi = { workspace = true, features = ["async"] }
|
||||
napi-derive = { workspace = true }
|
||||
|
||||
BIN
packages/backend/native/fixtures/audio-only.mka
Normal file
BIN
packages/backend/native/fixtures/audio-only.mka
Normal file
Binary file not shown.
BIN
packages/backend/native/fixtures/audio-only.webm
Normal file
BIN
packages/backend/native/fixtures/audio-only.webm
Normal file
Binary file not shown.
BIN
packages/backend/native/fixtures/audio-video.webm
Normal file
BIN
packages/backend/native/fixtures/audio-video.webm
Normal file
Binary file not shown.
10
packages/backend/native/index.d.ts
vendored
10
packages/backend/native/index.d.ts
vendored
@@ -54,6 +54,12 @@ export declare function llmDispatch(protocol: string, backendConfigJson: string,
|
||||
|
||||
export declare function llmDispatchStream(protocol: string, backendConfigJson: string, requestJson: string, callback: ((err: Error | null, arg: string) => void)): LlmStreamHandle
|
||||
|
||||
export declare function llmEmbeddingDispatch(protocol: string, backendConfigJson: string, requestJson: string): string
|
||||
|
||||
export declare function llmRerankDispatch(protocol: string, backendConfigJson: string, requestJson: string): string
|
||||
|
||||
export declare function llmStructuredDispatch(protocol: string, backendConfigJson: string, requestJson: string): string
|
||||
|
||||
/**
|
||||
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
|
||||
* result binary.
|
||||
@@ -83,6 +89,8 @@ export interface NativeCrawlResult {
|
||||
export interface NativeMarkdownResult {
|
||||
title: string
|
||||
markdown: string
|
||||
knownUnsupportedBlocks: Array<string>
|
||||
unknownBlocks: Array<string>
|
||||
}
|
||||
|
||||
export interface NativePageDocContent {
|
||||
@@ -110,6 +118,8 @@ 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>
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 {
|
||||
@@ -16,6 +18,8 @@ impl From<MarkdownResult> for NativeMarkdownResult {
|
||||
Self {
|
||||
title: result.title,
|
||||
markdown: result.markdown,
|
||||
known_unsupported_blocks: result.known_unsupported_blocks,
|
||||
unknown_blocks: result.unknown_blocks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use matroska::Matroska;
|
||||
use mp4parse::{TrackType, read_mp4};
|
||||
use napi_derive::napi;
|
||||
|
||||
@@ -8,7 +9,13 @@ pub fn get_mime(input: &[u8]) -> String {
|
||||
} else {
|
||||
file_format::FileFormat::from_bytes(input).media_type().to_string()
|
||||
};
|
||||
if mimetype == "video/mp4" {
|
||||
if let Some(container) = matroska_container_kind(input).or(match mimetype.as_str() {
|
||||
"video/webm" | "application/webm" => Some(ContainerKind::WebM),
|
||||
"video/x-matroska" | "application/x-matroska" => Some(ContainerKind::Matroska),
|
||||
_ => None,
|
||||
}) {
|
||||
detect_matroska_flavor(input, container, &mimetype)
|
||||
} else if mimetype == "video/mp4" {
|
||||
detect_mp4_flavor(input)
|
||||
} else {
|
||||
mimetype
|
||||
@@ -37,3 +44,68 @@ fn detect_mp4_flavor(input: &[u8]) -> String {
|
||||
Err(_) => "video/mp4".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ContainerKind {
|
||||
WebM,
|
||||
Matroska,
|
||||
}
|
||||
|
||||
impl ContainerKind {
|
||||
fn audio_mime(&self) -> &'static str {
|
||||
match self {
|
||||
ContainerKind::WebM => "audio/webm",
|
||||
ContainerKind::Matroska => "audio/x-matroska",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_matroska_flavor(input: &[u8], container: ContainerKind, fallback: &str) -> String {
|
||||
match Matroska::open(std::io::Cursor::new(input)) {
|
||||
Ok(file) => {
|
||||
let has_video = file.video_tracks().next().is_some();
|
||||
let has_audio = file.audio_tracks().next().is_some();
|
||||
if !has_video && has_audio {
|
||||
container.audio_mime().to_string()
|
||||
} else {
|
||||
fallback.to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => fallback.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn matroska_container_kind(input: &[u8]) -> Option<ContainerKind> {
|
||||
let header = &input[..1024.min(input.len())];
|
||||
if header.windows(4).any(|window| window.eq_ignore_ascii_case(b"webm")) {
|
||||
Some(ContainerKind::WebM)
|
||||
} else if header.windows(8).any(|window| window.eq_ignore_ascii_case(b"matroska")) {
|
||||
Some(ContainerKind::Matroska)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AUDIO_ONLY_WEBM: &[u8] = include_bytes!("../fixtures/audio-only.webm");
|
||||
const AUDIO_VIDEO_WEBM: &[u8] = include_bytes!("../fixtures/audio-video.webm");
|
||||
const AUDIO_ONLY_MATROSKA: &[u8] = include_bytes!("../fixtures/audio-only.mka");
|
||||
|
||||
#[test]
|
||||
fn detects_audio_only_webm_as_audio() {
|
||||
assert_eq!(get_mime(AUDIO_ONLY_WEBM), "audio/webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_video_webm() {
|
||||
assert_eq!(get_mime(AUDIO_VIDEO_WEBM), "video/webm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_audio_only_matroska_as_audio() {
|
||||
assert_eq!(get_mime(AUDIO_ONLY_MATROSKA), "audio/x-matroska");
|
||||
}
|
||||
}
|
||||
|
||||
353
packages/backend/native/src/image.rs
Normal file
353
packages/backend/native/src/image.rs
Normal file
@@ -0,0 +1,353 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod doc_loader;
|
||||
pub mod file_type;
|
||||
pub mod hashcash;
|
||||
pub mod html_sanitize;
|
||||
pub mod image;
|
||||
pub mod llm;
|
||||
pub mod tiktoken;
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ use std::sync::{
|
||||
|
||||
use llm_adapter::{
|
||||
backend::{
|
||||
BackendConfig, BackendError, BackendProtocol, ReqwestHttpClient, dispatch_request, dispatch_stream_events_with,
|
||||
BackendConfig, BackendError, BackendProtocol, DefaultHttpClient, dispatch_embedding_request, dispatch_request,
|
||||
dispatch_rerank_request, dispatch_stream_events_with, dispatch_structured_request,
|
||||
},
|
||||
core::{CoreRequest, StreamEvent},
|
||||
core::{CoreRequest, EmbeddingRequest, RerankRequest, StreamEvent, StructuredRequest},
|
||||
middleware::{
|
||||
MiddlewareConfig, PipelineContext, RequestMiddleware, StreamMiddleware, citation_indexing, clamp_max_tokens,
|
||||
normalize_messages, run_request_middleware_chain, run_stream_middleware_chain, stream_event_normalize,
|
||||
@@ -40,6 +41,20 @@ struct LlmDispatchPayload {
|
||||
middleware: LlmMiddlewarePayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct LlmStructuredDispatchPayload {
|
||||
#[serde(flatten)]
|
||||
request: StructuredRequest,
|
||||
#[serde(default)]
|
||||
middleware: LlmMiddlewarePayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct LlmRerankDispatchPayload {
|
||||
#[serde(flatten)]
|
||||
request: RerankRequest,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct LlmStreamHandle {
|
||||
aborted: Arc<AtomicBool>,
|
||||
@@ -61,7 +76,44 @@ pub fn llm_dispatch(protocol: String, backend_config_json: String, request_json:
|
||||
let request = apply_request_middlewares(payload.request, &payload.middleware)?;
|
||||
|
||||
let response =
|
||||
dispatch_request(&ReqwestHttpClient::default(), &config, protocol, &request).map_err(map_backend_error)?;
|
||||
dispatch_request(&DefaultHttpClient::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_structured_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: LlmStructuredDispatchPayload = serde_json::from_str(&request_json).map_err(map_json_error)?;
|
||||
let request = apply_structured_request_middlewares(payload.request, &payload.middleware)?;
|
||||
|
||||
let response = dispatch_structured_request(&DefaultHttpClient::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_embedding_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 request: EmbeddingRequest = serde_json::from_str(&request_json).map_err(map_json_error)?;
|
||||
|
||||
let response = dispatch_embedding_request(&DefaultHttpClient::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_rerank_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: LlmRerankDispatchPayload = serde_json::from_str(&request_json).map_err(map_json_error)?;
|
||||
|
||||
let response = dispatch_rerank_request(&DefaultHttpClient::default(), &config, protocol, &payload.request)
|
||||
.map_err(map_backend_error)?;
|
||||
|
||||
serde_json::to_string(&response).map_err(map_json_error)
|
||||
}
|
||||
@@ -98,7 +150,7 @@ pub fn llm_dispatch_stream(
|
||||
let mut aborted_by_user = false;
|
||||
let mut callback_dispatch_failed = false;
|
||||
|
||||
let result = dispatch_stream_events_with(&ReqwestHttpClient::default(), &config, protocol, &request, |event| {
|
||||
let result = dispatch_stream_events_with(&DefaultHttpClient::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()));
|
||||
@@ -155,6 +207,27 @@ fn apply_request_middlewares(request: CoreRequest, middleware: &LlmMiddlewarePay
|
||||
Ok(run_request_middleware_chain(request, &middleware.config, &chain))
|
||||
}
|
||||
|
||||
fn apply_structured_request_middlewares(
|
||||
request: StructuredRequest,
|
||||
middleware: &LlmMiddlewarePayload,
|
||||
) -> Result<StructuredRequest> {
|
||||
let mut core = request.as_core_request();
|
||||
core = apply_request_middlewares(core, middleware)?;
|
||||
|
||||
Ok(StructuredRequest {
|
||||
model: core.model,
|
||||
messages: core.messages,
|
||||
schema: core
|
||||
.response_schema
|
||||
.ok_or_else(|| Error::new(Status::InvalidArg, "Structured request schema is required"))?,
|
||||
max_tokens: core.max_tokens,
|
||||
temperature: core.temperature,
|
||||
reasoning: core.reasoning,
|
||||
strict: request.strict,
|
||||
response_mime_type: request.response_mime_type,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StreamPipeline {
|
||||
chain: Vec<StreamMiddleware>,
|
||||
@@ -268,6 +341,7 @@ fn parse_protocol(protocol: &str) -> Result<BackendProtocol> {
|
||||
}
|
||||
"openai_responses" | "openai-responses" | "responses" => Ok(BackendProtocol::OpenaiResponses),
|
||||
"anthropic" | "anthropic_messages" | "anthropic-messages" => Ok(BackendProtocol::AnthropicMessages),
|
||||
"gemini" | "gemini_generate_content" | "gemini-generate-content" => Ok(BackendProtocol::GeminiGenerateContent),
|
||||
other => Err(Error::new(
|
||||
Status::InvalidArg,
|
||||
format!("Unsupported llm backend protocol: {other}"),
|
||||
@@ -293,6 +367,7 @@ mod tests {
|
||||
assert!(parse_protocol("chat-completions").is_ok());
|
||||
assert!(parse_protocol("responses").is_ok());
|
||||
assert!(parse_protocol("anthropic").is_ok());
|
||||
assert!(parse_protocol("gemini").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
"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",
|
||||
@@ -28,15 +25,12 @@
|
||||
"dependencies": {
|
||||
"@affine/s3-compat": "workspace:*",
|
||||
"@affine/server-native": "workspace:*",
|
||||
"@ai-sdk/google": "^2.0.45",
|
||||
"@ai-sdk/google-vertex": "^3.0.88",
|
||||
"@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",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@nestjs-cls/transactional": "^2.7.0",
|
||||
"@nestjs-cls/transactional-adapter-prisma": "^1.2.24",
|
||||
"@nestjs-cls/transactional": "^3.2.0",
|
||||
"@nestjs-cls/transactional-adapter-prisma": "^1.3.4",
|
||||
"@nestjs/apollo": "^13.0.4",
|
||||
"@nestjs/bullmq": "^11.0.4",
|
||||
"@nestjs/common": "^11.0.21",
|
||||
@@ -70,7 +64,6 @@
|
||||
"@queuedash/api": "^3.16.0",
|
||||
"@react-email/components": "^0.5.7",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"ai": "^5.0.118",
|
||||
"bullmq": "^5.40.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cross-env": "^10.1.0",
|
||||
@@ -138,7 +131,7 @@
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/sinon": "^21.0.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"ava": "^6.4.0",
|
||||
"ava": "^7.0.0",
|
||||
"c8": "^10.1.3",
|
||||
"nodemon": "^3.1.14",
|
||||
"react-email": "^4.3.2",
|
||||
|
||||
@@ -118,7 +118,6 @@ 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',
|
||||
@@ -226,6 +225,20 @@ const checkStreamObjects = (result: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseStreamObjects = (result: string): StreamObject[] => {
|
||||
const streamObjects = JSON.parse(result);
|
||||
return z.array(StreamObjectSchema).parse(streamObjects);
|
||||
};
|
||||
|
||||
const getStreamObjectText = (result: string) =>
|
||||
parseStreamObjects(result)
|
||||
.filter(
|
||||
(chunk): chunk is Extract<StreamObject, { type: 'text-delta' }> =>
|
||||
chunk.type === 'text-delta'
|
||||
)
|
||||
.map(chunk => chunk.textDelta)
|
||||
.join('');
|
||||
|
||||
const retry = async (
|
||||
action: string,
|
||||
t: ExecutionContext<Tester>,
|
||||
@@ -445,6 +458,49 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
},
|
||||
type: 'object' as const,
|
||||
},
|
||||
{
|
||||
name: 'Gemini native text',
|
||||
promptName: ['Chat With AFFiNE AI'],
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content:
|
||||
'In one short sentence, explain what AFFiNE AI is and mention AFFiNE by name.',
|
||||
},
|
||||
],
|
||||
config: { model: 'gemini-2.5-flash' },
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(
|
||||
result.toLowerCase().includes('affine'),
|
||||
'should mention AFFiNE'
|
||||
);
|
||||
},
|
||||
prefer: CopilotProviderType.Gemini,
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
name: 'Gemini native stream objects',
|
||||
promptName: ['Chat With AFFiNE AI'],
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content:
|
||||
'Respond with one short sentence about AFFiNE AI and mention AFFiNE by name.',
|
||||
},
|
||||
],
|
||||
config: { model: 'gemini-2.5-flash' },
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
t.truthy(checkStreamObjects(result), 'should be valid stream objects');
|
||||
const assembledText = getStreamObjectText(result);
|
||||
t.assert(
|
||||
assembledText.toLowerCase().includes('affine'),
|
||||
'should mention AFFiNE'
|
||||
);
|
||||
},
|
||||
prefer: CopilotProviderType.Gemini,
|
||||
type: 'object' as const,
|
||||
},
|
||||
{
|
||||
name: 'Should transcribe short audio',
|
||||
promptName: ['Transcript audio'],
|
||||
@@ -717,14 +773,13 @@ for (const {
|
||||
const { factory, prompt: promptService } = t.context;
|
||||
const prompt = (await promptService.get(promptName))!;
|
||||
t.truthy(prompt, 'should have prompt');
|
||||
const provider = (await factory.getProviderByModel(prompt.model, {
|
||||
const finalConfig = Object.assign({}, prompt.config, config);
|
||||
const modelId = finalConfig.model || prompt.model;
|
||||
const provider = (await factory.getProviderByModel(modelId, {
|
||||
prefer,
|
||||
}))!;
|
||||
t.truthy(provider, 'should have provider');
|
||||
await retry(`action: ${promptName}`, t, async t => {
|
||||
const finalConfig = Object.assign({}, prompt.config, config);
|
||||
const modelId = finalConfig.model || prompt.model;
|
||||
|
||||
switch (type) {
|
||||
case 'text': {
|
||||
const result = await provider.text(
|
||||
@@ -892,7 +947,7 @@ test(
|
||||
'should be able to rerank message chunks',
|
||||
runIfCopilotConfigured,
|
||||
async t => {
|
||||
const { factory, prompt } = t.context;
|
||||
const { factory } = t.context;
|
||||
|
||||
await retry('rerank', t, async t => {
|
||||
const query = 'Is this content relevant to programming?';
|
||||
@@ -909,14 +964,18 @@ test(
|
||||
'The stock market is experiencing significant fluctuations.',
|
||||
];
|
||||
|
||||
const p = (await prompt.get('Rerank results'))!;
|
||||
t.assert(p, 'should have prompt for rerank');
|
||||
const provider = (await factory.getProviderByModel(p.model))!;
|
||||
const provider = (await factory.getProviderByModel('gpt-5.2'))!;
|
||||
t.assert(provider, 'should have provider for rerank');
|
||||
|
||||
const scores = await provider.rerank(
|
||||
{ modelId: p.model },
|
||||
embeddings.map(e => p.finish({ query, doc: e }))
|
||||
{ modelId: 'gpt-5.2' },
|
||||
{
|
||||
query,
|
||||
candidates: embeddings.map((text, index) => ({
|
||||
id: String(index),
|
||||
text,
|
||||
})),
|
||||
}
|
||||
);
|
||||
|
||||
t.is(scores.length, 10, 'should return scores for all chunks');
|
||||
@@ -931,8 +990,8 @@ test(
|
||||
t.log('Rerank scores:', scores);
|
||||
t.is(
|
||||
scores.filter(s => s > 0.5).length,
|
||||
4,
|
||||
'should have 4 related chunks'
|
||||
5,
|
||||
'should have 5 related chunks'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CopilotEmbeddingJob,
|
||||
MockEmbeddingClient,
|
||||
} from '../../plugins/copilot/embedding';
|
||||
import { ChatMessageCache } from '../../plugins/copilot/message';
|
||||
import { prompts, PromptService } from '../../plugins/copilot/prompt';
|
||||
import {
|
||||
CopilotProviderFactory,
|
||||
@@ -416,6 +417,7 @@ 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);
|
||||
@@ -463,6 +465,19 @@ 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
|
||||
|
||||
@@ -33,10 +33,7 @@ import {
|
||||
ModelOutputType,
|
||||
OpenAIProvider,
|
||||
} from '../../plugins/copilot/providers';
|
||||
import {
|
||||
CitationParser,
|
||||
TextStreamParser,
|
||||
} from '../../plugins/copilot/providers/utils';
|
||||
import { TextStreamParser } from '../../plugins/copilot/providers/utils';
|
||||
import { ChatSessionService } from '../../plugins/copilot/session';
|
||||
import { CopilotStorage } from '../../plugins/copilot/storage';
|
||||
import { CopilotTranscriptionService } from '../../plugins/copilot/transcript';
|
||||
@@ -660,6 +657,55 @@ test('should be able to generate with message id', async t => {
|
||||
}
|
||||
});
|
||||
|
||||
test('should preserve file handle attachments when merging user content into prompt', async t => {
|
||||
const { prompt, session } = t.context;
|
||||
|
||||
await prompt.set(promptName, 'model', [
|
||||
{ role: 'user', content: '{{content}}' },
|
||||
]);
|
||||
|
||||
const sessionId = await session.create({
|
||||
docId: 'test',
|
||||
workspaceId: 'test',
|
||||
userId,
|
||||
promptName,
|
||||
pinned: false,
|
||||
});
|
||||
const s = (await session.get(sessionId))!;
|
||||
|
||||
const message = await session.createMessage({
|
||||
sessionId,
|
||||
content: 'Summarize this file',
|
||||
attachments: [
|
||||
{
|
||||
kind: 'file_handle',
|
||||
fileHandle: 'file_123',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await s.pushByMessageId(message);
|
||||
const finalMessages = s.finish({});
|
||||
|
||||
t.deepEqual(finalMessages, [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Summarize this file',
|
||||
attachments: [
|
||||
{
|
||||
kind: 'file_handle',
|
||||
fileHandle: 'file_123',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
params: {
|
||||
content: 'Summarize this file',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should save message correctly', async t => {
|
||||
const { prompt, session } = t.context;
|
||||
|
||||
@@ -1225,149 +1271,6 @@ test('should be able to run image executor', async t => {
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
test('CitationParser should replace citation placeholders with URLs', t => {
|
||||
const content =
|
||||
'This is [a] test sentence with [citations [1]] and [[2]] and [3].';
|
||||
const citations = ['https://example1.com', 'https://example2.com'];
|
||||
|
||||
const parser = new CitationParser();
|
||||
for (const citation of citations) {
|
||||
parser.push(citation);
|
||||
}
|
||||
|
||||
const result = parser.parse(content) + parser.end();
|
||||
|
||||
const expected = [
|
||||
'This is [a] test sentence with [citations [^1]] and [^2] and [3].',
|
||||
`[^1]: {"type":"url","url":"${encodeURIComponent(citations[0])}"}`,
|
||||
`[^2]: {"type":"url","url":"${encodeURIComponent(citations[1])}"}`,
|
||||
].join('\n');
|
||||
|
||||
t.is(result, expected);
|
||||
});
|
||||
|
||||
test('CitationParser should replace chunks of citation placeholders with URLs', t => {
|
||||
const contents = [
|
||||
'[[]]',
|
||||
'This is [',
|
||||
'a] test sentence ',
|
||||
'with citations [1',
|
||||
'] and [',
|
||||
'[2]] and [[',
|
||||
'3]] and [[4',
|
||||
']] and [[5]',
|
||||
'] and [[6]]',
|
||||
' and [7',
|
||||
];
|
||||
const citations = [
|
||||
'https://example1.com',
|
||||
'https://example2.com',
|
||||
'https://example3.com',
|
||||
'https://example4.com',
|
||||
'https://example5.com',
|
||||
'https://example6.com',
|
||||
'https://example7.com',
|
||||
];
|
||||
|
||||
const parser = new CitationParser();
|
||||
for (const citation of citations) {
|
||||
parser.push(citation);
|
||||
}
|
||||
|
||||
let result = contents.reduce((acc, current) => {
|
||||
return acc + parser.parse(current);
|
||||
}, '');
|
||||
result += parser.end();
|
||||
|
||||
const expected = [
|
||||
'[[]]This is [a] test sentence with citations [^1] and [^2] and [^3] and [^4] and [^5] and [^6] and [7',
|
||||
`[^1]: {"type":"url","url":"${encodeURIComponent(citations[0])}"}`,
|
||||
`[^2]: {"type":"url","url":"${encodeURIComponent(citations[1])}"}`,
|
||||
`[^3]: {"type":"url","url":"${encodeURIComponent(citations[2])}"}`,
|
||||
`[^4]: {"type":"url","url":"${encodeURIComponent(citations[3])}"}`,
|
||||
`[^5]: {"type":"url","url":"${encodeURIComponent(citations[4])}"}`,
|
||||
`[^6]: {"type":"url","url":"${encodeURIComponent(citations[5])}"}`,
|
||||
`[^7]: {"type":"url","url":"${encodeURIComponent(citations[6])}"}`,
|
||||
].join('\n');
|
||||
t.is(result, expected);
|
||||
});
|
||||
|
||||
test('CitationParser should not replace citation already with URLs', t => {
|
||||
const content =
|
||||
'This is [a] test sentence with citations [1](https://example1.com) and [[2]](https://example2.com) and [[3](https://example3.com)].';
|
||||
const citations = [
|
||||
'https://example4.com',
|
||||
'https://example5.com',
|
||||
'https://example6.com',
|
||||
];
|
||||
|
||||
const parser = new CitationParser();
|
||||
for (const citation of citations) {
|
||||
parser.push(citation);
|
||||
}
|
||||
|
||||
const result = parser.parse(content) + parser.end();
|
||||
|
||||
const expected = [
|
||||
content,
|
||||
`[^1]: {"type":"url","url":"${encodeURIComponent(citations[0])}"}`,
|
||||
`[^2]: {"type":"url","url":"${encodeURIComponent(citations[1])}"}`,
|
||||
`[^3]: {"type":"url","url":"${encodeURIComponent(citations[2])}"}`,
|
||||
].join('\n');
|
||||
t.is(result, expected);
|
||||
});
|
||||
|
||||
test('CitationParser should not replace chunks of citation already with URLs', t => {
|
||||
const contents = [
|
||||
'This is [a] test sentence with citations [1',
|
||||
'](https://example1.com) and [[2]',
|
||||
'](https://example2.com) and [[3](https://example3.com)].',
|
||||
];
|
||||
const citations = [
|
||||
'https://example4.com',
|
||||
'https://example5.com',
|
||||
'https://example6.com',
|
||||
];
|
||||
|
||||
const parser = new CitationParser();
|
||||
for (const citation of citations) {
|
||||
parser.push(citation);
|
||||
}
|
||||
|
||||
let result = contents.reduce((acc, current) => {
|
||||
return acc + parser.parse(current);
|
||||
}, '');
|
||||
result += parser.end();
|
||||
|
||||
const expected = [
|
||||
contents.join(''),
|
||||
`[^1]: {"type":"url","url":"${encodeURIComponent(citations[0])}"}`,
|
||||
`[^2]: {"type":"url","url":"${encodeURIComponent(citations[1])}"}`,
|
||||
`[^3]: {"type":"url","url":"${encodeURIComponent(citations[2])}"}`,
|
||||
].join('\n');
|
||||
t.is(result, expected);
|
||||
});
|
||||
|
||||
test('CitationParser should replace openai style reference chunks', t => {
|
||||
const contents = [
|
||||
'This is [a] test sentence with citations ',
|
||||
'([example1.com](https://example1.com))',
|
||||
];
|
||||
|
||||
const parser = new CitationParser();
|
||||
|
||||
let result = contents.reduce((acc, current) => {
|
||||
return acc + parser.parse(current);
|
||||
}, '');
|
||||
result += parser.end();
|
||||
|
||||
const expected = [
|
||||
contents[0] + '[^1]',
|
||||
`[^1]: {"type":"url","url":"${encodeURIComponent('https://example1.com')}"}`,
|
||||
].join('\n');
|
||||
t.is(result, expected);
|
||||
});
|
||||
|
||||
test('TextStreamParser should format different types of chunks correctly', t => {
|
||||
// Define interfaces for fixtures
|
||||
interface BaseFixture {
|
||||
|
||||
@@ -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-4.1',
|
||||
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-4.1',
|
||||
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-4.1',
|
||||
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-4.1',
|
||||
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-4.1',
|
||||
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-4.1',
|
||||
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"}')
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
||||
import serverNativeModule from '@affine/server-native';
|
||||
import test from 'ava';
|
||||
|
||||
import type { NativeLlmRerankRequest } from '../../native';
|
||||
import { ProviderMiddlewareConfig } from '../../plugins/copilot/config';
|
||||
import {
|
||||
normalizeOpenAIOptionsForModel,
|
||||
OpenAIProvider,
|
||||
} from '../../plugins/copilot/providers/openai';
|
||||
import { CopilotProvider } from '../../plugins/copilot/providers/provider';
|
||||
import {
|
||||
CopilotProviderType,
|
||||
@@ -12,7 +18,7 @@ class TestOpenAIProvider extends CopilotProvider<{ apiKey: string }> {
|
||||
readonly type = CopilotProviderType.OpenAI;
|
||||
readonly models = [
|
||||
{
|
||||
id: 'gpt-4.1',
|
||||
id: 'gpt-5-mini',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text],
|
||||
@@ -36,7 +42,7 @@ class TestOpenAIProvider extends CopilotProvider<{ apiKey: string }> {
|
||||
}
|
||||
|
||||
exposeMetricLabels() {
|
||||
return this.metricLabels('gpt-4.1');
|
||||
return this.metricLabels('gpt-5-mini');
|
||||
}
|
||||
|
||||
exposeMiddleware() {
|
||||
@@ -44,6 +50,33 @@ class TestOpenAIProvider extends CopilotProvider<{ apiKey: string }> {
|
||||
}
|
||||
}
|
||||
|
||||
class NativeRerankProtocolProvider extends OpenAIProvider {
|
||||
override readonly models = [
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Rerank],
|
||||
defaultForOutputType: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
override get config() {
|
||||
return {
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
oldApiStyle: false,
|
||||
};
|
||||
}
|
||||
|
||||
override configured() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function createProvider(profileMiddleware?: ProviderMiddlewareConfig) {
|
||||
const provider = new TestOpenAIProvider();
|
||||
(provider as any).AFFiNEConfig = {
|
||||
@@ -97,3 +130,71 @@ test('getActiveProviderMiddleware should merge defaults with profile override',
|
||||
'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('OpenAI rerank should always use chat-completions native protocol', async t => {
|
||||
const provider = new NativeRerankProtocolProvider();
|
||||
let capturedProtocol: string | undefined;
|
||||
let capturedRequest: NativeLlmRerankRequest | undefined;
|
||||
|
||||
const original = (serverNativeModule as any).llmRerankDispatch;
|
||||
(serverNativeModule as any).llmRerankDispatch = (
|
||||
protocol: string,
|
||||
_backendConfigJson: string,
|
||||
requestJson: string
|
||||
) => {
|
||||
capturedProtocol = protocol;
|
||||
capturedRequest = JSON.parse(requestJson) as NativeLlmRerankRequest;
|
||||
return JSON.stringify({ model: 'gpt-5.2', scores: [0.9, 0.1] });
|
||||
};
|
||||
t.teardown(() => {
|
||||
(serverNativeModule as any).llmRerankDispatch = original;
|
||||
});
|
||||
|
||||
const scores = await provider.rerank(
|
||||
{ modelId: 'gpt-5.2' },
|
||||
{
|
||||
query: 'programming',
|
||||
candidates: [
|
||||
{ id: 'react', text: 'React is a UI library.' },
|
||||
{ id: 'weather', text: 'The weather is sunny today.' },
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
t.deepEqual(scores, [0.9, 0.1]);
|
||||
t.is(capturedProtocol, 'openai_chat');
|
||||
t.deepEqual(capturedRequest, {
|
||||
model: 'gpt-5.2',
|
||||
query: 'programming',
|
||||
candidates: [
|
||||
{ id: 'react', text: 'React is a UI library.' },
|
||||
{ id: 'weather', text: 'The weather is sunny today.' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,11 +88,11 @@ test('resolveModel should support explicit provider prefix and keep slash models
|
||||
|
||||
const prefixed = resolveModel({
|
||||
registry,
|
||||
modelId: 'openai-main/gpt-4.1',
|
||||
modelId: 'openai-main/gpt-5-mini',
|
||||
});
|
||||
t.deepEqual(prefixed, {
|
||||
rawModelId: 'openai-main/gpt-4.1',
|
||||
modelId: 'gpt-4.1',
|
||||
rawModelId: 'openai-main/gpt-5-mini',
|
||||
modelId: 'gpt-5-mini',
|
||||
explicitProviderId: 'openai-main',
|
||||
candidateProviderIds: ['openai-main'],
|
||||
});
|
||||
@@ -154,12 +154,15 @@ test('stripProviderPrefix should only strip matched provider prefix', t => {
|
||||
});
|
||||
|
||||
t.is(
|
||||
stripProviderPrefix(registry, 'openai-main', 'openai-main/gpt-4.1'),
|
||||
'gpt-4.1'
|
||||
stripProviderPrefix(registry, 'openai-main', 'openai-main/gpt-5-mini'),
|
||||
'gpt-5-mini'
|
||||
);
|
||||
t.is(
|
||||
stripProviderPrefix(registry, 'openai-main', 'another-main/gpt-4.1'),
|
||||
'another-main/gpt-4.1'
|
||||
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'
|
||||
);
|
||||
t.is(stripProviderPrefix(registry, 'openai-main', 'gpt-4.1'), 'gpt-4.1');
|
||||
});
|
||||
|
||||
@@ -34,6 +34,56 @@ test('ToolCallAccumulator should merge deltas and complete tool call', t => {
|
||||
id: 'call_1',
|
||||
name: 'doc_read',
|
||||
args: { doc_id: 'a1' },
|
||||
rawArgumentsText: '{"doc_id":"a1"}',
|
||||
thought: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('ToolCallAccumulator should preserve invalid JSON instead of swallowing it', t => {
|
||||
const accumulator = new ToolCallAccumulator();
|
||||
|
||||
accumulator.feedDelta({
|
||||
type: 'tool_call_delta',
|
||||
call_id: 'call_1',
|
||||
name: 'doc_read',
|
||||
arguments_delta: '{"doc_id":',
|
||||
});
|
||||
|
||||
const pending = accumulator.drainPending();
|
||||
|
||||
t.is(pending.length, 1);
|
||||
t.deepEqual(pending[0]?.id, 'call_1');
|
||||
t.deepEqual(pending[0]?.name, 'doc_read');
|
||||
t.deepEqual(pending[0]?.args, {});
|
||||
t.is(pending[0]?.rawArgumentsText, '{"doc_id":');
|
||||
t.truthy(pending[0]?.argumentParseError);
|
||||
});
|
||||
|
||||
test('ToolCallAccumulator should prefer native canonical tool arguments metadata', t => {
|
||||
const accumulator = new ToolCallAccumulator();
|
||||
|
||||
accumulator.feedDelta({
|
||||
type: 'tool_call_delta',
|
||||
call_id: 'call_1',
|
||||
name: 'doc_read',
|
||||
arguments_delta: '{"stale":true}',
|
||||
});
|
||||
|
||||
const completed = accumulator.complete({
|
||||
type: 'tool_call',
|
||||
call_id: 'call_1',
|
||||
name: 'doc_read',
|
||||
arguments: {},
|
||||
arguments_text: '{"doc_id":"a1"}',
|
||||
arguments_error: 'invalid json',
|
||||
});
|
||||
|
||||
t.deepEqual(completed, {
|
||||
id: 'call_1',
|
||||
name: 'doc_read',
|
||||
args: {},
|
||||
rawArgumentsText: '{"doc_id":"a1"}',
|
||||
argumentParseError: 'invalid json',
|
||||
thought: undefined,
|
||||
});
|
||||
});
|
||||
@@ -71,6 +121,8 @@ test('ToolSchemaExtractor should convert zod schema to json schema', t => {
|
||||
|
||||
test('ToolCallLoop should execute tool call and continue to next round', async t => {
|
||||
const dispatchRequests: NativeLlmRequest[] = [];
|
||||
const originalMessages = [{ role: 'user', content: 'read doc' }] as const;
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
const dispatch = (request: NativeLlmRequest) => {
|
||||
dispatchRequests.push(request);
|
||||
@@ -100,13 +152,17 @@ test('ToolCallLoop should execute tool call and continue to next round', async t
|
||||
};
|
||||
|
||||
let executedArgs: Record<string, unknown> | null = null;
|
||||
let executedMessages: unknown;
|
||||
let executedSignal: AbortSignal | undefined;
|
||||
const loop = new ToolCallLoop(
|
||||
dispatch,
|
||||
{
|
||||
doc_read: {
|
||||
inputSchema: z.object({ doc_id: z.string() }),
|
||||
execute: async args => {
|
||||
execute: async (args, options) => {
|
||||
executedArgs = args;
|
||||
executedMessages = options.messages;
|
||||
executedSignal = options.signal;
|
||||
return { markdown: '# doc' };
|
||||
},
|
||||
},
|
||||
@@ -114,21 +170,119 @@ test('ToolCallLoop should execute tool call and continue to next round', async t
|
||||
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' }] },
|
||||
],
|
||||
},
|
||||
signal,
|
||||
[...originalMessages]
|
||||
)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
t.deepEqual(executedArgs, { doc_id: 'a1' });
|
||||
t.deepEqual(executedMessages, originalMessages);
|
||||
t.is(executedSignal, signal);
|
||||
t.true(
|
||||
dispatchRequests[1]?.messages.some(message => message.role === 'tool')
|
||||
);
|
||||
t.deepEqual(dispatchRequests[1]?.messages[1]?.content, [
|
||||
{
|
||||
type: 'tool_call',
|
||||
call_id: 'call_1',
|
||||
name: 'doc_read',
|
||||
arguments: { doc_id: 'a1' },
|
||||
arguments_text: '{"doc_id":"a1"}',
|
||||
arguments_error: undefined,
|
||||
thought: undefined,
|
||||
},
|
||||
]);
|
||||
t.deepEqual(dispatchRequests[1]?.messages[2]?.content, [
|
||||
{
|
||||
type: 'tool_result',
|
||||
call_id: 'call_1',
|
||||
name: 'doc_read',
|
||||
arguments: { doc_id: 'a1' },
|
||||
arguments_text: '{"doc_id":"a1"}',
|
||||
arguments_error: undefined,
|
||||
output: { markdown: '# doc' },
|
||||
is_error: undefined,
|
||||
},
|
||||
]);
|
||||
t.deepEqual(
|
||||
events.map(event => event.type),
|
||||
['tool_call', 'tool_result', 'text_delta', 'done']
|
||||
);
|
||||
});
|
||||
|
||||
test('ToolCallLoop should surface invalid JSON as tool error without executing', async t => {
|
||||
let executed = false;
|
||||
let round = 0;
|
||||
const loop = new ToolCallLoop(
|
||||
request => {
|
||||
round += 1;
|
||||
const hasToolResult = request.messages.some(
|
||||
message => message.role === 'tool'
|
||||
);
|
||||
return (async function* (): AsyncIterableIterator<NativeLlmStreamEvent> {
|
||||
if (!hasToolResult && round === 1) {
|
||||
yield {
|
||||
type: 'tool_call_delta',
|
||||
call_id: 'call_1',
|
||||
name: 'doc_read',
|
||||
arguments_delta: '{"doc_id":',
|
||||
};
|
||||
yield { type: 'done', finish_reason: 'tool_calls' };
|
||||
return;
|
||||
}
|
||||
|
||||
yield { type: 'done', finish_reason: 'stop' };
|
||||
})();
|
||||
},
|
||||
{
|
||||
doc_read: {
|
||||
inputSchema: z.object({ doc_id: z.string() }),
|
||||
execute: async () => {
|
||||
executed = true;
|
||||
return { markdown: '# doc' };
|
||||
},
|
||||
},
|
||||
},
|
||||
2
|
||||
);
|
||||
|
||||
const events: NativeLlmStreamEvent[] = [];
|
||||
for await (const event of loop.run({
|
||||
model: 'gpt-4.1',
|
||||
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']
|
||||
);
|
||||
t.false(executed);
|
||||
t.true(events[0]?.type === 'tool_result');
|
||||
t.deepEqual(events[0], {
|
||||
type: 'tool_result',
|
||||
call_id: 'call_1',
|
||||
name: 'doc_read',
|
||||
arguments: {},
|
||||
arguments_text: '{"doc_id":',
|
||||
arguments_error:
|
||||
events[0]?.type === 'tool_result' ? events[0].arguments_error : undefined,
|
||||
output: {
|
||||
message: 'Invalid tool arguments JSON',
|
||||
rawArguments: '{"doc_id":',
|
||||
error:
|
||||
events[0]?.type === 'tool_result'
|
||||
? events[0].arguments_error
|
||||
: undefined,
|
||||
},
|
||||
is_error: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import test from 'ava';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
chatToGPTMessage,
|
||||
CitationFootnoteFormatter,
|
||||
CitationParser,
|
||||
StreamPatternParser,
|
||||
} from '../../plugins/copilot/providers/utils';
|
||||
import { CitationFootnoteFormatter } from '../../plugins/copilot/providers/utils';
|
||||
|
||||
test('CitationFootnoteFormatter should format sorted footnotes from citation events', t => {
|
||||
const formatter = new CitationFootnoteFormatter();
|
||||
@@ -50,67 +44,3 @@ test('CitationFootnoteFormatter should overwrite duplicated index with latest ur
|
||||
'[^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,6 +9,16 @@ 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.␊
|
||||
␊
|
||||
␊
|
||||
@@ -70,35 +80,9 @@ 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
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { normalizeSMTPHeloHostname } from '../core/mail/utils';
|
||||
import { Renderers } from '../mails';
|
||||
import { TEST_DOC, TEST_USER } from '../mails/common';
|
||||
|
||||
@@ -21,3 +22,22 @@ test('should render mention email with empty doc title', async t => {
|
||||
});
|
||||
t.snapshot(content.html, content.subject);
|
||||
});
|
||||
|
||||
test('should normalize valid SMTP HELO hostnames', t => {
|
||||
t.is(normalizeSMTPHeloHostname('mail.example.com'), 'mail.example.com');
|
||||
t.is(normalizeSMTPHeloHostname(' localhost '), 'localhost');
|
||||
t.is(normalizeSMTPHeloHostname('[127.0.0.1]'), '[127.0.0.1]');
|
||||
t.is(normalizeSMTPHeloHostname('[IPv6:2001:db8::1]'), '[IPv6:2001:db8::1]');
|
||||
});
|
||||
|
||||
test('should reject invalid SMTP HELO hostnames', t => {
|
||||
t.is(normalizeSMTPHeloHostname(''), undefined);
|
||||
t.is(normalizeSMTPHeloHostname(' '), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('AFFiNE Server'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('-example.com'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('example-.com'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('example..com'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('[bad host]'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('[foo]'), undefined);
|
||||
t.is(normalizeSMTPHeloHostname('[IPv6:foo]'), undefined);
|
||||
});
|
||||
|
||||
@@ -33,39 +33,12 @@ export class MockCopilotProvider extends OpenAIProvider {
|
||||
id: 'test-image',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text],
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Image],
|
||||
defaultForOutputType: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-2024-08-06',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1-2025-04-14',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5',
|
||||
capabilities: [
|
||||
@@ -97,6 +70,19 @@ export class MockCopilotProvider extends OpenAIProvider {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-nano',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [
|
||||
ModelOutputType.Text,
|
||||
ModelOutputType.Object,
|
||||
ModelOutputType.Structured,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-image-1',
|
||||
capabilities: [
|
||||
@@ -133,6 +119,23 @@ export class MockCopilotProvider extends OpenAIProvider {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gemini-3.1-pro-preview',
|
||||
capabilities: [
|
||||
{
|
||||
input: [
|
||||
ModelInputType.Text,
|
||||
ModelInputType.Image,
|
||||
ModelInputType.Audio,
|
||||
],
|
||||
output: [
|
||||
ModelOutputType.Text,
|
||||
ModelOutputType.Object,
|
||||
ModelOutputType.Structured,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
override async text(
|
||||
|
||||
@@ -4,9 +4,9 @@ import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import {
|
||||
createBmp,
|
||||
createTestingApp,
|
||||
getPublicUserById,
|
||||
smallestGif,
|
||||
smallestPng,
|
||||
TestingApp,
|
||||
updateAvatar,
|
||||
@@ -40,7 +40,10 @@ test('should be able to upload user avatar', async t => {
|
||||
|
||||
const avatarRes = await app.GET(new URL(avatarUrl).pathname);
|
||||
|
||||
t.deepEqual(avatarRes.body, avatar);
|
||||
t.true(avatarRes.headers['content-type'].startsWith('image/webp'));
|
||||
t.notDeepEqual(avatarRes.body, avatar);
|
||||
t.is(avatarRes.body.subarray(0, 4).toString('ascii'), 'RIFF');
|
||||
t.is(avatarRes.body.subarray(8, 12).toString('ascii'), 'WEBP');
|
||||
});
|
||||
|
||||
test('should be able to update user avatar, and invalidate old avatar url', async t => {
|
||||
@@ -54,9 +57,7 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn
|
||||
|
||||
const oldAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
const newAvatar = await fetch(smallestGif)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
const newAvatar = createBmp(32, 32);
|
||||
res = await updateAvatar(app, newAvatar);
|
||||
const newAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
@@ -66,7 +67,46 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn
|
||||
t.is(avatarRes.status, 404);
|
||||
|
||||
const newAvatarRes = await app.GET(new URL(newAvatarUrl).pathname);
|
||||
t.deepEqual(newAvatarRes.body, newAvatar);
|
||||
t.true(newAvatarRes.headers['content-type'].startsWith('image/webp'));
|
||||
t.notDeepEqual(newAvatarRes.body, newAvatar);
|
||||
t.is(newAvatarRes.body.subarray(0, 4).toString('ascii'), 'RIFF');
|
||||
t.is(newAvatarRes.body.subarray(8, 12).toString('ascii'), 'WEBP');
|
||||
});
|
||||
|
||||
test('should accept avatar uploads up to 5MB after conversion', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signup();
|
||||
const avatar = createBmp(1024, 1024);
|
||||
t.true(avatar.length > 500 * 1024);
|
||||
t.true(avatar.length < 5 * 1024 * 1024);
|
||||
|
||||
const res = await updateAvatar(app, avatar, {
|
||||
filename: 'large.bmp',
|
||||
contentType: 'image/bmp',
|
||||
});
|
||||
|
||||
t.is(res.status, 200);
|
||||
const avatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
const avatarRes = await app.GET(new URL(avatarUrl).pathname);
|
||||
|
||||
t.true(avatarRes.headers['content-type'].startsWith('image/webp'));
|
||||
});
|
||||
|
||||
test('should reject unsupported vector avatars', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signup();
|
||||
const avatar = Buffer.from(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>'
|
||||
);
|
||||
const res = await updateAvatar(app, avatar, {
|
||||
filename: 'avatar.svg',
|
||||
contentType: 'image/svg+xml',
|
||||
});
|
||||
|
||||
t.is(res.status, 200);
|
||||
t.is(res.body.errors[0].message, 'Image format not supported: image/svg+xml');
|
||||
});
|
||||
|
||||
test('should be able to get public user by id', async t => {
|
||||
|
||||
@@ -7,6 +7,35 @@ export const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
export const smallestGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=';
|
||||
|
||||
export function createBmp(width: number, height: number) {
|
||||
const rowSize = Math.ceil((width * 3) / 4) * 4;
|
||||
const pixelDataSize = rowSize * height;
|
||||
const fileSize = 54 + pixelDataSize;
|
||||
const buffer = Buffer.alloc(fileSize);
|
||||
|
||||
buffer.write('BM', 0, 'ascii');
|
||||
buffer.writeUInt32LE(fileSize, 2);
|
||||
buffer.writeUInt32LE(54, 10);
|
||||
buffer.writeUInt32LE(40, 14);
|
||||
buffer.writeInt32LE(width, 18);
|
||||
buffer.writeInt32LE(height, 22);
|
||||
buffer.writeUInt16LE(1, 26);
|
||||
buffer.writeUInt16LE(24, 28);
|
||||
buffer.writeUInt32LE(pixelDataSize, 34);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const rowOffset = 54 + y * rowSize;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const pixelOffset = rowOffset + x * 3;
|
||||
buffer[pixelOffset] = 0x33;
|
||||
buffer[pixelOffset + 1] = 0x66;
|
||||
buffer[pixelOffset + 2] = 0x99;
|
||||
}
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function listBlobs(
|
||||
app: TestingApp,
|
||||
workspaceId: string
|
||||
|
||||
@@ -121,7 +121,11 @@ export async function deleteAccount(app: TestingApp) {
|
||||
return res.deleteAccount.success;
|
||||
}
|
||||
|
||||
export async function updateAvatar(app: TestingApp, avatar: Buffer) {
|
||||
export async function updateAvatar(
|
||||
app: TestingApp,
|
||||
avatar: Buffer,
|
||||
options: { filename?: string; contentType?: string } = {}
|
||||
) {
|
||||
return app
|
||||
.POST('/graphql')
|
||||
.field(
|
||||
@@ -138,7 +142,7 @@ export async function updateAvatar(app: TestingApp, avatar: Buffer) {
|
||||
)
|
||||
.field('map', JSON.stringify({ '0': ['variables.avatar'] }))
|
||||
.attach('0', avatar, {
|
||||
filename: 'test.png',
|
||||
contentType: 'image/png',
|
||||
filename: options.filename || 'test.png',
|
||||
contentType: options.contentType || 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -301,6 +301,11 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
},
|
||||
|
||||
// Input errors
|
||||
image_format_not_supported: {
|
||||
type: 'invalid_input',
|
||||
args: { format: 'string' },
|
||||
message: ({ format }) => `Image format not supported: ${format}`,
|
||||
},
|
||||
query_too_long: {
|
||||
type: 'invalid_input',
|
||||
args: { max: 'number' },
|
||||
|
||||
@@ -82,6 +82,16 @@ export class EmailServiceNotConfigured extends UserFriendlyError {
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class ImageFormatNotSupportedDataType {
|
||||
@Field() format!: string
|
||||
}
|
||||
|
||||
export class ImageFormatNotSupported extends UserFriendlyError {
|
||||
constructor(args: ImageFormatNotSupportedDataType, message?: string | ((args: ImageFormatNotSupportedDataType) => string)) {
|
||||
super('invalid_input', 'image_format_not_supported', message, args);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class QueryTooLongDataType {
|
||||
@Field() max!: number
|
||||
}
|
||||
@@ -1155,6 +1165,7 @@ export enum ErrorNames {
|
||||
SSRF_BLOCKED_ERROR,
|
||||
RESPONSE_TOO_LARGE_ERROR,
|
||||
EMAIL_SERVICE_NOT_CONFIGURED,
|
||||
IMAGE_FORMAT_NOT_SUPPORTED,
|
||||
QUERY_TOO_LONG,
|
||||
VALIDATION_ERROR,
|
||||
USER_NOT_FOUND,
|
||||
@@ -1297,5 +1308,5 @@ registerEnumType(ErrorNames, {
|
||||
export const ErrorDataUnionType = createUnionType({
|
||||
name: 'ErrorDataUnion',
|
||||
types: () =>
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, SsrfBlockedErrorDataType, ResponseTooLargeErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CalendarProviderRequestErrorDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, SsrfBlockedErrorDataType, ResponseTooLargeErrorDataType, ImageFormatNotSupportedDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CalendarProviderRequestErrorDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
});
|
||||
|
||||
@@ -129,6 +129,8 @@ test('should return markdown content and skip page view when accept is text/mark
|
||||
const markdown = Sinon.stub(docReader, 'getDocMarkdown').resolves({
|
||||
title: 'markdown-doc',
|
||||
markdown: '# markdown-doc',
|
||||
knownUnsupportedBlocks: [],
|
||||
unknownBlocks: [],
|
||||
});
|
||||
const docContent = Sinon.stub(docReader, 'getDocContent');
|
||||
const record = Sinon.stub(
|
||||
|
||||
@@ -402,6 +402,8 @@ test('should get doc markdown in json format', async t => {
|
||||
return {
|
||||
title: 'test title',
|
||||
markdown: 'test markdown',
|
||||
knownUnsupportedBlocks: [],
|
||||
unknownBlocks: [],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -418,6 +420,8 @@ test('should get doc markdown in json format', async t => {
|
||||
.expect({
|
||||
title: 'test title',
|
||||
markdown: 'test markdown',
|
||||
knownUnsupportedBlocks: [],
|
||||
unknownBlocks: [],
|
||||
});
|
||||
t.pass();
|
||||
});
|
||||
|
||||
@@ -9,6 +9,16 @@ 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.␊
|
||||
␊
|
||||
␊
|
||||
@@ -70,33 +80,7 @@ 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: [],
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -9,6 +9,16 @@ 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.␊
|
||||
␊
|
||||
␊
|
||||
@@ -70,33 +80,7 @@ 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: [],
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -32,6 +32,8 @@ export interface WorkspaceDocInfo {
|
||||
export interface DocMarkdown {
|
||||
title: string;
|
||||
markdown: string;
|
||||
knownUnsupportedBlocks: string[];
|
||||
unknownBlocks: string[];
|
||||
}
|
||||
|
||||
export abstract class DocReader {
|
||||
@@ -185,12 +187,27 @@ export class DatabaseDocReader extends DocReader {
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
return parseDocToMarkdownFromDocSnapshot(
|
||||
workspaceId,
|
||||
docId,
|
||||
doc.bin,
|
||||
aiEditable
|
||||
);
|
||||
try {
|
||||
const markdown = parseDocToMarkdownFromDocSnapshot(
|
||||
workspaceId,
|
||||
docId,
|
||||
doc.bin,
|
||||
aiEditable
|
||||
);
|
||||
|
||||
const unknownBlocks = markdown.unknownBlocks ?? [];
|
||||
if (unknownBlocks.length > 0) {
|
||||
this.logger.warn(
|
||||
`Unknown blocks found when parsing markdown for ${workspaceId}/${docId}.`,
|
||||
{ unknownBlocks }
|
||||
);
|
||||
}
|
||||
|
||||
return markdown;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to parse ${workspaceId}/${docId}.`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getDocDiff(
|
||||
|
||||
@@ -31,8 +31,8 @@ declare global {
|
||||
|
||||
defineModuleConfig('mailer', {
|
||||
'SMTP.name': {
|
||||
desc: 'Name of the email server (e.g. your domain name)',
|
||||
default: 'AFFiNE Server',
|
||||
desc: 'Hostname used for SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.',
|
||||
default: '',
|
||||
env: 'MAILER_SERVERNAME',
|
||||
},
|
||||
'SMTP.host': {
|
||||
@@ -72,8 +72,8 @@ defineModuleConfig('mailer', {
|
||||
shape: z.array(z.string()),
|
||||
},
|
||||
'fallbackSMTP.name': {
|
||||
desc: 'Name of the fallback email server (e.g. your domain name)',
|
||||
default: 'AFFiNE Server',
|
||||
desc: 'Hostname used for fallback SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname.',
|
||||
default: '',
|
||||
},
|
||||
'fallbackSMTP.host': {
|
||||
desc: 'Host of the email server (e.g. smtp.gmail.com)',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user