Compare commits

..

2 Commits

Author SHA1 Message Date
yoyoyohamapi 2dc69a3bef feat(core): block diff ui 2025-06-19 11:30:58 +08:00
yoyoyohamapi e8d774a2ad feat(core): markdown-diff & patch apply 2025-06-18 11:12:39 +08:00
269 changed files with 2298 additions and 9511 deletions
+84 -14
View File
@@ -27,11 +27,26 @@ concurrency:
cancel-in-progress: true
jobs:
optimize_ci:
name: Optimize CI
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.check_skip.outputs.skip }}
steps:
- uses: actions/checkout@v4
- name: Graphite CI Optimizer
uses: withgraphite/graphite-ci-action@main
id: check_skip
with:
graphite_token: ${{ secrets.GRAPHITE_CI_OPTIMIZER_TOKEN }}
analyze:
name: Analyze
runs-on: ubuntu-latest
env:
NODE_OPTIONS: --max-old-space-size=14384
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
permissions:
actions: read
contents: read
@@ -65,6 +80,9 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-24.04-arm
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
- name: Run oxlint
@@ -90,6 +108,8 @@ jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-24.04-arm
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
NODE_OPTIONS: --max-old-space-size=14384
steps:
@@ -117,6 +137,8 @@ jobs:
lint-rust:
name: Lint Rust
runs-on: ubuntu-latest
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/build-rust
@@ -137,7 +159,9 @@ jobs:
name: Check Git Status
runs-on: ubuntu-latest
needs:
- optimize_ci
- build-server-native
if: needs.optimize_ci.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -173,6 +197,8 @@ jobs:
check-yarn-binary:
name: Check yarn binary
runs-on: ubuntu-latest
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
- name: Run check
@@ -183,6 +209,8 @@ jobs:
e2e-blocksuite-test:
name: E2E BlockSuite Test
runs-on: ubuntu-latest
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
strategy:
fail-fast: false
matrix:
@@ -214,6 +242,8 @@ jobs:
e2e-blocksuite-cross-browser-test:
name: E2E BlockSuite Cross Browser Test
runs-on: ubuntu-latest
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
strategy:
fail-fast: false
matrix:
@@ -248,6 +278,8 @@ jobs:
e2e-test:
name: E2E Test
runs-on: ubuntu-24.04-arm
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
DISTRIBUTION: web
IN_CI_TEST: true
@@ -280,6 +312,8 @@ jobs:
e2e-mobile-test:
name: E2E Mobile Test
runs-on: ubuntu-latest
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
DISTRIBUTION: mobile
IN_CI_TEST: true
@@ -311,7 +345,9 @@ jobs:
name: Unit Test
runs-on: ubuntu-latest
needs:
- optimize_ci
- build-native
if: needs.optimize_ci.outputs.skip == 'false'
env:
DISTRIBUTION: web
strategy:
@@ -348,6 +384,8 @@ jobs:
build-native:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
strategy:
@@ -390,6 +428,8 @@ jobs:
build-windows-native:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
strategy:
@@ -437,6 +477,8 @@ jobs:
build-server-native:
name: Build Server native
runs-on: ubuntu-latest
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
steps:
@@ -462,6 +504,8 @@ jobs:
build-electron-renderer:
name: Build @affine/electron renderer
runs-on: ubuntu-latest
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -487,7 +531,9 @@ jobs:
name: Native Unit Test
runs-on: ubuntu-latest
needs:
- optimize_ci
- build-native
if: needs.optimize_ci.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -507,7 +553,9 @@ jobs:
name: Server Test
runs-on: ubuntu-latest
needs:
- optimize_ci
- build-server-native
if: needs.optimize_ci.outputs.skip == 'false'
strategy:
fail-fast: false
matrix:
@@ -539,7 +587,7 @@ jobs:
- 1025:1025
- 8025:8025
indexer:
image: manticoresearch/manticore:10.1.0
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -580,7 +628,9 @@ jobs:
name: Server Test with Elasticsearch
runs-on: ubuntu-latest
needs:
- optimize_ci
- build-server-native
if: needs.optimize_ci.outputs.skip == 'false'
strategy:
fail-fast: false
env:
@@ -663,7 +713,9 @@ jobs:
name: Server E2E Test
runs-on: ubuntu-latest
needs:
- optimize_ci
- build-server-native
if: needs.optimize_ci.outputs.skip == 'false'
env:
NODE_ENV: test
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -685,7 +737,7 @@ jobs:
ports:
- 6379:6379
indexer:
image: manticoresearch/manticore:10.1.0
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -721,6 +773,9 @@ jobs:
miri:
name: miri code check
runs-on: ubuntu-latest
needs:
- optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
RUST_BACKTRACE: full
CARGO_TERM_COLOR: always
@@ -734,9 +789,7 @@ jobs:
toolchain: nightly
components: miri
- name: Install latest nextest release
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9.98
uses: taiki-e/install-action@nextest
- name: Miri Code Check
continue-on-error: true
@@ -746,6 +799,9 @@ jobs:
loom:
name: loom thread test
runs-on: ubuntu-latest
needs:
- optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
RUSTFLAGS: --cfg loom
RUST_BACKTRACE: full
@@ -758,9 +814,7 @@ jobs:
with:
toolchain: stable
- name: Install latest nextest release
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9.98
uses: taiki-e/install-action@nextest
- name: Loom Thread Test
run: |
@@ -769,6 +823,9 @@ jobs:
fuzzing:
name: fuzzing
runs-on: ubuntu-latest
needs:
- optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
CARGO_TERM_COLOR: always
steps:
@@ -815,6 +872,9 @@ jobs:
- { target: 'aarch64-apple-darwin', os: 'macos-latest' }
- { target: 'x86_64-pc-windows-msvc', os: 'windows-latest' }
- { target: 'aarch64-pc-windows-msvc', os: 'windows-11-arm' }
needs:
- optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -848,6 +908,8 @@ jobs:
rust-test:
name: Run native tests
runs-on: ubuntu-latest
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
env:
CARGO_TERM_COLOR: always
steps:
@@ -859,9 +921,7 @@ jobs:
no-build: 'true'
- name: Install latest nextest release
uses: taiki-e/install-action@v2
with:
tool: nextest@0.9.98
uses: taiki-e/install-action@nextest
- name: Run tests
run: cargo nextest run --workspace --exclude affine_server_native --features use-as-lib --release --no-fail-fast
@@ -870,7 +930,9 @@ jobs:
name: Server Copilot Api Test
runs-on: ubuntu-latest
needs:
- optimize_ci
- build-server-native
if: needs.optimize_ci.outputs.skip == 'false'
env:
NODE_ENV: test
DISTRIBUTION: web
@@ -898,7 +960,7 @@ jobs:
- 1025:1025
- 8025:8025
indexer:
image: manticoresearch/manticore:10.1.0
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -992,7 +1054,7 @@ jobs:
ports:
- 6379:6379
indexer:
image: manticoresearch/manticore:10.1.0
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -1052,8 +1114,10 @@ jobs:
name: ${{ matrix.tests.name }}
runs-on: ubuntu-latest
needs:
- optimize_ci
- build-server-native
- build-native
if: needs.optimize_ci.outputs.skip == 'false'
env:
DISTRIBUTION: web
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -1112,7 +1176,7 @@ jobs:
- 1025:1025
- 8025:8025
indexer:
image: manticoresearch/manticore:10.1.0
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -1157,8 +1221,10 @@ jobs:
name: Desktop Test (${{ matrix.spec.os }}, ${{ matrix.spec.platform }}, ${{ matrix.spec.arch }}, ${{ matrix.spec.target }}, ${{ matrix.spec.test }})
runs-on: ${{ matrix.spec.os }}
needs:
- optimize_ci
- build-electron-renderer
- build-native
if: needs.optimize_ci.outputs.skip == 'false'
strategy:
fail-fast: false
matrix:
@@ -1253,8 +1319,10 @@ jobs:
name: Desktop bundle check (${{ matrix.spec.os }}, ${{ matrix.spec.platform }}, ${{ matrix.spec.arch }}, ${{ matrix.spec.target }}, ${{ matrix.spec.test }})
runs-on: ${{ matrix.spec.os }}
needs:
- optimize_ci
- build-electron-renderer
- build-native
if: needs.optimize_ci.outputs.skip == 'false'
strategy:
fail-fast: false
matrix:
@@ -1360,6 +1428,8 @@ jobs:
test-build-mobile-app:
uses: ./.github/workflows/release-mobile.yml
needs: optimize_ci
if: needs.optimize_ci.outputs.skip == 'false'
with:
build-type: canary
build-target: development
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
- 1025:1025
- 8025:8025
indexer:
image: manticoresearch/manticore:10.1.0
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
@@ -130,7 +130,7 @@ jobs:
ports:
- 6379:6379
indexer:
image: manticoresearch/manticore:10.1.0
image: manticoresearch/manticore:9.3.2
ports:
- 9308:9308
steps:
Generated
+14 -318
View File
@@ -77,10 +77,8 @@ version = "0.0.0"
dependencies = [
"block2",
"core-foundation",
"coreaudio-rs 0.12.1",
"cpal",
"coreaudio-rs",
"criterion2",
"crossbeam-channel",
"dispatch2",
"libc",
"napi",
@@ -93,8 +91,6 @@ dependencies = [
"symphonia",
"thiserror 2.0.12",
"uuid",
"windows 0.61.1",
"windows-core 0.61.2",
]
[[package]]
@@ -217,28 +213,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.9.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "android-tzdata"
version = "0.1.1"
@@ -721,17 +695,9 @@ version = "1.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7"
dependencies = [
"jobserver",
"libc",
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -887,16 +853,6 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -1023,17 +979,6 @@ dependencies = [
"libm",
]
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-rs"
version = "0.12.1"
@@ -1054,29 +999,6 @@ dependencies = [
"bindgen",
]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"core-foundation-sys",
"coreaudio-rs 0.11.3",
"dasp_sample",
"jni",
"js-sys",
"libc",
"mach2",
"ndk",
"ndk-context",
"oboe",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.54.0",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1163,15 +1085,6 @@ dependencies = [
"walkdir",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -1252,12 +1165,6 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "der"
version = "0.7.10"
@@ -1913,7 +1820,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.57.0",
"windows-core 0.61.2",
]
[[package]]
@@ -2175,38 +2082,6 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jobserver"
version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
dependencies = [
"getrandom 0.3.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.77"
@@ -2284,7 +2159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
dependencies = [
"cfg-if",
"windows-targets 0.48.5",
"windows-targets 0.52.6",
]
[[package]]
@@ -2389,15 +2264,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mach2"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709"
dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -2504,9 +2370,9 @@ dependencies = [
[[package]]
name = "napi"
version = "3.0.0-beta.8"
version = "3.0.0-beta.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c502f122fc89e92c6222810b3144411c6f945da5aa3b713ddfad3bdcae7c9bb4"
checksum = "2a5c343e6e1fb57bf3ea3386638c4affb394ee932708128840a56aaac3d6a8ab"
dependencies = [
"anyhow",
"bitflags 2.9.1",
@@ -2514,23 +2380,21 @@ dependencies = [
"ctor",
"napi-build",
"napi-sys",
"nohash-hasher",
"rustc-hash 2.1.1",
"serde",
"tokio",
]
[[package]]
name = "napi-build"
version = "2.2.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44e0e3177307063d3e7e55b7dd7b648cca9d7f46daa35422c0d98cc2bf48c2c1"
checksum = "03acbfa4f156a32188bfa09b86dc11a431b5725253fc1fc6f6df5bed273382c4"
[[package]]
name = "napi-derive"
version = "3.0.0-beta.8"
version = "3.0.0-beta.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcf1e732a67e934b069d6d527251d6288753a36840572abe132a7aed9e77f0bc"
checksum = "08d23065ee795a4b1a8755fdf4a39c2a229679f01f923a8feea33f045d6d96cb"
dependencies = [
"convert_case 0.8.0",
"ctor",
@@ -2542,9 +2406,9 @@ dependencies = [
[[package]]
name = "napi-derive-backend"
version = "2.0.0-beta.8"
version = "2.0.0-beta.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "462b775ba74791c98989fadc46c4bb2ec53016427be4d420d31c4bbaab34b308"
checksum = "348aaac2c51b5d11cf90cf7670b470c7f4d1607d15c338efd4d3db361003e4f5"
dependencies = [
"convert_case 0.8.0",
"proc-macro2",
@@ -2555,42 +2419,13 @@ dependencies = [
[[package]]
name = "napi-sys"
version = "3.0.0-alpha.3"
version = "3.0.0-alpha.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4401c63f866b42d673a8b213d5662c84a0701b0f6c3acff7e2b9fc439f1675d"
checksum = "b443b980b2258dbaa31b99115e74da6c0866e537278309d566b4672a2f8df516"
dependencies = [
"libloading",
]
[[package]]
name = "ndk"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.9.1",
"jni-sys",
"log",
"ndk-sys",
"num_enum",
"thiserror 1.0.69",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -2609,12 +2444,6 @@ dependencies = [
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "7.1.3"
@@ -2681,17 +2510,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -2810,29 +2628,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni",
"ndk",
"ndk-context",
"num-derive",
"num-traits",
"oboe-sys",
]
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
dependencies = [
"cc",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -5208,19 +5003,6 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.100"
@@ -5334,7 +5116,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -5343,16 +5125,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.57.0"
@@ -5385,16 +5157,6 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.57.0"
@@ -5518,15 +5280,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@@ -5554,21 +5307,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
@@ -5609,12 +5347,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -5627,12 +5359,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
@@ -5645,12 +5371,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
@@ -5669,12 +5389,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
@@ -5687,12 +5401,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
@@ -5705,12 +5413,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
@@ -5723,12 +5425,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
-15
View File
@@ -28,14 +28,12 @@ base64-simd = "0.8"
bitvec = "1.0"
block2 = "0.6"
byteorder = "1.5"
cpal = "0.15"
chrono = "0.4"
clap = { version = "4.4", features = ["derive"] }
core-foundation = "0.10"
coreaudio-rs = "0.12"
criterion = { version = "0.5", features = ["html_reports"] }
criterion2 = { version = "3", default-features = false }
crossbeam-channel = "0.5"
dispatch2 = "0.3"
docx-parser = { git = "https://github.com/toeverything/docx-parser" }
dotenvy = "0.15"
@@ -99,19 +97,6 @@ uniffi = "0.29"
url = { version = "2.5" }
uuid = "1.8"
v_htmlescape = "0.15"
windows = { version = "0.61", features = [
"Win32_Devices_FunctionDiscovery",
"Win32_UI_Shell_PropertiesSystem",
"Win32_Media_Audio",
"Win32_System_Variant",
"Win32_System_Com_StructuredStorage",
"Win32_System_Threading",
"Win32_System_ProcessStatus",
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Diagnostics_ToolHelp",
] }
windows-core = { version = "0.61" }
y-octo = { path = "./packages/common/y-octo/core" }
y-sync = { version = "0.4" }
yrs = "0.23.0"
@@ -62,11 +62,13 @@ const builtinSurfaceToolbarConfig = {
if (!rootModel) return;
const { id: frameId, xywh } = model;
let lastNoteId = rootModel.children.findLast(
note =>
matchModels(note, [NoteBlockModel]) &&
note.props.displayMode !== NoteDisplayMode.EdgelessOnly
)?.id;
let lastNoteId = rootModel.children
.filter(
note =>
matchModels(note, [NoteBlockModel]) &&
note.props.displayMode !== NoteDisplayMode.EdgelessOnly
)
.pop()?.id;
if (!lastNoteId) {
const bounds = Bound.deserialize(xywh);
@@ -14,7 +14,6 @@ export class EdgelessNoteMask extends SignalWatcher(
protected override firstUpdated() {
const maskDOM = this.renderRoot!.querySelector('.affine-note-mask');
const observer = new ResizeObserver(entries => {
if (this.model.store.readonly) return;
for (const entry of entries) {
if (!this.model.props.edgeless.collapse) {
const bound = Bound.deserialize(this.model.xywh);
@@ -28,6 +28,7 @@ import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { repeat } from 'lit/directives/repeat.js';
import { ParagraphBlockConfigExtension } from './paragraph-block-config.js';
import { paragraphBlockStyles } from './styles.js';
@@ -227,6 +228,12 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
}
override renderBlock(): TemplateResult<1> {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
const { type$ } = this.model.props;
const collapsed = this.store.readonly
? this._readonlyCollapsed
@@ -341,6 +348,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
</div>
${children}
${widgets}
</div>
`;
}
@@ -11,7 +11,6 @@ import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { keyed } from 'lit/directives/keyed.js';
import type { ClassInfo } from 'lit-html/directives/class-map.js';
import { ifDefined } from 'lit-html/directives/if-defined.js';
import { MenuFocusable } from './focusable.js';
import type { Menu } from './menu.js';
@@ -22,7 +21,6 @@ export type MenuButtonData = {
class: ClassInfo;
select: (ele: HTMLElement) => void | false;
onHover?: (hover: boolean) => void;
testId?: string;
};
export class MenuButton extends MenuFocusable {
@@ -99,12 +97,7 @@ export class MenuButton extends MenuFocusable {
focused: this.isFocused$.value,
...this.data.class,
});
return html` <div
class="${classString}"
data-testid=${ifDefined(this.data.testId)}
>
${this.data.content()}
</div>`;
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
@@ -164,12 +157,7 @@ export class MobileMenuButton extends MenuFocusable {
focused: this.isFocused$.value,
...this.data.class,
});
return html` <div
class="${classString}"
data-testid=${ifDefined(this.data.testId)}
>
${this.data.content()}
</div>`;
return html` <div class="${classString}">${this.data.content()}</div>`;
}
@property({ attribute: false })
@@ -200,7 +188,6 @@ export const menuButtonItems = {
onHover?: (hover: boolean) => void;
class?: MenuClass;
hide?: () => boolean;
testId?: string;
}) =>
menu => {
if (config.hide?.() || !menu.search(config.name)) {
@@ -222,7 +209,6 @@ export const menuButtonItems = {
'selected-item': config.isSelected ?? false,
...config.class,
},
testId: config.testId,
};
return renderButton(data, menu);
},
@@ -234,7 +220,6 @@ export const menuButtonItems = {
label?: () => TemplateResult;
select: (checked: boolean) => boolean;
class?: ClassInfo;
testId?: string;
}) =>
menu => {
if (!menu.search(config.name)) {
@@ -255,7 +240,6 @@ export const menuButtonItems = {
return false;
},
class: config.class ?? {},
testId: config.testId,
};
return html`${keyed(config.name, renderButton(data, menu))}`;
},
@@ -263,12 +247,10 @@ export const menuButtonItems = {
(config: {
name: string;
on: boolean;
prefix?: TemplateResult;
postfix?: TemplateResult;
label?: () => TemplateResult;
onChange: (on: boolean) => void;
class?: ClassInfo;
testId?: string;
}) =>
menu => {
if (!menu.search(config.name)) {
@@ -280,7 +262,6 @@ export const menuButtonItems = {
const data: MenuButtonData = {
content: () => html`
${config.prefix}
<div class="affine-menu-action-text">
${config.label?.() ?? config.name}
</div>
@@ -295,7 +276,6 @@ export const menuButtonItems = {
return false;
},
class: config.class ?? {},
testId: config.testId,
};
return html`${keyed(config.name, renderButton(data, menu))}`;
},
@@ -23,7 +23,6 @@ export type MenuOptions = {
placeholder?: string;
};
items: MenuConfig[];
testId?: string;
};
// Global menu open listener type
@@ -73,9 +72,6 @@ export class Menu {
? document.createElement('mobile-menu')
: document.createElement('affine-menu');
this.menuElement.menu = this;
if (this.options.testId) {
this.menuElement.dataset.testid = this.options.testId;
}
// Call global menu open listeners
menuOpenListeners.forEach(listener => {
@@ -111,7 +111,6 @@ export class FilterableListComponent<Props = unknown> extends WithDisposable(
if (ev.isComposing) break;
ev.preventDefault();
const item = filteredItems[this._curFocusIndex];
if (!item) return;
this._select(item);
break;
}
@@ -35,7 +35,7 @@ export const selectPropertyModelConfig = selectPropertyType.modelConfig({
const name = oldValue
.split(',')
.map(v => v.trim())
.find(v => v);
.filter(v => v)[0];
if (!name) {
return { value: null, data: data };
}
@@ -40,11 +40,13 @@ export const groupToolbarConfig = {
if (!rootModel) return;
const { id: groupId, xywh } = model;
let lastNoteId = rootModel.children.findLast(
note =>
matchModels(note, [NoteBlockModel]) &&
note.props.displayMode !== NoteDisplayMode.EdgelessOnly
)?.id;
let lastNoteId = rootModel.children
.filter(
note =>
matchModels(note, [NoteBlockModel]) &&
note.props.displayMode !== NoteDisplayMode.EdgelessOnly
)
.pop()?.id;
if (!lastNoteId) {
const bounds = Bound.deserialize(xywh);
+4 -1
View File
@@ -10,6 +10,8 @@
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.12",
@@ -63,7 +65,8 @@
"./theme": "./src/theme/index.ts",
"./styles": "./src/styles/index.ts",
"./services": "./src/services/index.ts",
"./adapters": "./src/adapters/index.ts"
"./adapters": "./src/adapters/index.ts",
"./test-utils": "./src/test-utils/index.ts"
},
"files": [
"src",
@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getFirstBlockCommand', () => {
@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getLastBlockCommand', () => {
@@ -1,13 +1,13 @@
/**
* @vitest-environment happy-dom
*/
import '../../helpers/affine-test-utils';
import '../../../test-utils/affine-test-utils';
import type { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { replaceSelectedTextWithBlocksCommand } from '../../../commands/model-crud/replace-selected-text-with-blocks';
import { affine, block } from '../../helpers/affine-template';
import { affine, block } from '../../../test-utils';
describe('commands/model-crud', () => {
describe('replaceSelectedTextWithBlocksCommand', () => {
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
import { ImageSelection } from '../../../selection';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/selection', () => {
describe('isNothingSelectedCommand', () => {
@@ -1,7 +1,7 @@
import { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { affine } from './affine-template';
import { affine } from '../../test-utils';
describe('helpers/affine-template', () => {
it('should create a basic document structure from template', () => {
@@ -3,7 +3,7 @@ import { SurfaceSelection } from '@blocksuite/std';
import type { GetSelectionCommand } from './types';
export const getSurfaceSelectionCommand: GetSelectionCommand = (ctx, next) => {
const currentSurfaceSelection = ctx.std.selection.find(SurfaceSelection);
const currentSurfaceSelection = ctx.std.selection.filter(SurfaceSelection)[0];
if (!currentSurfaceSelection) return;
next({ currentSurfaceSelection });
@@ -21,7 +21,6 @@ export interface BlockSuiteFlags {
enable_table_virtual_scroll: boolean;
enable_turbo_renderer: boolean;
enable_dom_renderer: boolean;
enable_web_container: boolean;
}
export class FeatureFlagService extends StoreExtension {
@@ -47,7 +46,6 @@ export class FeatureFlagService extends StoreExtension {
enable_table_virtual_scroll: false,
enable_turbo_renderer: false,
enable_dom_renderer: false,
enable_web_container: false,
});
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
@@ -1,29 +1,32 @@
import {
CodeBlockSchemaExtension,
DatabaseBlockSchemaExtension,
ImageBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { getInternalStoreExtensions } from '@blocksuite/affine/extensions/store';
import { StoreExtensionManager } from '@blocksuite/affine-ext-loader';
import { Container } from '@blocksuite/global/di';
import { TextSelection } from '@blocksuite/std';
import { type Block, type Store } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { type Block, type Store, Text } from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { createTestHost } from './create-test-host';
// Extensions array
const extensions = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
ImageBlockSchemaExtension,
DatabaseBlockSchemaExtension,
CodeBlockSchemaExtension,
];
const manager = new StoreExtensionManager(getInternalStoreExtensions());
const extensions = manager.get('store');
// // Extensions array
// const extensions = [
// RootBlockSchemaExtension,
// NoteBlockSchemaExtension,
// ParagraphBlockSchemaExtension,
// ListBlockSchemaExtension,
// ImageBlockSchemaExtension,
// DatabaseBlockSchemaExtension,
// CodeBlockSchemaExtension,
// RootStoreExtension,
// NoteStoreExtension,
// ParagraphStoreExtension,
// ListStoreExtension,
// ImageStoreExtension,
// DatabaseStoreExtension,
// CodeStoreExtension
// ];
// Mapping from tag names to flavours
const tagToFlavour: Record<string, string> = {
@@ -75,8 +78,11 @@ export function affine(strings: TemplateStringsArray, ...values: any[]) {
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('test-doc');
const store = doc.getStore({ extensions });
const container = new Container();
extensions.forEach(extension => {
extension.setup(container);
});
const store = doc.getStore({ extensions, provider: container.provider() });
let selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
@@ -63,10 +63,8 @@ function compareBlocks(
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
return false;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < actual.children.length; i++) {
if (!compareBlocks(actual.children[i], expected.children[i], compareId))
return false;
for (const [i, child] of actual.children.entries()) {
if (!compareBlocks(child, expected.children[i], compareId)) return false;
}
return true;
@@ -240,7 +240,7 @@ export function createTestHost(doc: Store): EditorHost {
std.selection = new MockSelectionStore();
std.command = new CommandManager(std as any);
// @ts-expect-error
// @ts-expect-error dev-only
host.command = std.command;
host.selection = std.selection;
@@ -0,0 +1,3 @@
export * from './affine-template';
export * from './affine-test-utils';
export * from './create-test-host';
@@ -20,7 +20,3 @@
- [Store](classes/Store.md)
- [StoreSlots](interfaces/StoreSlots.md)
## Other
- [Schema](classes/Schema.md)
@@ -1,297 +0,0 @@
[**BlockSuite API Documentation**](../../../README.md)
***
[BlockSuite API Documentation](../../../README.md) / [@blocksuite/store](../README.md) / Schema
# Class: Schema
Represents a schema manager for block flavours and their relationships.
Provides methods to register, validate, and query block schemas.
## Properties
### flavourSchemaMap
> `readonly` **flavourSchemaMap**: `Map`\<`string`, \{ `model`: \{ `children?`: `string`[]; `flavour`: `string`; `isFlatData?`: `boolean`; `parent?`: `string`[]; `props?`: (...`args`) => `Record`\<`string`, `any`\>; `role`: `string`; `toModel?`: (...`args`) => `BlockModel`\<`object`\>; \}; `transformer?`: (...`args`) => `BaseBlockTransformer`\<`object`\>; `version`: `number`; \}\>
A map storing block flavour names to their corresponding schema definitions.
## Accessors
### versions
#### Get Signature
> **get** **versions**(): `object`
Returns an object mapping each registered flavour to its version number.
##### Returns
`object`
## Methods
### get()
> **get**(`flavour`): `undefined` \| \{ `model`: \{ `children?`: `string`[]; `flavour`: `string`; `isFlatData?`: `boolean`; `parent?`: `string`[]; `props?`: (...`args`) => `Record`\<`string`, `any`\>; `role`: `string`; `toModel?`: (...`args`) => `BlockModel`\<`object`\>; \}; `transformer?`: (...`args`) => `BaseBlockTransformer`\<`object`\>; `version`: `number`; \}
Retrieves the schema for a given block flavour.
#### Parameters
##### flavour
`string`
The block flavour name.
#### Returns
`undefined` \| \{ `model`: \{ `children?`: `string`[]; `flavour`: `string`; `isFlatData?`: `boolean`; `parent?`: `string`[]; `props?`: (...`args`) => `Record`\<`string`, `any`\>; `role`: `string`; `toModel?`: (...`args`) => `BlockModel`\<`object`\>; \}; `transformer?`: (...`args`) => `BaseBlockTransformer`\<`object`\>; `version`: `number`; \}
The corresponding BlockSchemaType or undefined if not found.
***
### isValid()
> **isValid**(`child`, `parent`): `boolean`
Checks if the child flavour is valid under the parent flavour.
#### Parameters
##### child
`string`
The child block flavour name.
##### parent
`string`
The parent block flavour name.
#### Returns
`boolean`
True if the relationship is valid, false otherwise.
***
### register()
> **register**(`blockSchema`): `Schema`
Registers an array of block schemas into the schema manager.
#### Parameters
##### blockSchema
`object`[]
An array of block schema definitions to register.
#### Returns
`Schema`
The Schema instance (for chaining).
***
### safeValidate()
> **safeValidate**(`flavour`, `parentFlavour?`, `childFlavours?`): `boolean`
Safely validates the schema relationship for a given flavour, parent, and children.
Returns true if valid, false otherwise (does not throw).
#### Parameters
##### flavour
`string`
The block flavour to validate.
##### parentFlavour?
`string`
The parent block flavour (optional).
##### childFlavours?
`string`[]
The child block flavours (optional).
#### Returns
`boolean`
True if the schema relationship is valid, false otherwise.
***
### toJSON()
> **toJSON**(): `object`
Serializes the schema map to a plain object for JSON output.
#### Returns
`object`
An object mapping each flavour to its role, parent, and children.
***
### validate()
> **validate**(`flavour`, `parentFlavour?`, `childFlavours?`): `void`
Validates the schema relationship for a given flavour, parent, and children.
Throws SchemaValidateError if invalid.
#### Parameters
##### flavour
`string`
The block flavour to validate.
##### parentFlavour?
`string`
The parent block flavour (optional).
##### childFlavours?
`string`[]
The child block flavours (optional).
#### Returns
`void`
#### Throws
If the schema relationship is invalid.
***
### validateSchema()
> **validateSchema**(`child`, `parent`): `void`
Validates the relationship between a child and parent schema.
Throws if the relationship is invalid.
#### Parameters
##### child
The child block schema.
###### model
\{ `children?`: `string`[]; `flavour`: `string`; `isFlatData?`: `boolean`; `parent?`: `string`[]; `props?`: (...`args`) => `Record`\<`string`, `any`\>; `role`: `string`; `toModel?`: (...`args`) => `BlockModel`\<`object`\>; \} = `...`
###### model.children?
`string`[] = `ContentSchema`
###### model.flavour
`string` = `FlavourSchema`
###### model.isFlatData?
`boolean` = `...`
###### model.parent?
`string`[] = `ParentSchema`
###### model.props?
(...`args`) => `Record`\<`string`, `any`\> = `...`
###### model.role
`string` = `RoleSchema`
###### model.toModel?
(...`args`) => `BlockModel`\<`object`\> = `...`
###### transformer?
(...`args`) => `BaseBlockTransformer`\<`object`\> = `...`
###### version
`number` = `...`
##### parent
The parent block schema.
###### model
\{ `children?`: `string`[]; `flavour`: `string`; `isFlatData?`: `boolean`; `parent?`: `string`[]; `props?`: (...`args`) => `Record`\<`string`, `any`\>; `role`: `string`; `toModel?`: (...`args`) => `BlockModel`\<`object`\>; \} = `...`
###### model.children?
`string`[] = `ContentSchema`
###### model.flavour
`string` = `FlavourSchema`
###### model.isFlatData?
`boolean` = `...`
###### model.parent?
`string`[] = `ParentSchema`
###### model.props?
(...`args`) => `Record`\<`string`, `any`\> = `...`
###### model.role
`string` = `RoleSchema`
###### model.toModel?
(...`args`) => `BlockModel`\<`object`\> = `...`
###### transformer?
(...`args`) => `BaseBlockTransformer`\<`object`\> = `...`
###### version
`number` = `...`
#### Returns
`void`
#### Throws
If the relationship is invalid.
@@ -1035,13 +1035,13 @@ Get the Doc instance for current store.
#### Get Signature
> **get** **schema**(): [`Schema`](Schema.md)
> **get** **schema**(): `Schema`
Get the [Schema](Schema.md) instance of the store.
Get the Schema instance of the store.
##### Returns
[`Schema`](Schema.md)
`Schema`
***
@@ -4,25 +4,9 @@ import { SCHEMA_NOT_FOUND_MESSAGE } from '../consts.js';
import { BlockSchema, type BlockSchemaType } from '../model/index.js';
import { SchemaValidateError } from './error.js';
/**
* Represents a schema manager for block flavours and their relationships.
* Provides methods to register, validate, and query block schemas.
*/
export class Schema {
/**
* A map storing block flavour names to their corresponding schema definitions.
*/
readonly flavourSchemaMap = new Map<string, BlockSchemaType>();
/**
* Safely validates the schema relationship for a given flavour, parent, and children.
* Returns true if valid, false otherwise (does not throw).
*
* @param flavour - The block flavour to validate.
* @param parentFlavour - The parent block flavour (optional).
* @param childFlavours - The child block flavours (optional).
* @returns True if the schema relationship is valid, false otherwise.
*/
safeValidate = (
flavour: string,
parentFlavour?: string,
@@ -36,25 +20,10 @@ export class Schema {
}
};
/**
* Retrieves the schema for a given block flavour.
*
* @param flavour - The block flavour name.
* @returns The corresponding BlockSchemaType or undefined if not found.
*/
get(flavour: string) {
return this.flavourSchemaMap.get(flavour);
}
/**
* Validates the schema relationship for a given flavour, parent, and children.
* Throws SchemaValidateError if invalid.
*
* @param flavour - The block flavour to validate.
* @param parentFlavour - The parent block flavour (optional).
* @param childFlavours - The child block flavours (optional).
* @throws {SchemaValidateError} If the schema relationship is invalid.
*/
validate = (
flavour: string,
parentFlavour?: string,
@@ -102,9 +71,6 @@ export class Schema {
validateChildren();
};
/**
* Returns an object mapping each registered flavour to its version number.
*/
get versions() {
return Object.fromEntries(
Array.from(this.flavourSchemaMap.values()).map(
@@ -113,13 +79,6 @@ export class Schema {
);
}
/**
* Checks if two flavours match, using minimatch for wildcard support.
*
* @param childFlavour - The child block flavour.
* @param parentFlavour - The parent block flavour.
* @returns True if the flavours match, false otherwise.
*/
private _matchFlavour(childFlavour: string, parentFlavour: string) {
return (
minimatch(childFlavour, parentFlavour) ||
@@ -127,15 +86,6 @@ export class Schema {
);
}
/**
* Checks if two values match as either flavours or roles, supporting role syntax (e.g., '@role').
*
* @param childValue - The child value (flavour or role).
* @param parentValue - The parent value (flavour or role).
* @param childRole - The actual role of the child.
* @param parentRole - The actual role of the parent.
* @returns True if the values match as flavours or roles, false otherwise.
*/
private _matchFlavourOrRole(
childValue: string,
parentValue: string,
@@ -162,13 +112,6 @@ export class Schema {
return this._matchFlavour(childValue, parentValue);
}
/**
* Validates if the parent schema is a valid parent for the child schema.
*
* @param child - The child block schema.
* @param parent - The parent block schema.
* @returns True if the parent is valid for the child, false otherwise.
*/
private _validateParent(
child: BlockSchemaType,
parent: BlockSchemaType
@@ -226,14 +169,6 @@ export class Schema {
});
}
/**
* Validates the role relationship between child and parent schemas.
* Throws if the child is a root block but has a parent.
*
* @param child - The child block schema.
* @param parent - The parent block schema.
* @throws {SchemaValidateError} If the child is a root block with a parent.
*/
private _validateRole(child: BlockSchemaType, parent: BlockSchemaType) {
const childRole = child.model.role;
const childFlavour = child.model.flavour;
@@ -247,13 +182,6 @@ export class Schema {
}
}
/**
* Checks if the child flavour is valid under the parent flavour.
*
* @param child - The child block flavour name.
* @param parent - The parent block flavour name.
* @returns True if the relationship is valid, false otherwise.
*/
isValid(child: string, parent: string) {
const childSchema = this.flavourSchemaMap.get(child);
const parentSchema = this.flavourSchemaMap.get(parent);
@@ -268,12 +196,6 @@ export class Schema {
}
}
/**
* Registers an array of block schemas into the schema manager.
*
* @param blockSchema - An array of block schema definitions to register.
* @returns The Schema instance (for chaining).
*/
register(blockSchema: BlockSchemaType[]) {
blockSchema.forEach(schema => {
BlockSchema.parse(schema);
@@ -282,11 +204,6 @@ export class Schema {
return this;
}
/**
* Serializes the schema map to a plain object for JSON output.
*
* @returns An object mapping each flavour to its role, parent, and children.
*/
toJSON() {
return Object.fromEntries(
Array.from(this.flavourSchemaMap.values()).map(
@@ -302,14 +219,6 @@ export class Schema {
);
}
/**
* Validates the relationship between a child and parent schema.
* Throws if the relationship is invalid.
*
* @param child - The child block schema.
* @param parent - The parent block schema.
* @throws {SchemaValidateError} If the relationship is invalid.
*/
validateSchema(child: BlockSchemaType, parent: BlockSchemaType) {
this._validateRole(child, parent);
+2 -2
View File
@@ -78,11 +78,11 @@
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-sonarjs": "^3.0.1",
"eslint-plugin-unicorn": "^59.0.0",
"happy-dom": "^18.0.0",
"happy-dom": "^17.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.0.0",
"msw": "^2.6.8",
"oxlint": "^1.1.0",
"oxlint": "0.16.11",
"prettier": "^3.4.2",
"semver": "^7.6.3",
"serve": "^14.2.4",
+1 -1
View File
@@ -32,7 +32,7 @@
"build:debug": "napi build"
},
"devDependencies": {
"@napi-rs/cli": "3.0.0-alpha.89",
"@napi-rs/cli": "3.0.0-alpha.81",
"lib0": "^0.2.99",
"tiktoken": "^1.0.17",
"tinybench": "^4.0.0",
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "ai_sessions_messages" ADD COLUMN "streamObjects" JSON;
+1 -1
View File
@@ -147,7 +147,7 @@
"c8": "^10.1.3",
"nodemon": "^3.1.7",
"react-email": "4.0.11",
"sinon": "^21.0.0",
"sinon": "^20.0.0",
"supertest": "^7.0.0",
"why-is-node-running": "^3.2.2"
},
+8 -9
View File
@@ -414,15 +414,14 @@ model AiPrompt {
}
model AiSessionMessage {
id String @id @default(uuid()) @db.VarChar
sessionId String @map("session_id") @db.VarChar
role AiPromptRole
content String @db.Text
streamObjects Json? @db.Json
attachments Json? @db.Json
params Json? @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
id String @id @default(uuid()) @db.VarChar
sessionId String @map("session_id") @db.VarChar
role AiPromptRole
content String @db.Text
attachments Json? @db.Json
params Json? @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@ -1,6 +1,5 @@
import type { ExecutionContext, TestFn } from 'ava';
import ava from 'ava';
import { z } from 'zod';
import { ServerFeature, ServerService } from '../core';
import { AuthService } from '../core/auth';
@@ -10,8 +9,6 @@ import { prompts, PromptService } from '../plugins/copilot/prompt';
import {
CopilotProviderFactory,
CopilotProviderType,
StreamObject,
StreamObjectSchema,
} from '../plugins/copilot/providers';
import { TranscriptionResponseSchema } from '../plugins/copilot/transcript/types';
import {
@@ -186,16 +183,6 @@ const checkUrl = (url: string) => {
}
};
const checkStreamObjects = (result: string) => {
try {
const streamObjects = JSON.parse(result);
z.array(StreamObjectSchema).parse(streamObjects);
return true;
} catch {
return false;
}
};
const retry = async (
action: string,
t: ExecutionContext<Tester>,
@@ -400,20 +387,6 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
type: 'text' as const,
},
{
name: 'stream objects',
promptName: ['Chat With AFFiNE AI'],
messages: [
{
role: 'user' as const,
content: 'what is AFFiNE AI',
},
],
verifier: (t: ExecutionContext<Tester>, result: string) => {
t.truthy(checkStreamObjects(result), 'should be valid stream objects');
},
type: 'object' as const,
},
{
name: 'Should transcribe short audio',
promptName: ['Transcript audio'],
@@ -707,27 +680,6 @@ for (const {
verifier?.(t, result);
break;
}
case 'object': {
const streamObjects: StreamObject[] = [];
for await (const chunk of provider.streamObject(
{ modelId: prompt.model },
[
...prompt.finish(
messages.reduce(
(acc, m) => Object.assign(acc, (m as any).params || {}),
{}
)
),
...messages,
],
finalConfig
)) {
streamObjects.push(chunk);
}
t.truthy(streamObjects, 'should return result');
verifier?.(t, JSON.stringify(streamObjects));
break;
}
case 'image': {
const finalMessage = [...messages];
const params = {};
@@ -39,7 +39,6 @@ import {
array2sse,
audioTranscription,
chatWithImages,
chatWithStreamObject,
chatWithText,
chatWithTextStream,
chatWithWorkflow,
@@ -513,28 +512,6 @@ test('should be able to chat with api', async t => {
);
}
{
const sessionId = await createCopilotSession(
app,
id,
randomUUID(),
textPromptName
);
const messageId = await createCopilotMessage(app, sessionId);
const ret4 = await chatWithStreamObject(app, sessionId, messageId);
const objects = Array.from('generate text to object stream').map(data =>
JSON.stringify({ type: 'text-delta', textDelta: data })
);
t.is(
ret4,
textToEventStream(objects, messageId),
'should be able to chat with stream object'
);
}
Sinon.restore();
});
@@ -1,118 +0,0 @@
# Snapshot report for `src/__tests__/e2e/doc-service/controller.spec.ts`
The actual snapshot is saved in `controller.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should get doc markdown success
> Snapshot 1
{
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.␊
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
|For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|<span data-affine-option data-value="0jh9gNw4Yl" data-option-color="var(--affine-tag-orange)">Developers</span>|␊
|Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Trello with their Kanban|Trello with their Kanban|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}
## should get doc markdown return null when doc not exists
> Snapshot 1
{
code: 'Not Found',
message: 'Doc not found',
name: 'NOT_FOUND',
status: 404,
type: 'RESOURCE_NOT_FOUND',
}
@@ -1,42 +0,0 @@
import { randomUUID } from 'node:crypto';
import { CryptoHelper } from '../../../base';
import { app, e2e, Mockers } from '../test';
const crypto = app.get(CryptoHelper);
e2e('should get doc markdown success', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const res = await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docSnapshot.id}/markdown`)
.set('x-access-token', crypto.sign(docSnapshot.id))
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
t.snapshot(res.body);
});
e2e('should get doc markdown return null when doc not exists', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner,
});
const docId = randomUUID();
const res = await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/markdown`)
.set('x-access-token', crypto.sign(docId))
.expect(404)
.expect('Content-Type', 'application/json; charset=utf-8');
t.snapshot(res.body);
});
@@ -9,7 +9,6 @@ import {
ModelInputType,
ModelOutputType,
PromptMessage,
StreamObject,
} from '../../plugins/copilot/providers';
import {
DEFAULT_DIMENSIONS,
@@ -24,7 +23,7 @@ export class MockCopilotProvider extends OpenAIProvider {
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
defaultForOutputType: true,
},
],
@@ -44,7 +43,7 @@ export class MockCopilotProvider extends OpenAIProvider {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -53,7 +52,7 @@ export class MockCopilotProvider extends OpenAIProvider {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -62,7 +61,7 @@ export class MockCopilotProvider extends OpenAIProvider {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -71,7 +70,7 @@ export class MockCopilotProvider extends OpenAIProvider {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -80,11 +79,7 @@ export class MockCopilotProvider extends OpenAIProvider {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
},
],
},
@@ -99,15 +94,11 @@ export class MockCopilotProvider extends OpenAIProvider {
],
},
{
id: 'gemini-2.5-flash',
id: 'gemini-2.5-flash-preview-05-20',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
},
],
},
@@ -204,24 +195,4 @@ export class MockCopilotProvider extends OpenAIProvider {
await sleep(100);
return [Array.from(randomBytes(options.dimensions)).map(v => v % 128)];
}
override async *streamObject(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<StreamObject> {
const fullCond = { ...cond, outputType: ModelOutputType.Object };
await this.checkParams({ messages, cond: fullCond, options });
// make some time gap for history test case
await sleep(100);
const result = 'generate text to object stream';
for (const data of result) {
yield { type: 'text-delta', textDelta: data } as const;
if (options.signal?.aborted) {
break;
}
}
}
}
@@ -12,7 +12,6 @@ export type MockDocSnapshotInput = {
docId?: string;
blob?: Uint8Array;
updatedAt?: Date;
snapshotFile?: string;
};
export type MockedDocSnapshot = Snapshot;
@@ -24,10 +23,7 @@ export class MockDocSnapshot extends Mocker<
override async create(input: MockDocSnapshotInput) {
if (!input.blob) {
const snapshot = await readFile(
path.join(
import.meta.dirname,
`../__fixtures__/${input.snapshotFile ?? 'test-doc.snapshot.bin'}`
)
path.join(import.meta.dirname, '../__fixtures__/test-doc.snapshot.bin')
);
input.blob = snapshot;
}
@@ -582,14 +582,6 @@ export async function chatWithImages(
return chatWithText(app, sessionId, messageId, '/images');
}
export async function chatWithStreamObject(
app: TestingApp,
sessionId: string,
messageId?: string
) {
return chatWithText(app, sessionId, messageId, '/stream-object');
}
export async function unsplashSearch(
app: TestingApp,
params: Record<string, string> = {}
@@ -20,12 +20,6 @@ export class SocketIoAdapter extends IoAdapter {
const server: Server = super.createIOServer(port, {
...config,
...options,
// Enable CORS for Socket.IO
cors: {
origin: true, // Allow all origins
credentials: true, // Allow credentials (cookies, auth headers)
methods: ['GET', 'POST'],
},
});
if (config.canActivate) {
@@ -175,7 +175,6 @@ test('should get doc content in json format', async t => {
await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content`)
.set('x-access-token', t.context.crypto.sign(docId))
.expect('Content-Type', 'application/json; charset=utf-8')
.expect({
title: 'test title',
summary: 'test summary',
@@ -185,7 +184,6 @@ test('should get doc content in json format', async t => {
await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content?full=false`)
.set('x-access-token', t.context.crypto.sign(docId))
.expect('Content-Type', 'application/json; charset=utf-8')
.expect({
title: 'test title',
summary: 'test summary',
@@ -207,7 +205,6 @@ test('should get full doc content in json format', async t => {
await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content?full=true`)
.set('x-access-token', t.context.crypto.sign(docId))
.expect('Content-Type', 'application/json; charset=utf-8')
.expect({
title: 'test title',
summary: 'test summary full',
@@ -254,44 +251,3 @@ test('should get workspace content in json format', async t => {
});
t.pass();
});
test('should get doc markdown in json format', async t => {
const { app } = t.context;
mock.method(t.context.databaseDocReader, 'getDocMarkdown', async () => {
return {
title: 'test title',
markdown: 'test markdown',
};
});
const docId = randomUUID();
await app
.GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/markdown`)
.set('x-access-token', t.context.crypto.sign(docId))
.expect('Content-Type', 'application/json; charset=utf-8')
.expect(200)
.expect({
title: 'test title',
markdown: 'test markdown',
});
t.pass();
});
test('should 404 when doc markdown not found', async t => {
const { app } = t.context;
const workspaceId = '123';
const docId = '123';
await app
.GET(`/rpc/workspaces/${workspaceId}/docs/${docId}/markdown`)
.set('x-access-token', t.context.crypto.sign(docId))
.expect({
status: 404,
code: 'Not Found',
type: 'RESOURCE_NOT_FOUND',
name: 'NOT_FOUND',
message: 'Doc not found',
})
.expect(404);
t.pass();
});
@@ -42,20 +42,6 @@ export class DocRpcController {
res.send(doc.bin);
}
@SkipThrottle()
@Internal()
@Get('/workspaces/:workspaceId/docs/:docId/markdown')
async getDocMarkdown(
@Param('workspaceId') workspaceId: string,
@Param('docId') docId: string
) {
const result = await this.docReader.getDocMarkdown(workspaceId, docId);
if (!result) {
throw new NotFound('Doc not found');
}
return result;
}
@SkipThrottle()
@Internal()
@Post('/workspaces/:workspaceId/docs/:docId/diff')
@@ -1,106 +0,0 @@
# Snapshot report for `src/core/doc/__tests__/reader-from-database.spec.ts`
The actual snapshot is saved in `reader-from-database.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should return doc markdown success
> Snapshot 1
{
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.␊
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
|For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|<span data-affine-option data-value="0jh9gNw4Yl" data-option-color="var(--affine-tag-orange)">Developers</span>|␊
|Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Trello with their Kanban|Trello with their Kanban|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}
@@ -1,106 +0,0 @@
# Snapshot report for `src/core/doc/__tests__/reader-from-rpc.spec.ts`
The actual snapshot is saved in `reader-from-rpc.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should return doc markdown success
> Snapshot 1
{
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.␊
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
|For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|<span data-affine-option data-value="0jh9gNw4Yl" data-option-color="var(--affine-tag-orange)">Developers</span>|␊
|Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Trello with their Kanban|Trello with their Kanban|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}
@@ -257,28 +257,3 @@ test('should get workspace content with custom avatar', async t => {
avatarUrl: `http://localhost:3010/api/workspaces/${workspace.id}/blobs/${avatarKey}`,
});
});
test('should return doc markdown success', async t => {
const workspace = await module.create(Mockers.Workspace, {
owner: user,
name: '',
});
const docSnapshot = await module.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user,
});
const result = await docReader.getDocMarkdown(workspace.id, docSnapshot.id);
t.snapshot(result);
});
test('should read markdown return null when doc not exists', async t => {
const workspace = await module.create(Mockers.Workspace, {
owner: user,
name: '',
});
const result = await docReader.getDocMarkdown(workspace.id, randomUUID());
t.is(result, null);
});
@@ -5,24 +5,13 @@ import { User, Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { applyUpdate, Doc as YDoc } from 'yjs';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { createTestingApp, type TestingApp } from '../../../__tests__/utils';
import { UserFriendlyError } from '../../../base';
import { ConfigFactory } from '../../../base/config';
import { Models } from '../../../models';
import {
DatabaseDocReader,
DocReader,
DocStorageModule,
PgWorkspaceDocStorageAdapter,
} from '..';
import { DatabaseDocReader, DocReader, PgWorkspaceDocStorageAdapter } from '..';
import { RpcDocReader } from '../reader';
const module = await createModule({
imports: [DocStorageModule],
});
const test = ava as TestFn<{
models: Models;
app: TestingApp;
@@ -79,12 +68,6 @@ test.afterEach.always(() => {
test.after.always(async t => {
await t.context.app.close();
await t.context.docApp.close();
await module.close();
});
test('should be rpc reader', async t => {
const { docReader } = t.context;
t.true(docReader instanceof RpcDocReader);
});
test('should return null when doc not found', async t => {
@@ -161,6 +144,7 @@ test('should fallback to database doc reader when endpoint network error', async
test('should return doc when found', async t => {
const { docReader } = t.context;
t.true(docReader instanceof RpcDocReader);
const docId = randomUUID();
const timestamp = Date.now();
@@ -375,32 +359,3 @@ test('should return null when workspace bin meta not exists', async t => {
const notExists = await docReader.getWorkspaceContent(randomUUID());
t.is(notExists, null);
});
test('should return doc markdown success', async t => {
const { docReader } = t.context;
const workspace = await module.create(Mockers.Workspace, {
owner: user,
name: '',
});
const docSnapshot = await module.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user,
});
const result = await docReader.getDocMarkdown(workspace.id, docSnapshot.id);
t.snapshot(result);
});
test('should read markdown return null when doc not exists', async t => {
const { docReader } = t.context;
const workspace = await module.create(Mockers.Workspace, {
owner: user,
name: '',
});
const result = await docReader.getDocMarkdown(workspace.id, randomUUID());
t.is(result, null);
});
@@ -18,7 +18,6 @@ import { Models } from '../../models';
import { WorkspaceBlobStorage } from '../storage';
import {
type PageDocContent,
parseDocToMarkdownFromDocSnapshot,
parsePageDoc,
parseWorkspaceDoc,
} from '../utils/blocksuite';
@@ -34,11 +33,6 @@ export interface WorkspaceDocInfo {
avatarUrl?: string;
}
export interface DocMarkdown {
title: string;
markdown: string;
}
export abstract class DocReader {
protected readonly logger = new Logger(DocReader.name);
@@ -65,11 +59,6 @@ export abstract class DocReader {
docId: string
): Promise<DocRecord | null>;
abstract getDocMarkdown(
workspaceId: string,
docId: string
): Promise<DocMarkdown | null>;
abstract getDocDiff(
spaceId: string,
docId: string,
@@ -182,17 +171,6 @@ export class DatabaseDocReader extends DocReader {
return await this.workspace.getDoc(workspaceId, docId);
}
async getDocMarkdown(
workspaceId: string,
docId: string
): Promise<DocMarkdown | null> {
const doc = await this.workspace.getDoc(workspaceId, docId);
if (!doc) {
return null;
}
return parseDocToMarkdownFromDocSnapshot(workspaceId, docId, doc.bin);
}
async getDocDiff(
spaceId: string,
docId: string,
@@ -326,33 +304,6 @@ export class RpcDocReader extends DatabaseDocReader {
}
}
override async getDocMarkdown(
workspaceId: string,
docId: string
): Promise<DocMarkdown | null> {
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}/markdown`;
const accessToken = this.crypto.sign(docId);
try {
const res = await this.fetch(accessToken, url, 'GET');
if (!res) {
return null;
}
return (await res.json()) as DocMarkdown;
} catch (e) {
if (e instanceof UserFriendlyError) {
throw e;
}
const err = e as Error;
// other error
this.logger.error(
`Failed to fetch doc markdown ${url}, fallback to database doc reader`,
err
);
// fallback to database doc reader if the error is not user friendly, like network error
return await super.getDocMarkdown(workspaceId, docId);
}
}
override async getDocDiff(
workspaceId: string,
docId: string,
@@ -646,93 +646,6 @@ Generated by [AVA](https://avajs.dev).
title: 'Write, Draw, Plan all at Once.',
}
## can read blob filename from doc snapshot
> Snapshot 1
{
blocks: [
{
additional: {
displayMode: 'edgeless',
},
blockId: '4YHKIhPzAK',
content: 'index file name',
docId: 'doc-0',
flavour: 'affine:page',
yblock: {
'prop:title': 'index file name',
'sys:children': [
'WypcCGdupE',
'hZ1-cdLW5e',
],
'sys:flavour': 'affine:page',
'sys:id': '4YHKIhPzAK',
'sys:version': 2,
},
},
{
additional: {
displayMode: 'edgeless',
},
blockId: 'WypcCGdupE',
content: [],
docId: 'doc-0',
flavour: 'affine:surface',
parentBlockId: '4YHKIhPzAK',
parentFlavour: 'affine:page',
yblock: {
'prop:elements': {
type: '$blocksuite:internal:native$',
value: {},
},
'sys:children': [],
'sys:flavour': 'affine:surface',
'sys:id': 'WypcCGdupE',
'sys:version': 5,
},
},
{
additional: {
displayMode: 'page',
noteBlockId: 'hZ1-cdLW5e',
},
blob: [
'ldZMrM4PDlsNG4Q4YvCsz623h6TKu4qI9_FpTqIypfw=',
],
blockId: 'tfz1yFZdnn',
content: 'test file name here.txt',
docId: 'doc-0',
flavour: 'affine:attachment',
parentBlockId: 'hZ1-cdLW5e',
parentFlavour: 'affine:note',
yblock: {
'prop:embed': false,
'prop:footnoteIdentifier': null,
'prop:index': 'a0',
'prop:lockedBySelf': false,
'prop:meta:createdAt': 1750036953927,
'prop:meta:createdBy': '46ce597c-098a-4c61-a106-ce79827ec1de',
'prop:meta:updatedAt': 1750036953928,
'prop:meta:updatedBy': '46ce597c-098a-4c61-a106-ce79827ec1de',
'prop:name': 'test file name here.txt',
'prop:rotate': 0,
'prop:size': 3,
'prop:sourceId': 'ldZMrM4PDlsNG4Q4YvCsz623h6TKu4qI9_FpTqIypfw=',
'prop:style': 'horizontalThin',
'prop:type': 'text/plain',
'prop:xywh': '[0,0,0,0]',
'sys:children': [],
'sys:flavour': 'affine:attachment',
'sys:id': 'tfz1yFZdnn',
'sys:version': 1,
},
},
],
summary: '',
title: 'index file name',
}
## can read all blocks from doc snapshot without workspace snapshot
> Snapshot 1
@@ -1366,104 +1279,3 @@ Generated by [AVA](https://avajs.dev).
summary: '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. You own your data, with no compromisesLocal-first & Real-time collaborativeWe love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.Blocks that assemble your next docs, tasks kanban or whiteboardThere is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further. ',
title: 'Write, Draw, Plan all at Once.',
}
## can parse doc to markdown from doc snapshot
> Snapshot 1
{
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.␊
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
|For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|<span data-affine-option data-value="0jh9gNw4Yl" data-option-color="var(--affine-tag-orange)">Developers</span>|␊
|Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Trello with their Kanban|Trello with their Kanban|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}
@@ -5,7 +5,6 @@ import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { Models } from '../../../models';
import {
parseDocToMarkdownFromDocSnapshot,
readAllBlocksFromDocSnapshot,
readAllDocIdsFromWorkspaceSnapshot,
} from '../blocksuite';
@@ -57,23 +56,6 @@ test('can read all blocks from doc snapshot', async t => {
});
});
test('can read blob filename from doc snapshot', async t => {
const docSnapshot = await module.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
snapshotFile: 'test-doc-with-blob.snapshot.bin',
});
const result = await readAllBlocksFromDocSnapshot(
workspace.id,
'doc-0',
docSnapshot.blob
);
// NOTE: avoid snapshot result directly, because it will cause hanging
t.snapshot(JSON.parse(JSON.stringify(result)));
});
test('can read all blocks from doc snapshot without workspace snapshot', async t => {
const doc = await models.doc.get(workspace.id, docSnapshot.id);
t.truthy(doc);
@@ -89,13 +71,3 @@ test('can read all blocks from doc snapshot without workspace snapshot', async t
blocks: result!.blocks.map(block => omit(block, ['yblock'])),
});
});
test('can parse doc to markdown from doc snapshot', async t => {
const result = parseDocToMarkdownFromDocSnapshot(
workspace.id,
docSnapshot.id,
docSnapshot.blob
);
t.snapshot(result);
});
@@ -8,7 +8,6 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports -- import from bundle
import {
parsePageDoc as parseDocToMarkdown,
readAllBlocksFromDoc,
readAllDocIdsFromRootDoc,
} from '@affine/reader/dist';
@@ -197,30 +196,3 @@ export async function readAllBlocksFromDocSnapshot(
maxSummaryLength,
});
}
export function parseDocToMarkdownFromDocSnapshot(
workspaceId: string,
docId: string,
docSnapshot: Uint8Array
) {
const ydoc = new YDoc({
guid: docId,
});
applyUpdate(ydoc, docSnapshot);
const parsed = parseDocToMarkdown({
workspaceId,
doc: ydoc,
buildBlobUrl: (blobId: string) => {
return `/${workspaceId}/blobs/${blobId}`;
},
buildDocUrl: (docId: string) => {
return `/workspace/${workspaceId}/${docId}`;
},
});
return {
title: parsed.title,
markdown: parsed.md,
};
}
@@ -1,5 +1,4 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
Cache,
@@ -16,6 +15,8 @@ import {
Models,
} from '../../../models';
import { type EmbeddingClient, getEmbeddingClient } from '../embedding';
import { PromptService } from '../prompt';
import { CopilotProviderFactory } from '../providers';
import { ContextSession } from './session';
const CONTEXT_SESSION_KEY = 'context-session';
@@ -26,9 +27,10 @@ export class CopilotContextService implements OnApplicationBootstrap {
private client: EmbeddingClient | undefined;
constructor(
private readonly moduleRef: ModuleRef,
private readonly cache: Cache,
private readonly models: Models
private readonly models: Models,
private readonly providerFactory: CopilotProviderFactory,
private readonly prompt: PromptService
) {}
@OnEvent('config.init')
@@ -42,7 +44,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
}
private async setup() {
this.client = await getEmbeddingClient(this.moduleRef);
this.client = await getEmbeddingClient(this.providerFactory, this.prompt);
}
async onApplicationBootstrap() {
@@ -163,7 +165,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
);
if (!fileChunks.length) return [];
return await this.embeddingClient.reRank(content, fileChunks, topK, signal);
return this.embeddingClient.reRank(content, fileChunks, topK, signal);
}
async matchWorkspaceDocs(
@@ -186,48 +188,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
);
if (!workspaceChunks.length) return [];
return await this.embeddingClient.reRank(
content,
workspaceChunks,
topK,
signal
);
}
async matchWorkspaceAll(
workspaceId: string,
content: string,
topK: number = 5,
signal?: AbortSignal,
threshold: number = 0.5
) {
if (!this.embeddingClient) return [];
const embedding = await this.embeddingClient.getEmbedding(content, signal);
if (!embedding) return [];
const [fileChunks, workspaceChunks] = await Promise.all([
this.models.copilotWorkspace.matchFileEmbedding(
workspaceId,
embedding,
topK * 2,
threshold
),
this.models.copilotContext.matchWorkspaceEmbedding(
embedding,
workspaceId,
topK * 2,
threshold
),
]);
if (!fileChunks.length && !workspaceChunks.length) return [];
return await this.embeddingClient.reRank(
content,
[...fileChunks, ...workspaceChunks],
topK,
signal
);
return this.embeddingClient.reRank(content, workspaceChunks, topK, signal);
}
@OnEvent('workspace.doc.embed.failed')
@@ -51,7 +51,6 @@ import {
ModelInputType,
ModelOutputType,
} from './providers';
import { StreamObjectParser } from './providers/utils';
import { ChatSession, ChatSessionService } from './session';
import { CopilotStorage } from './storage';
import { ChatMessage, ChatQuerySchema } from './types';
@@ -190,45 +189,6 @@ export class CopilotController implements BeforeApplicationShutdown {
return merge(source$.pipe(finalize(() => subject$.next(null))), ping$);
}
private async prepareChatSession(
user: CurrentUser,
sessionId: string,
query: Record<string, string | string[]>,
outputType: ModelOutputType
) {
let { messageId, retry, modelId, params } = ChatQuerySchema.parse(query);
const { provider, model } = await this.chooseProvider(
outputType,
user.id,
sessionId,
messageId,
modelId
);
const [latestMessage, session] = await this.appendSessionMessage(
sessionId,
messageId,
retry
);
if (latestMessage) {
params = Object.assign({}, params, latestMessage.params, {
content: latestMessage.content,
attachments: latestMessage.attachments,
});
}
const finalMessage = session.finish(params);
return {
provider,
model,
session,
finalMessage,
};
}
@Get('/chat/:sessionId')
@CallMetric('ai', 'chat', { timer: true })
async chat(
@@ -240,24 +200,40 @@ export class CopilotController implements BeforeApplicationShutdown {
const info: any = { sessionId, params: query };
try {
const { provider, model, session, finalMessage } =
await this.prepareChatSession(
user,
sessionId,
query,
ModelOutputType.Text
);
let { messageId, retry, reasoning, webSearch, modelId, params } =
ChatQuerySchema.parse(query);
const { provider, model } = await this.chooseProvider(
ModelOutputType.Text,
user.id,
sessionId,
messageId,
modelId
);
const [latestMessage, session] = await this.appendSessionMessage(
sessionId,
messageId,
retry
);
info.model = model;
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
metrics.ai.counter('chat_calls').add(1, { model });
const { reasoning, webSearch } = ChatQuerySchema.parse(query);
if (latestMessage) {
params = Object.assign({}, params, latestMessage.params, {
content: latestMessage.content,
attachments: latestMessage.attachments,
});
}
const finalMessage = session.finish(params);
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
const content = await provider.text({ modelId: model }, finalMessage, {
...session.config.promptConfig,
signal: this.getSignal(req),
user: user.id,
workspace: session.config.workspaceId,
reasoning,
webSearch,
});
@@ -292,26 +268,42 @@ export class CopilotController implements BeforeApplicationShutdown {
const info: any = { sessionId, params: query, throwInStream: false };
try {
const { provider, model, session, finalMessage } =
await this.prepareChatSession(
user,
sessionId,
query,
ModelOutputType.Text
);
let { messageId, retry, reasoning, webSearch, modelId, params } =
ChatQuerySchema.parse(query);
const { provider, model } = await this.chooseProvider(
ModelOutputType.Text,
user.id,
sessionId,
messageId,
modelId
);
const [latestMessage, session] = await this.appendSessionMessage(
sessionId,
messageId,
retry
);
info.model = model;
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
metrics.ai.counter('chat_stream_calls').add(1, { model });
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
if (latestMessage) {
params = Object.assign({}, params, latestMessage.params, {
content: latestMessage.content,
attachments: latestMessage.attachments,
});
}
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const finalMessage = session.finish(params);
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
const source$ = from(
provider.streamText({ modelId: model }, finalMessage, {
...session.config.promptConfig,
signal: this.getSignal(req),
user: user.id,
workspace: session.config.workspaceId,
reasoning,
webSearch,
})
@@ -354,83 +346,6 @@ export class CopilotController implements BeforeApplicationShutdown {
}
}
@Sse('/chat/:sessionId/stream-object')
@CallMetric('ai', 'chat_object_stream', { timer: true })
async chatStreamObject(
@CurrentUser() user: CurrentUser,
@Req() req: Request,
@Param('sessionId') sessionId: string,
@Query() query: Record<string, string>
): Promise<Observable<ChatEvent>> {
const info: any = { sessionId, params: query, throwInStream: false };
try {
const { provider, model, session, finalMessage } =
await this.prepareChatSession(
user,
sessionId,
query,
ModelOutputType.Object
);
info.model = model;
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
metrics.ai.counter('chat_object_stream_calls').add(1, { model });
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
const source$ = from(
provider.streamObject({ modelId: model }, finalMessage, {
...session.config.promptConfig,
signal: this.getSignal(req),
user: user.id,
workspace: session.config.workspaceId,
reasoning,
webSearch,
})
).pipe(
connect(shared$ =>
merge(
// actual chat event stream
shared$.pipe(
map(data => ({ type: 'message' as const, id: messageId, data }))
),
// save the generated text to the session
shared$.pipe(
toArray(),
concatMap(values => {
const parser = new StreamObjectParser();
const streamObjects = parser.mergeTextDelta(values);
const content = parser.mergeContent(streamObjects);
session.push({
role: 'assistant',
content,
streamObjects,
createdAt: new Date(),
});
return from(session.save());
}),
mergeMap(() => EMPTY)
)
)
),
catchError(e => {
metrics.ai.counter('chat_object_stream_errors').add(1);
info.throwInStream = true;
return mapSseError(e, info);
}),
finalize(() => {
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value - 1);
})
);
return this.mergePingStream(messageId || '', source$);
} catch (err) {
metrics.ai.counter('chat_object_stream_errors').add(1, info);
return mapSseError(err, info);
}
}
@Sse('/chat/:sessionId/workflow')
@CallMetric('ai', 'chat_workflow', { timer: true })
async chatWorkflow(
@@ -463,7 +378,6 @@ export class CopilotController implements BeforeApplicationShutdown {
...session.config.promptConfig,
signal: this.getSignal(req),
user: user.id,
workspace: session.config.workspaceId,
})
).pipe(
connect(shared$ =>
@@ -586,7 +500,6 @@ export class CopilotController implements BeforeApplicationShutdown {
seed: this.parseNumber(params.seed),
signal: this.getSignal(req),
user: user.id,
workspace: session.config.workspaceId,
}
)
).pipe(
@@ -1,5 +1,4 @@
import { Logger } from '@nestjs/common';
import type { ModuleRef } from '@nestjs/core';
import {
CopilotPromptNotFound,
@@ -194,16 +193,12 @@ class ProductionEmbeddingClient extends EmbeddingClient {
let EMBEDDING_CLIENT: EmbeddingClient | undefined;
export async function getEmbeddingClient(
moduleRef: ModuleRef
providerFactory: CopilotProviderFactory,
prompt: PromptService
): Promise<EmbeddingClient | undefined> {
if (EMBEDDING_CLIENT) {
return EMBEDDING_CLIENT;
}
const providerFactory = moduleRef.get(CopilotProviderFactory, {
strict: false,
});
const prompt = moduleRef.get(PromptService, { strict: false });
const client = new ProductionEmbeddingClient(providerFactory, prompt);
if (await client.configured()) {
EMBEDDING_CLIENT = client;
@@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
AFFiNELogger,
@@ -15,6 +14,8 @@ import {
} from '../../../base';
import { DocReader } from '../../../core/doc';
import { Models } from '../../../models';
import { PromptService } from '../prompt';
import { CopilotProviderFactory } from '../providers';
import { CopilotStorage } from '../storage';
import { readStream } from '../utils';
import { getEmbeddingClient } from './client';
@@ -30,11 +31,12 @@ export class CopilotEmbeddingJob {
private client: EmbeddingClient | undefined;
constructor(
private readonly moduleRef: ModuleRef,
private readonly doc: DocReader,
private readonly event: EventBus,
private readonly logger: AFFiNELogger,
private readonly models: Models,
private readonly providerFactory: CopilotProviderFactory,
private readonly prompt: PromptService,
private readonly queue: JobQueue,
private readonly storage: CopilotStorage
) {
@@ -55,7 +57,7 @@ export class CopilotEmbeddingJob {
this.supportEmbedding =
await this.models.copilotContext.checkEmbeddingAvailable();
if (this.supportEmbedding) {
this.client = await getEmbeddingClient(this.moduleRef);
this.client = await getEmbeddingClient(this.providerFactory, this.prompt);
}
}
@@ -303,7 +303,7 @@ const textActions: Prompt[] = [
{
name: 'Transcript audio',
action: 'Transcript audio',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -539,7 +539,7 @@ A concise paragraph that captures the article's main argument and key conclusion
{
name: 'Explain this code',
action: 'Explain this code',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -590,7 +590,7 @@ A concise paragraph that captures the article's main argument and key conclusion
{
name: 'Translate to',
action: 'Translate',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -714,7 +714,7 @@ You are an assistant helping find actions of meeting summary. Use this format, r
{
name: 'Write an article about this',
action: 'Write an article about this',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -806,7 +806,7 @@ You are an assistant helping find actions of meeting summary. Use this format, r
{
name: 'Write a poem about this',
action: 'Write a poem about this',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -855,7 +855,7 @@ You are an assistant helping find actions of meeting summary. Use this format, r
{
name: 'Write a blog post about this',
action: 'Write a blog post about this',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -906,7 +906,7 @@ You are an assistant helping find actions of meeting summary. Use this format, r
{
name: 'Write outline',
action: 'Write outline',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -980,7 +980,7 @@ You are an assistant helping find actions of meeting summary. Use this format, r
{
name: 'Brainstorm ideas about this',
action: 'Brainstorm ideas about this',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -1074,7 +1074,7 @@ You are an assistant helping find actions of meeting summary. Use this format, r
{
name: 'Improve writing for it',
action: 'Improve writing for it',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -1146,7 +1146,7 @@ The output must be perfect. Adherence to every detail of these instructions is n
{
name: 'Fix spelling for it',
action: 'Fix spelling for it',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -1300,7 +1300,7 @@ If there are items in the content that can be used as to-do tasks, please refer
{
name: 'Create headings',
action: 'Create headings',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -1408,7 +1408,7 @@ When sent new notes, respond ONLY with the contents of the html file.`,
{
name: 'Make it longer',
action: 'Make it longer',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -1433,7 +1433,7 @@ When sent new notes, respond ONLY with the contents of the html file.`,
{
name: 'Make it shorter',
action: 'Make it shorter',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -1458,7 +1458,7 @@ When sent new notes, respond ONLY with the contents of the html file.`,
{
name: 'Continue writing',
action: 'Continue writing',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-flash-preview-05-20',
messages: [
{
role: 'system',
@@ -1654,8 +1654,8 @@ const CHAT_PROMPT: Omit<Prompt, 'name'> = {
'claude-sonnet-4-20250514',
'claude-3-7-sonnet-20250219',
'claude-3-5-sonnet-20241022',
'gemini-2.5-flash',
'gemini-2.5-pro',
'gemini-2.5-flash-preview-05-20',
'gemini-2.5-pro-preview-06-05',
'claude-opus-4@20250514',
'claude-sonnet-4@20250514',
'claude-3-7-sonnet@20250219',
@@ -1791,13 +1791,7 @@ Below is the user's query. Please respond in the user's preferred language witho
},
],
config: {
tools: [
'docRead',
'docEdit',
'docKeywordSearch',
'docSemanticSearch',
'webSearch',
],
tools: ['webSearch'],
},
};
@@ -10,20 +10,15 @@ import {
metrics,
UserFriendlyError,
} from '../../../../base';
import { createExaCrawlTool, createExaSearchTool } from '../../tools';
import { CopilotProvider } from '../provider';
import type {
CopilotChatOptions,
CopilotProviderModel,
ModelConditions,
PromptMessage,
StreamObject,
} from '../types';
import { ModelOutputType } from '../types';
import {
chatToGPTMessage,
StreamObjectParser,
TextStreamParser,
} from '../utils';
import { chatToGPTMessage, TextStreamParser } from '../utils';
export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
private readonly MAX_STEPS = 20;
@@ -73,7 +68,7 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
providerOptions: {
anthropic: this.getAnthropicOptions(options, model.id),
},
tools: await this.getTools(options, model.id),
tools: this.getTools(),
maxSteps: this.MAX_STEPS,
experimental_continueSteps: true,
});
@@ -98,7 +93,21 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
try {
metrics.ai.counter('chat_text_stream_calls').add(1, { model: model.id });
const fullStream = await this.getFullStream(model, messages, options);
const [system, msgs] = await chatToGPTMessage(messages, true, true);
const { fullStream } = streamText({
model: this.instance(model.id),
system,
messages: msgs,
abortSignal: options.signal,
providerOptions: {
anthropic: this.getAnthropicOptions(options, model.id),
},
tools: this.getTools(),
maxSteps: this.MAX_STEPS,
experimental_continueSteps: true,
});
const parser = new TextStreamParser();
for await (const chunk of fullStream) {
const result = parser.parse(chunk);
@@ -114,58 +123,11 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
}
}
override async *streamObject(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<StreamObject> {
const fullCond = { ...cond, outputType: ModelOutputType.Object };
await this.checkParams({ cond: fullCond, messages, options });
const model = this.selectModel(fullCond);
try {
metrics.ai
.counter('chat_object_stream_calls')
.add(1, { model: model.id });
const fullStream = await this.getFullStream(model, messages, options);
const parser = new StreamObjectParser();
for await (const chunk of fullStream) {
const result = parser.parse(chunk);
if (result) {
yield result;
}
if (options.signal?.aborted) {
await fullStream.cancel();
break;
}
}
} catch (e: any) {
metrics.ai
.counter('chat_object_stream_errors')
.add(1, { model: model.id });
throw this.handleError(e);
}
}
private async getFullStream(
model: CopilotProviderModel,
messages: PromptMessage[],
options: CopilotChatOptions = {}
) {
const [system, msgs] = await chatToGPTMessage(messages, true, true);
const { fullStream } = streamText({
model: this.instance(model.id),
system,
messages: msgs,
abortSignal: options.signal,
providerOptions: {
anthropic: this.getAnthropicOptions(options, model.id),
},
tools: await this.getTools(options, model.id),
maxSteps: this.MAX_STEPS,
experimental_continueSteps: true,
});
return fullStream;
private getTools() {
return {
web_search_exa: createExaSearchTool(this.AFFiNEConfig),
web_crawl_exa: createExaCrawlTool(this.AFFiNEConfig),
};
}
private getAnthropicOptions(options: CopilotChatOptions, model: string) {
@@ -20,7 +20,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -29,7 +29,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -38,7 +38,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -47,7 +47,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
defaultForOutputType: true,
},
],
@@ -18,7 +18,7 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -27,7 +27,7 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -36,7 +36,7 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -45,7 +45,7 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
defaultForOutputType: true,
},
],
@@ -180,7 +180,7 @@ export class FalProvider extends CopilotProvider<FalConfig> {
? v.attachment
: undefined
)
.find(v => !!v),
.filter(v => !!v)[0],
prompt: content.trim(),
loras: lora.length ? lora : undefined,
controlnets: controlnets.length ? controlnets : undefined,
@@ -21,17 +21,11 @@ import { CopilotProvider } from '../provider';
import type {
CopilotChatOptions,
CopilotImageOptions,
CopilotProviderModel,
ModelConditions,
PromptMessage,
StreamObject,
} from '../types';
import { ModelOutputType } from '../types';
import {
chatToGPTMessage,
StreamObjectParser,
TextStreamParser,
} from '../utils';
import { chatToGPTMessage, TextStreamParser } from '../utils';
export const DEFAULT_DIMENSIONS = 256;
@@ -156,7 +150,21 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
try {
metrics.ai.counter('chat_text_stream_calls').add(1, { model: model.id });
const fullStream = await this.getFullStream(model, messages, options);
const [system, msgs] = await chatToGPTMessage(messages);
const { fullStream } = streamText({
model: this.instance(model.id, {
useSearchGrounding: this.useSearchGrounding(options),
}),
system,
messages: msgs,
abortSignal: options.signal,
maxSteps: this.MAX_STEPS,
providerOptions: {
google: this.getGeminiOptions(options, model.id),
},
});
const parser = new TextStreamParser();
for await (const chunk of fullStream) {
const result = parser.parse(chunk);
@@ -172,60 +180,6 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
}
}
override async *streamObject(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<StreamObject> {
const fullCond = { ...cond, outputType: ModelOutputType.Object };
await this.checkParams({ cond: fullCond, messages, options });
const model = this.selectModel(fullCond);
try {
metrics.ai
.counter('chat_object_stream_calls')
.add(1, { model: model.id });
const fullStream = await this.getFullStream(model, messages, options);
const parser = new StreamObjectParser();
for await (const chunk of fullStream) {
const result = parser.parse(chunk);
if (result) {
yield result;
}
if (options.signal?.aborted) {
await fullStream.cancel();
break;
}
}
} catch (e: any) {
metrics.ai
.counter('chat_object_stream_errors')
.add(1, { model: model.id });
throw this.handleError(e);
}
}
private async getFullStream(
model: CopilotProviderModel,
messages: PromptMessage[],
options: CopilotChatOptions = {}
) {
const [system, msgs] = await chatToGPTMessage(messages);
const { fullStream } = streamText({
model: this.instance(model.id, {
useSearchGrounding: this.useSearchGrounding(options),
}),
system,
messages: msgs,
abortSignal: options.signal,
maxSteps: this.MAX_STEPS,
providerOptions: {
google: this.getGeminiOptions(options, model.id),
},
});
return fullStream;
}
private getGeminiOptions(options: CopilotChatOptions, model: string) {
const result: GoogleGenerativeAIProviderOptions = {};
if (options?.reasoning && this.isReasoningModel(model)) {
@@ -25,18 +25,14 @@ export class GeminiGenerativeProvider extends GeminiProvider<GeminiGenerativeCon
ModelInputType.Image,
ModelInputType.Audio,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
defaultForOutputType: true,
},
],
},
{
name: 'Gemini 2.5 Flash',
id: 'gemini-2.5-flash',
id: 'gemini-2.5-flash-preview-05-20',
capabilities: [
{
input: [
@@ -44,17 +40,13 @@ export class GeminiGenerativeProvider extends GeminiProvider<GeminiGenerativeCon
ModelInputType.Image,
ModelInputType.Audio,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
},
],
},
{
name: 'Gemini 2.5 Pro',
id: 'gemini-2.5-pro',
id: 'gemini-2.5-pro-preview-06-05',
capabilities: [
{
input: [
@@ -62,11 +54,7 @@ export class GeminiGenerativeProvider extends GeminiProvider<GeminiGenerativeCon
ModelInputType.Image,
ModelInputType.Audio,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
},
],
},
@@ -15,7 +15,7 @@ export class GeminiVertexProvider extends GeminiProvider<GeminiVertexConfig> {
readonly models = [
{
name: 'Gemini 2.5 Flash',
id: 'gemini-2.5-flash',
id: 'gemini-2.5-flash-preview-05-20',
capabilities: [
{
input: [
@@ -23,17 +23,13 @@ export class GeminiVertexProvider extends GeminiProvider<GeminiVertexConfig> {
ModelInputType.Image,
ModelInputType.Audio,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
},
],
},
{
name: 'Gemini 2.5 Pro',
id: 'gemini-2.5-pro',
id: 'gemini-2.5-pro-preview-06-05',
capabilities: [
{
input: [
@@ -41,11 +37,7 @@ export class GeminiVertexProvider extends GeminiProvider<GeminiVertexConfig> {
ModelInputType.Image,
ModelInputType.Audio,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
},
],
},
@@ -11,7 +11,7 @@ import {
generateObject,
generateText,
streamText,
Tool,
ToolSet,
} from 'ai';
import { z } from 'zod';
@@ -21,25 +21,18 @@ import {
metrics,
UserFriendlyError,
} from '../../../base';
import { createExaCrawlTool, createExaSearchTool } from '../tools';
import { CopilotProvider } from './provider';
import type {
CopilotChatOptions,
CopilotChatTools,
CopilotEmbeddingOptions,
CopilotImageOptions,
CopilotProviderModel,
CopilotStructuredOptions,
ModelConditions,
PromptMessage,
StreamObject,
} from './types';
import { CopilotProviderType, ModelInputType, ModelOutputType } from './types';
import {
chatToGPTMessage,
CitationParser,
StreamObjectParser,
TextStreamParser,
} from './utils';
import { chatToGPTMessage, CitationParser, TextStreamParser } from './utils';
export const DEFAULT_DIMENSIONS = 256;
@@ -72,7 +65,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -82,7 +75,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -91,7 +84,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -101,7 +94,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -110,11 +103,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
defaultForOutputType: true,
},
],
@@ -124,11 +113,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
},
],
},
@@ -137,11 +122,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
},
],
},
@@ -150,11 +131,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
output: [ModelOutputType.Text, ModelOutputType.Structured],
},
],
},
@@ -163,7 +140,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -172,7 +149,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -181,7 +158,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
output: [ModelOutputType.Text],
},
],
},
@@ -271,14 +248,25 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
}
}
override getProviderSpecificTools(
toolName: CopilotChatTools,
model: string
): [string, Tool] | undefined {
if (toolName === 'webSearch' && !this.isReasoningModel(model)) {
return ['web_search_preview', openai.tools.webSearchPreview()];
private getTools(options: CopilotChatOptions, model: string): ToolSet {
const tools: ToolSet = {};
if (options?.tools?.length) {
for (const tool of options.tools) {
switch (tool) {
case 'webSearch': {
if (this.isReasoningModel(model)) {
tools.web_search_exa = createExaSearchTool(this.AFFiNEConfig);
tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig);
} else {
tools.web_search_preview = openai.tools.webSearchPreview();
}
break;
}
}
}
return tools;
}
return;
return tools;
}
async text(
@@ -309,7 +297,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
providerOptions: {
openai: this.getOpenAIOptions(options, model.id),
},
tools: await this.getTools(options, model.id),
tools: this.getTools(options, model.id),
maxSteps: this.MAX_STEPS,
abortSignal: options.signal,
});
@@ -335,7 +323,26 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
try {
metrics.ai.counter('chat_text_stream_calls').add(1, { model: model.id });
const fullStream = await this.getFullStream(model, messages, options);
const [system, msgs] = await chatToGPTMessage(messages);
const modelInstance = this.#instance.responses(model.id);
const { fullStream } = streamText({
model: modelInstance,
system,
messages: msgs,
frequencyPenalty: options.frequencyPenalty ?? 0,
presencePenalty: options.presencePenalty ?? 0,
temperature: options.temperature ?? 0,
maxTokens: options.maxTokens ?? 4096,
providerOptions: {
openai: this.getOpenAIOptions(options, model.id),
},
tools: this.getTools(options, model.id),
maxSteps: this.MAX_STEPS,
abortSignal: options.signal,
});
const citationParser = new CitationParser();
const textParser = new TextStreamParser();
for await (const chunk of fullStream) {
@@ -367,39 +374,6 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
}
}
override async *streamObject(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<StreamObject> {
const fullCond = { ...cond, outputType: ModelOutputType.Object };
await this.checkParams({ cond: fullCond, messages, options });
const model = this.selectModel(fullCond);
try {
metrics.ai
.counter('chat_object_stream_calls')
.add(1, { model: model.id });
const fullStream = await this.getFullStream(model, messages, options);
const parser = new StreamObjectParser();
for await (const chunk of fullStream) {
const result = parser.parse(chunk);
if (result) {
yield result;
}
if (options.signal?.aborted) {
await fullStream.cancel();
break;
}
}
} catch (e: any) {
metrics.ai
.counter('chat_object_stream_errors')
.add(1, { model: model.id });
throw this.handleError(e, model.id, options);
}
}
override async structure(
cond: ModelConditions,
messages: PromptMessage[],
@@ -440,31 +414,6 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
}
}
private async getFullStream(
model: CopilotProviderModel,
messages: PromptMessage[],
options: CopilotChatOptions = {}
) {
const [system, msgs] = await chatToGPTMessage(messages);
const modelInstance = this.#instance.responses(model.id);
const { fullStream } = streamText({
model: modelInstance,
system,
messages: msgs,
frequencyPenalty: options.frequencyPenalty ?? 0,
presencePenalty: options.presencePenalty ?? 0,
temperature: options.temperature ?? 0,
maxTokens: options.maxTokens ?? 4096,
providerOptions: {
openai: this.getOpenAIOptions(options, model.id),
},
tools: await this.getTools(options, model.id),
maxSteps: this.MAX_STEPS,
abortSignal: options.signal,
});
return fullStream;
}
// ====== text to image ======
private async *generateImageWithAttachments(
model: string,
@@ -1,6 +1,4 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { Tool, ToolSet } from 'ai';
import { z } from 'zod';
import {
@@ -9,18 +7,9 @@ import {
CopilotProviderNotSupported,
OnEvent,
} from '../../../base';
import { AccessController } from '../../../core/permission';
import { CopilotContextService } from '../context';
import {
buildDocSearchGetter,
createDocSemanticSearchTool,
createExaCrawlTool,
createExaSearchTool,
} from '../tools';
import { CopilotProviderFactory } from './factory';
import {
type CopilotChatOptions,
CopilotChatTools,
type CopilotEmbeddingOptions,
type CopilotImageOptions,
CopilotProviderModel,
@@ -33,7 +22,6 @@ import {
ModelInputType,
type PromptMessage,
PromptMessageSchema,
StreamObject,
} from './types';
@Injectable()
@@ -45,7 +33,6 @@ export abstract class CopilotProvider<C = any> {
@Inject() protected readonly AFFiNEConfig!: Config;
@Inject() protected readonly factory!: CopilotProviderFactory;
@Inject() protected readonly moduleRef!: ModuleRef;
get config(): C {
return this.AFFiNEConfig.copilot.providers[this.type] as C;
@@ -111,50 +98,6 @@ export abstract class CopilotProvider<C = any> {
);
}
protected getProviderSpecificTools(
_toolName: CopilotChatTools,
_model: string
): [string, Tool] | undefined {
return;
}
// use for tool use, shared between providers
protected async getTools(
options: CopilotChatOptions,
model: string
): Promise<ToolSet> {
const tools: ToolSet = {};
if (options?.tools?.length) {
for (const tool of options.tools) {
const toolDef = this.getProviderSpecificTools(tool, model);
if (toolDef) {
tools[toolDef[0]] = toolDef[1];
continue;
}
switch (tool) {
case 'docSemanticSearch': {
const ac = this.moduleRef.get(AccessController, { strict: false });
const context = this.moduleRef.get(CopilotContextService, {
strict: false,
});
const searchDocs = buildDocSearchGetter(ac, context);
tools.doc_semantic_search = createDocSemanticSearchTool(
searchDocs.bind(null, options)
);
break;
}
case 'webSearch': {
tools.web_search_exa = createExaSearchTool(this.AFFiNEConfig);
tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig);
break;
}
}
}
return tools;
}
return tools;
}
private handleZodError(ret: z.SafeParseReturnType<any, any>) {
if (ret.success) return;
const issues = ret.error.issues.map(i => {
@@ -226,17 +169,6 @@ export abstract class CopilotProvider<C = any> {
options?: CopilotChatOptions
): AsyncIterable<string>;
streamObject(
_model: ModelConditions,
_messages: PromptMessage[],
_options?: CopilotChatOptions
): AsyncIterable<StreamObject> {
throw new CopilotProviderNotSupported({
provider: this.type,
kind: 'object',
});
}
structure(
_cond: ModelConditions,
_messages: PromptMessage[],
@@ -57,21 +57,7 @@ export const VertexSchema: JSONSchema = {
// ========== prompt ==========
export const PromptConfigStrictSchema = z.object({
tools: z
.enum([
// work with morph
'docEdit',
// work with indexer
'docRead',
'docKeywordSearch',
// work with embeddings
'docSemanticSearch',
// work with exa/model internal tools
'webSearch',
])
.array()
.nullable()
.optional(),
tools: z.enum(['webSearch']).array().nullable().optional(),
// params requirements
requireContent: z.boolean().nullable().optional(),
requireAttachment: z.boolean().nullable().optional(),
@@ -118,33 +104,8 @@ export const ChatMessageAttachment = z.union([
}),
]);
export const StreamObjectSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('text-delta'),
textDelta: z.string(),
}),
z.object({
type: z.literal('reasoning'),
textDelta: z.string(),
}),
z.object({
type: z.literal('tool-call'),
toolCallId: z.string(),
toolName: z.string(),
args: z.record(z.any()),
}),
z.object({
type: z.literal('tool-result'),
toolCallId: z.string(),
toolName: z.string(),
args: z.record(z.any()),
result: z.any(),
}),
]);
export const PureMessageSchema = z.object({
content: z.string(),
streamObjects: z.array(StreamObjectSchema).optional().nullable(),
attachments: z.array(ChatMessageAttachment).optional().nullable(),
params: z.record(z.any()).optional().nullable(),
});
@@ -154,14 +115,12 @@ export const PromptMessageSchema = PureMessageSchema.extend({
}).strict();
export type PromptMessage = z.infer<typeof PromptMessageSchema>;
export type PromptParams = NonNullable<PromptMessage['params']>;
export type StreamObject = z.infer<typeof StreamObjectSchema>;
// ========== options ==========
const CopilotProviderOptionsSchema = z.object({
signal: z.instanceof(AbortSignal).optional(),
user: z.string().optional(),
workspace: z.string().optional(),
});
export const CopilotChatOptionsSchema = CopilotProviderOptionsSchema.merge(
@@ -174,9 +133,6 @@ export const CopilotChatOptionsSchema = CopilotProviderOptionsSchema.merge(
.optional();
export type CopilotChatOptions = z.infer<typeof CopilotChatOptionsSchema>;
export type CopilotChatTools = NonNullable<
NonNullable<CopilotChatOptions>['tools']
>[number];
export const CopilotStructuredOptionsSchema =
CopilotProviderOptionsSchema.merge(PromptConfigStrictSchema).optional();
@@ -213,7 +169,6 @@ export enum ModelInputType {
export enum ModelOutputType {
Text = 'text',
Object = 'object',
Embedding = 'embedding',
Image = 'image',
Structured = 'structured',
@@ -9,12 +9,8 @@ import {
} from 'ai';
import { ZodType } from 'zod';
import {
createDocSemanticSearchTool,
createExaCrawlTool,
createExaSearchTool,
} from '../tools';
import { PromptMessage, StreamObject } from './types';
import { createExaCrawlTool, createExaSearchTool } from '../tools';
import { PromptMessage } from './types';
type ChatMessage = CoreUserMessage | CoreAssistantMessage;
@@ -380,29 +376,12 @@ export class CitationParser {
}
export interface CustomAITools extends ToolSet {
doc_semantic_search: ReturnType<typeof createDocSemanticSearchTool>;
web_search_exa: ReturnType<typeof createExaSearchTool>;
web_crawl_exa: ReturnType<typeof createExaCrawlTool>;
}
type ChunkType = TextStreamPart<CustomAITools>['type'];
export function parseUnknownError(error: unknown) {
if (typeof error === 'string') {
throw new Error(error);
} else if (error instanceof Error) {
throw error;
} else if (
typeof error === 'object' &&
error !== null &&
'message' in error
) {
throw new Error(String(error.message));
} else {
throw new Error(JSON.stringify(error));
}
}
export class TextStreamParser {
private readonly CALLOUT_PREFIX = '\n[!]\n';
@@ -445,12 +424,6 @@ export class TextStreamParser {
case 'tool-result': {
result = this.addPrefix(result);
switch (chunk.toolName) {
case 'doc_semantic_search': {
if (Array.isArray(chunk.result)) {
result += `\nFound ${chunk.result.length} document${chunk.result.length !== 1 ? 's' : ''} related to “${chunk.args.query}”.\n`;
}
break;
}
case 'web_search_exa': {
if (Array.isArray(chunk.result)) {
result += `\n${this.getWebSearchLinks(chunk.result)}\n`;
@@ -462,8 +435,8 @@ export class TextStreamParser {
break;
}
case 'error': {
parseUnknownError(chunk.error);
break;
const error = chunk.error as { type: string; message: string };
throw new Error(error.message);
}
}
this.lastType = chunk.type;
@@ -506,54 +479,3 @@ export class TextStreamParser {
return links;
}
}
export class StreamObjectParser {
public parse(chunk: TextStreamPart<CustomAITools>) {
switch (chunk.type) {
case 'reasoning':
case 'text-delta':
case 'tool-call':
case 'tool-result': {
return chunk;
}
case 'error': {
parseUnknownError(chunk.error);
return null;
}
default: {
return null;
}
}
}
public mergeTextDelta(chunks: StreamObject[]): StreamObject[] {
return chunks.reduce((acc, curr) => {
const prev = acc.at(-1);
switch (curr.type) {
case 'reasoning':
case 'text-delta': {
if (prev && prev.type === curr.type) {
prev.textDelta += curr.textDelta;
} else {
acc.push(curr);
}
break;
}
default: {
acc.push(curr);
break;
}
}
return acc;
}, [] as StreamObject[]);
}
public mergeContent(chunks: StreamObject[]): string {
return chunks.reduce((acc, curr) => {
if (curr.type === 'text-delta') {
acc += curr.textDelta;
}
return acc;
}, '');
}
}
@@ -34,7 +34,7 @@ import { Admin } from '../../core/common';
import { AccessController } from '../../core/permission';
import { UserType } from '../../core/user';
import { PromptService } from './prompt';
import { PromptMessage, StreamObject } from './providers';
import { PromptMessage } from './providers';
import { ChatSessionService } from './session';
import { CopilotStorage } from './storage';
import {
@@ -168,27 +168,6 @@ class QueryChatHistoriesInput implements Partial<ListHistoriesOptions> {
// ================== Return Types ==================
@ObjectType('StreamObject')
class StreamObjectType {
@Field(() => String)
type!: string;
@Field(() => String, { nullable: true })
textDelta?: string;
@Field(() => String, { nullable: true })
toolCallId?: string;
@Field(() => String, { nullable: true })
toolName?: string;
@Field(() => GraphQLJSON, { nullable: true })
args?: any;
@Field(() => GraphQLJSON, { nullable: true })
result?: any;
}
@ObjectType('ChatMessage')
class ChatMessageType implements Partial<ChatMessage> {
// id will be null if message is a prompt message
@@ -201,9 +180,6 @@ class ChatMessageType implements Partial<ChatMessage> {
@Field(() => String)
content!: string;
@Field(() => [StreamObjectType], { nullable: true })
streamObjects!: StreamObject[];
@Field(() => [String], { nullable: true })
attachments!: string[];
@@ -282,7 +282,6 @@ export class ChatSessionService {
await tx.aiSessionMessage.createMany({
data: state.messages.map(m => ({
...m,
streamObjects: m.streamObjects || undefined,
attachments: m.attachments || undefined,
params: omit(m.params, ['docs']) || undefined,
sessionId,
@@ -513,7 +512,6 @@ export class ChatSessionService {
id: true,
role: true,
content: true,
streamObjects: true,
attachments: true,
params: true,
createdAt: true,
@@ -1,54 +0,0 @@
import { tool } from 'ai';
import { z } from 'zod';
import type { AccessController } from '../../../core/permission';
import type { ChunkSimilarity } from '../../../models';
import type { CopilotContextService } from '../context';
import type { CopilotChatOptions } from '../providers';
export const buildDocSearchGetter = (
ac: AccessController,
context: CopilotContextService
) => {
const searchDocs = async (options: CopilotChatOptions, query?: string) => {
if (!options || !query?.trim() || !options.user || !options.workspace) {
return undefined;
}
const canAccess = await ac
.user(options.user)
.workspace(options.workspace)
.can('Workspace.Read');
if (!canAccess) return undefined;
const chunks = await context.matchWorkspaceAll(options.workspace, query);
const docChunks = await ac
.user(options.user)
.workspace(options.workspace)
.docs(
chunks.filter(c => 'docId' in c),
'Doc.Read'
);
const fileChunks = chunks.filter(c => 'fileId' in c);
if (!docChunks.length && !fileChunks.length) return undefined;
return [...fileChunks, ...docChunks];
};
return searchDocs;
};
export const createDocSemanticSearchTool = (
searchDocs: (query: string) => Promise<ChunkSimilarity[] | undefined>
) => {
return tool({
description:
'Semantic search for relevant documents in the current workspace',
parameters: z.object({
query: z.string().describe('The query to search for.'),
}),
execute: async ({ query }) => {
try {
return await searchDocs(query);
} catch {
return 'Failed to search documents.';
}
},
});
};
@@ -1,2 +1 @@
export * from './doc-semantic-search';
export * from './web-search';
@@ -494,30 +494,3 @@ Generated by [AVA](https://avajs.dev).
],
},
]
## should search blob names from doc snapshot work
> Snapshot 1
Map {
'ldZMrM4PDlsNG4Q4YvCsz623h6TKu4qI9_FpTqIypfw=' => 'test file name here.txt',
}
## should search blob names work
> Snapshot 1
[
[
'blob1',
'blob1 name.txt',
],
[
'blob2',
'blob2 name.md',
],
[
'blob3',
'blob3 name.docx',
],
]
@@ -2113,103 +2113,3 @@ test('should index doc work', async t => {
t.is(module.event.count('doc.indexer.updated'), count + 1);
});
// #endregion
// #region searchBlobNames()
test('should search blob names from doc snapshot work', async t => {
const docSnapshot = await module.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user,
snapshotFile: 'test-doc-with-blob.snapshot.bin',
});
await indexerService.indexDoc(workspace.id, docSnapshot.id, {
refresh: true,
});
const blobNameMap = await indexerService.searchBlobNames(workspace.id, [
'ldZMrM4PDlsNG4Q4YvCsz623h6TKu4qI9_FpTqIypfw=',
]);
t.snapshot(blobNameMap);
});
test('should search blob names work', async t => {
const workspaceId = randomUUID();
const blobId1 = 'blob1';
const blobId2 = 'blob2';
const blobId3 = 'blob3';
const blobId4 = 'blob4';
await indexerService.write(
SearchTable.block,
[
{
workspaceId,
blob: blobId1,
content: 'blob1 name.txt',
flavour: 'affine:attachment',
docId: randomUUID(),
blockId: randomUUID(),
createdByUserId: user.id,
updatedByUserId: user.id,
createdAt: new Date(),
updatedAt: new Date(),
},
{
workspaceId,
blob: blobId2,
content: 'blob2 name.md',
flavour: 'affine:attachment',
docId: randomUUID(),
blockId: randomUUID(),
createdByUserId: user.id,
updatedByUserId: user.id,
createdAt: new Date(),
updatedAt: new Date(),
},
{
workspaceId,
blob: blobId3,
content: 'blob3 name.docx',
flavour: 'affine:attachment',
docId: randomUUID(),
blockId: randomUUID(),
createdByUserId: user.id,
updatedByUserId: user.id,
createdAt: new Date(),
updatedAt: new Date(),
},
// no attachment
{
workspaceId,
blob: blobId3,
content: 'mock blob3 content',
flavour: 'affine:page',
docId: randomUUID(),
blockId: randomUUID(),
createdByUserId: user.id,
updatedByUserId: user.id,
createdAt: new Date(),
updatedAt: new Date(),
},
],
{
refresh: true,
}
);
const blobNameMap = await indexerService.searchBlobNames(workspaceId, [
blobId1,
blobId2,
blobId3,
blobId4,
]);
t.is(blobNameMap.size, 3);
t.snapshot(
Array.from(blobNameMap.entries()).sort((a, b) => a[0].localeCompare(b[0]))
);
});
// #endregion
@@ -387,52 +387,6 @@ export class IndexerService {
await searchProvider.deleteByQuery(table, dsl, options);
}
async searchBlobNames(workspaceId: string, blobIds: string[]) {
const result = await this.search({
table: SearchTable.block,
query: {
type: SearchQueryType.boolean,
occur: SearchQueryOccur.must,
queries: [
{
type: SearchQueryType.match,
field: 'workspaceId',
match: workspaceId,
},
{
type: SearchQueryType.match,
field: 'flavour',
match: 'affine:attachment',
},
{
type: SearchQueryType.boolean,
occur: SearchQueryOccur.should,
queries: blobIds.map(blobId => ({
type: SearchQueryType.match,
field: 'blob',
match: blobId,
})),
},
],
},
options: {
fields: ['blob', 'content'],
pagination: {
limit: 10000,
},
},
});
const blobNameMap = new Map<string, string>();
for (const node of result.nodes) {
const blobId = node.fields.blob[0] as string;
const content = node.fields.content[0] as string;
if (blobId && content) {
blobNameMap.set(blobId, content);
}
}
return blobNameMap;
}
#formatSearchNodes(nodes: SearchNode[]) {
return nodes.map(node => ({
...node,
-10
View File
@@ -96,7 +96,6 @@ type ChatMessage {
id: ID
params: JSON
role: String!
streamObjects: [StreamObject!]
}
enum ContextCategories {
@@ -1629,15 +1628,6 @@ type SpaceShouldHaveOnlyOneOwnerDataType {
spaceId: String!
}
type StreamObject {
args: JSON
result: JSON
textDelta: String
toolCallId: String
toolName: String
type: String!
}
type SubscriptionAlreadyExistsDataType {
plan: String!
}
@@ -14,14 +14,6 @@ query getCopilotHistories(
id
role
content
streamObjects {
type
textDelta
toolCallId
toolName
args
result
}
attachments
createdAt
}
+7 -15
View File
@@ -15,7 +15,7 @@ export const passwordLimitsFragment = `fragment PasswordLimits on PasswordLimits
minLength
maxLength
}`;
export const licenseBodyFragment = `fragment licenseBody on License {
export const licenseFragment = `fragment license on License {
expiredAt
installedAt
quantity
@@ -617,14 +617,6 @@ export const getCopilotHistoriesQuery = {
id
role
content
streamObjects {
type
textDelta
toolCallId
toolName
args
result
}
attachments
createdAt
}
@@ -1451,10 +1443,10 @@ export const activateLicenseMutation = {
op: 'activateLicense',
query: `mutation activateLicense($workspaceId: String!, $license: String!) {
activateLicense(workspaceId: $workspaceId, license: $license) {
...licenseBody
...license
}
}
${licenseBodyFragment}`,
${licenseFragment}`,
};
export const deactivateLicenseMutation = {
@@ -1471,11 +1463,11 @@ export const getLicenseQuery = {
query: `query getLicense($workspaceId: String!) {
workspace(id: $workspaceId) {
license {
...licenseBody
...license
}
}
}
${licenseBodyFragment}`,
${licenseFragment}`,
};
export const installLicenseMutation = {
@@ -1483,10 +1475,10 @@ export const installLicenseMutation = {
op: 'installLicense',
query: `mutation installLicense($workspaceId: String!, $license: Upload!) {
installLicense(workspaceId: $workspaceId, license: $license) {
...licenseBody
...license
}
}
${licenseBodyFragment}`,
${licenseFragment}`,
file: true,
};
@@ -1,7 +1,7 @@
#import './license-body.gql'
#import './license.gql'
mutation activateLicense($workspaceId: String!, $license: String!) {
activateLicense(workspaceId: $workspaceId, license: $license) {
...licenseBody
...license
}
}
@@ -1,9 +1,9 @@
#import './license-body.gql'
#import './license.gql'
query getLicense($workspaceId: String!) {
workspace(id: $workspaceId) {
license {
...licenseBody
...license
}
}
}
@@ -1,7 +1,7 @@
#import './license-body.gql'
#import './license.gql'
mutation installLicense($workspaceId: String!, $license: Upload!) {
installLicense(workspaceId: $workspaceId, license: $license) {
...licenseBody
...license
}
}
@@ -1,4 +1,4 @@
fragment licenseBody on License {
fragment license on License {
expiredAt
installedAt
quantity
+1 -21
View File
@@ -137,7 +137,6 @@ export interface ChatMessage {
id: Maybe<Scalars['ID']['output']>;
params: Maybe<Scalars['JSON']['output']>;
role: Scalars['String']['output'];
streamObjects: Maybe<Array<StreamObject>>;
}
export enum ContextCategories {
@@ -2196,16 +2195,6 @@ export interface SpaceShouldHaveOnlyOneOwnerDataType {
spaceId: Scalars['String']['output'];
}
export interface StreamObject {
__typename?: 'StreamObject';
args: Maybe<Scalars['JSON']['output']>;
result: Maybe<Scalars['JSON']['output']>;
textDelta: Maybe<Scalars['String']['output']>;
toolCallId: Maybe<Scalars['String']['output']>;
toolName: Maybe<Scalars['String']['output']>;
type: Scalars['String']['output'];
}
export interface SubscriptionAlreadyExistsDataType {
__typename?: 'SubscriptionAlreadyExistsDataType';
plan: Scalars['String']['output'];
@@ -3385,15 +3374,6 @@ export type GetCopilotHistoriesQuery = {
content: string;
attachments: Array<string> | null;
createdAt: string;
streamObjects: Array<{
__typename?: 'StreamObject';
type: string;
textDelta: string | null;
toolCallId: string | null;
toolName: string | null;
args: Record<string, string> | null;
result: Record<string, string> | null;
}> | null;
}>;
}>;
};
@@ -4421,7 +4401,7 @@ export type InstallLicenseMutation = {
};
};
export type LicenseBodyFragment = {
export type LicenseFragment = {
__typename?: 'License';
expiredAt: string | null;
installedAt: string;
@@ -16,7 +16,6 @@ export type AppSetting = {
autoCheckUpdate: boolean;
autoDownloadUpdate: boolean;
enableTelemetry: boolean;
showLinkedDocInSidebar: boolean;
};
export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [
'frameless',
@@ -34,7 +33,6 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>(
autoCheckUpdate: true,
autoDownloadUpdate: true,
enableTelemetry: true,
showLinkedDocInSidebar: true,
},
undefined,
{
@@ -52,509 +52,6 @@ exports[`should get all docs from root doc work 2`] = `
]
`;
exports[`should parse page doc work 1`] = `
{
"md": "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.
# You own your data, with no compromises
## Local-first & Real-time collaborative
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.
### Blocks that assemble your next docs, tasks kanban or whiteboard
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.
If you want to learn more about the product design of AFFiNE, here goes the concepts:
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.
## A true canvas for blocks in any form
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:
* Quip & Notion with their great concept of "everything is a block"
* Trello with their Kanban
* Airtable & Miro with their no-code programable datasheets
* Miro & Whimiscal with their edgeless visual whiteboard
* Remnote & Capacities with their object-based tag system
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)
## Self Host
Self host AFFiNE
||Title|Tag|
|---|---|---|
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|
|For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|<span data-affine-option data-value="0jh9gNw4Yl" data-option-color="var(--affine-tag-orange)">Developers</span>|
|Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|
|Trello with their Kanban|Trello with their Kanban|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|
|Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||
## Affine Development
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)
",
"parsedBlock": {
"children": [
{
"children": [
{
"children": [],
"content": "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.
",
"flavour": "affine:paragraph",
"id": "FoPQcAyV_m",
"type": "text",
},
{
"children": [],
"content": "
",
"flavour": "affine:paragraph",
"id": "oz48nn_zp8",
"type": "text",
},
{
"children": [],
"content": "# You own your data, with no compromises
",
"flavour": "affine:paragraph",
"id": "g8a-D9-jXS",
"type": "h1",
},
{
"children": [],
"content": "## Local-first & Real-time collaborative
",
"flavour": "affine:paragraph",
"id": "J8lHN1GR_5",
"type": "h2",
},
{
"children": [],
"content": "We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.
",
"flavour": "affine:paragraph",
"id": "xCuWdM0VLz",
"type": "text",
},
{
"children": [],
"content": "AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.
",
"flavour": "affine:paragraph",
"id": "zElMi0tViK",
"type": "text",
},
{
"children": [],
"content": "
",
"flavour": "affine:paragraph",
"id": "Z4rK0OF9Wk",
"type": "text",
},
],
"content": "",
"flavour": "affine:note",
"id": "RX4CG2zsBk",
"type": undefined,
},
{
"children": [
{
"children": [],
"content": "### Blocks that assemble your next docs, tasks kanban or whiteboard
",
"flavour": "affine:paragraph",
"id": "DQ0Ryb-SpW",
"type": "h3",
},
],
"content": "",
"flavour": "affine:note",
"id": "S1mkc8zUoU",
"type": undefined,
},
{
"children": [
{
"children": [],
"content": "There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.
",
"flavour": "affine:paragraph",
"id": "HAZC3URZp_",
"type": "text",
},
{
"children": [],
"content": "We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.
",
"flavour": "affine:paragraph",
"id": "0H87ypiuv8",
"type": "text",
},
{
"children": [],
"content": "If you want to learn more about the product design of AFFiNE, here goes the concepts:
",
"flavour": "affine:paragraph",
"id": "Sp4G1KD0Wn",
"type": "text",
},
{
"children": [],
"content": "To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.
",
"flavour": "affine:paragraph",
"id": "RsUhDuEqXa",
"type": "text",
},
],
"content": "",
"flavour": "affine:note",
"id": "yGlBdshAqN",
"type": undefined,
},
{
"children": [
{
"children": [],
"content": "## A true canvas for blocks in any form
",
"flavour": "affine:paragraph",
"id": "Z2HibKzAr-",
"type": "h2",
},
{
"children": [],
"content": "[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.
",
"flavour": "affine:paragraph",
"id": "UwvWddamzM",
"type": "text",
},
{
"children": [],
"content": "
",
"flavour": "affine:paragraph",
"id": "g9xKUjhJj1",
"type": "text",
},
{
"children": [],
"content": ""We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:
",
"flavour": "affine:paragraph",
"id": "wDTn4YJ4pm",
"type": "text",
},
{
"children": [],
"content": "* Quip & Notion with their great concept of "everything is a block"
",
"flavour": "affine:list",
"id": "xFrrdiP3-V",
"type": "bulleted",
},
{
"children": [],
"content": "* Trello with their Kanban
",
"flavour": "affine:list",
"id": "Tp9xyN4Okl",
"type": "bulleted",
},
{
"children": [],
"content": "* Airtable & Miro with their no-code programable datasheets
",
"flavour": "affine:list",
"id": "K_4hUzKZFQ",
"type": "bulleted",
},
{
"children": [],
"content": "* Miro & Whimiscal with their edgeless visual whiteboard
",
"flavour": "affine:list",
"id": "QwMzON2s7x",
"type": "bulleted",
},
{
"children": [],
"content": "* Remnote & Capacities with their object-based tag system
",
"flavour": "affine:list",
"id": "FFVmit6u1T",
"type": "bulleted",
},
],
"content": "",
"flavour": "affine:note",
"id": "6lDiuDqZGL",
"type": undefined,
},
{
"children": [
{
"children": [],
"content": "For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)
",
"flavour": "affine:paragraph",
"id": "YqnG5O6AE6",
"type": "text",
},
{
"children": [],
"content": "## Self Host
",
"flavour": "affine:paragraph",
"id": "sbDTmZMZcq",
"type": "h2",
},
{
"children": [],
"content": "Self host AFFiNE
",
"flavour": "affine:paragraph",
"id": "QVvitesfbj",
"type": "text",
},
],
"content": "",
"flavour": "affine:note",
"id": "cauvaHOQmh",
"type": undefined,
},
{
"children": [
{
"children": [],
"content": "||Title|Tag|
|---|---|---|
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|
|For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|<span data-affine-option data-value="0jh9gNw4Yl" data-option-color="var(--affine-tag-orange)">Developers</span>|
|Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|
|Trello with their Kanban|Trello with their Kanban|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|
|Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||
",
"flavour": "affine:database",
"id": "U_GoHFD9At",
"rows": [
{
"Tag": "<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>",
"Title": "Affine Development
",
"undefined": "Affine Development
",
},
{
"Tag": "<span data-affine-option data-value="0jh9gNw4Yl" data-option-color="var(--affine-tag-orange)">Developers</span>",
"Title": "For developers or installations guides, please go to AFFiNE Doc
",
"undefined": "For developers or installations guides, please go to AFFiNE Doc
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Quip & Notion with their great concept of "everything is a block"
",
"undefined": "Quip & Notion with their great concept of "everything is a block"
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Trello with their Kanban
",
"undefined": "Trello with their Kanban
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Airtable & Miro with their no-code programable datasheets
",
"undefined": "Airtable & Miro with their no-code programable datasheets
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Miro & Whimiscal with their edgeless visual whiteboard
",
"undefined": "Miro & Whimiscal with their edgeless visual whiteboard
",
},
{
"Tag": "",
"Title": "Remnote & Capacities with their object-based tag system
",
"undefined": "Remnote & Capacities with their object-based tag system
",
},
],
"title": "Learning From",
"type": undefined,
},
],
"content": "",
"flavour": "affine:note",
"id": "2jwCeO8Yot",
"type": undefined,
},
{
"children": [
{
"children": [],
"content": "## Affine Development
",
"flavour": "affine:paragraph",
"id": "NyHXrMX3R1",
"type": "h2",
},
{
"children": [],
"content": "For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)
",
"flavour": "affine:paragraph",
"id": "9-K49otbCv",
"type": "text",
},
{
"children": [],
"content": "
",
"flavour": "affine:paragraph",
"id": "faFteK9eG-",
"type": "text",
},
],
"content": "",
"flavour": "affine:note",
"id": "c9MF_JiRgx",
"type": undefined,
},
],
"content": "",
"flavour": "affine:page",
"id": "TnUgtVg7Eu",
"type": undefined,
},
"title": "Write, Draw, Plan all at Once.",
}
`;
exports[`should read all doc ids from root doc snapshot work 1`] = `
[
"5nS9BSp3Px",
@@ -5,7 +5,6 @@ import { expect, test } from 'vitest';
import { applyUpdate, Array as YArray, Doc as YDoc, Map as YMap } from 'yjs';
import {
parsePageDoc,
readAllBlocksFromDoc,
readAllDocIdsFromRootDoc,
readAllDocsFromRootDoc,
@@ -101,20 +100,3 @@ test('should read all doc ids from root doc snapshot work', async () => {
const docIds = readAllDocIdsFromRootDoc(rootDoc);
expect(docIds).toMatchSnapshot();
});
test('should parse page doc work', () => {
const doc = new YDoc({
guid: 'test-doc',
});
applyUpdate(doc, docSnapshot);
const result = parsePageDoc({
workspaceId: 'test-space',
doc,
buildBlobUrl: id => `blob://${id}`,
buildDocUrl: id => `doc://${id}`,
renderDocTitle: id => `Doc Title ${id}`,
});
expect(result).toMatchSnapshot();
});
@@ -1 +0,0 @@
A fork of https://github.com/frysztak/quill-delta-to-markdown
@@ -1,95 +0,0 @@
// eslint-disable
// @ts-nocheck
import { Node } from './utils/node';
import { encodeLink } from './utils/url';
export interface InlineReference {
type: 'LinkedPage';
pageId: string;
title?: string;
params?: { mode: 'doc' | 'edgeless' };
}
export interface ConverterOptions {
convertInlineReferenceLink?: (reference: InlineReference) => {
title: string;
link: string;
};
}
const defaultConvertInlineReferenceLink = (reference: InlineReference) => {
return {
title: reference.title || '',
link: [reference.type, reference.pageId, reference.params?.mode]
.filter(Boolean)
.join(':'),
};
};
export function getConverters(opts: ConverterOptions = {}) {
const { convertInlineReferenceLink = defaultConvertInlineReferenceLink } =
opts;
return {
embed: {
image: function (src) {
this.append('![](' + encodeLink(src) + ')');
},
// Not a default Quill feature, converts custom divider embed blot added when
// creating quill editor instance.
// See https://quilljs.com/guides/cloning-medium-with-parchment/#dividers
thematic_break: function () {
this.open = '\n---\n' + this.open;
},
},
inline: {
italic: function () {
return ['_', '_'];
},
bold: function () {
return ['**', '**'];
},
link: function (url) {
return ['[', '](' + url + ')'];
},
reference: function (reference: InlineReference) {
const { title, link } = convertInlineReferenceLink(reference);
return ['[', `${title}](${link})`];
},
strike: function () {
return ['~~', '~~'];
},
code: function () {
return ['`', '`'];
},
},
block: {
header: function ({ header }) {
this.open = '#'.repeat(header) + ' ' + this.open;
},
blockquote: function () {
this.open = '> ' + this.open;
},
list: {
group: function () {
return new Node(['', '\n']);
},
line: function (attrs, group) {
if (attrs.list === 'bullet') {
this.open = '- ' + this.open;
} else if (attrs.list === 'checked') {
this.open = '- [x] ' + this.open;
} else if (attrs.list === 'unchecked') {
this.open = '- [ ] ' + this.open;
} else if (attrs.list === 'ordered') {
group.count = group.count || 0;
var count = ++group.count;
this.open = count + '. ' + this.open;
}
},
},
},
};
}
@@ -1,147 +0,0 @@
// eslint-disable
// @ts-nocheck
import { Node } from './utils/node';
export const deltaToMd = (delta, converters) => {
return convert(delta, converters).render().trimEnd() + '\n';
};
function convert(ops, converters) {
let group, line, el, activeInline, beginningOfLine;
let root = new Node();
function newLine() {
el = line = new Node(['', '\n']);
root.append(line);
activeInline = {};
}
newLine();
for (let i = 0; i < ops.length; i++) {
let op = ops[i];
if (typeof op.insert === 'object') {
for (let k in op.insert) {
if (converters.embed[k]) {
applyInlineAttributes(op.attributes);
converters.embed[k].call(el, op.insert[k], op.attributes);
}
}
} else {
let lines = op.insert.split('\n');
if (hasBlockLevelAttribute(op.attributes, converters)) {
// Some line-level styling (ie headings) is applied by inserting a \n
// with the style; the style applies back to the previous \n.
// There *should* only be one style in an insert operation.
for (let j = 1; j < lines.length; j++) {
for (let attr in op.attributes) {
if (converters.block[attr]) {
let fn = converters.block[attr];
if (typeof fn === 'object') {
if (group && group.type !== attr) {
group = null;
}
if (!group && fn.group) {
group = {
el: fn.group(),
type: attr,
value: op.attributes[attr],
distance: 0,
};
root.append(group.el);
}
if (group) {
group.el.append(line);
group.distance = 0;
}
fn = fn.line;
}
fn.call(line, op.attributes, group);
newLine();
break;
}
}
}
beginningOfLine = true;
} else {
for (let l = 0; l < lines.length; l++) {
if ((l > 0 || beginningOfLine) && group && ++group.distance >= 2) {
group = null;
}
applyInlineAttributes(
op.attributes,
ops[i + 1] && ops[i + 1].attributes
);
el.append(lines[l]);
if (l < lines.length - 1) {
newLine();
}
}
beginningOfLine = false;
}
}
}
return root;
function applyInlineAttributes(attrs, next?: any) {
let first: any[] = [];
let then: any[] = [];
attrs = attrs || {};
let tag = el,
seen = {};
while (tag._format) {
seen[tag._format] = true;
if (!attrs[tag._format] || tag.open !== tag.close) {
for (let k in seen) {
delete activeInline[k];
}
el = tag.parent();
}
tag = tag.parent();
}
for (let attr in attrs) {
if (converters.inline[attr] && attrs[attr]) {
if (activeInline[attr] && activeInline[attr] === attrs[attr]) {
continue; // do nothing -- we should already be inside this style's tag
}
if (next && attrs[attr] === next[attr]) {
first.push(attr); // if the next operation has the same style, this should be the outermost tag
} else {
then.push(attr);
}
activeInline[attr] = attrs[attr];
}
}
first.forEach(apply);
then.forEach(apply);
function apply(fmt) {
let newEl = converters.inline[fmt].call(null, attrs[fmt]);
if (Array.isArray(newEl)) {
newEl = new Node(newEl);
}
newEl._format = fmt;
el.append(newEl);
el = newEl;
}
}
}
function hasBlockLevelAttribute(attrs, converters) {
for (let k in attrs) {
if (Object.keys(converters.block).includes(k)) {
return true;
}
}
return false;
}
@@ -1,2 +0,0 @@
export { getConverters } from './delta-converters';
export { deltaToMd } from './delta-to-md';
@@ -1,66 +0,0 @@
// eslint-disable
// @ts-nocheck
let id = 0;
export class Node {
id = ++id;
children: Node[];
open: string;
close: string;
text: string;
_format: string;
_parent: Node;
constructor(data?: string[] | string) {
if (Array.isArray(data)) {
this.open = data[0];
this.close = data[1];
} else if (typeof data === 'string') {
this.text = data;
}
this.children = [];
}
append(e: Node) {
if (!(e instanceof Node)) {
e = new Node(e);
}
if (e._parent) {
const idx = e._parent.children.indexOf(e);
e._parent.children.splice(idx, 1);
}
e._parent = this;
this.children = this.children.concat(e);
}
render() {
const inner =
(this.text || '') + this.children.map(c => c.render()).join('');
if (
inner.trim() === '' &&
this.open === this.close &&
this.open &&
this.close
) {
return '';
}
const wrapped = this.open && this.close;
const emptyInner = inner.trim() === '';
const fragments = [
inner.startsWith(' ') && !emptyInner && wrapped ? ' ' : '',
this.open,
wrapped ? inner.trim() : inner,
this.close,
inner.endsWith(' ') && !emptyInner && wrapped ? ' ' : '',
].filter(f => f);
return fragments.join('');
}
parent() {
return this._parent;
}
}
@@ -1,5 +0,0 @@
export const encodeLink = (link: string) =>
encodeURI(link)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/(\?|&)response-content-disposition=attachment.*$/, '');
@@ -1,437 +0,0 @@
import type { ColumnDataType } from '@blocksuite/affine/model';
import { Array as YArray, type Map as YMap, type Text as YText } from 'yjs';
import { deltaToMd, getConverters } from './delta-to-md';
import type {
BaseParsedBlock,
Flavour,
ParsedBlock,
ParsedDoc,
ParserContext,
SerializedCells,
YBlock,
YBlocks,
} from './types';
export const parseBlockToMd = (
block: BaseParsedBlock,
padding = ''
): string => {
if (block.content) {
return (
block.content
.split('\n')
.map(line => padding + line)
.join('\n') +
'\n' +
block.children.map(b => parseBlockToMd(b, padding + ' ')).join('')
);
} else {
return block.children.map(b => parseBlockToMd(b, padding)).join('');
}
};
export function parseBlock(
context: ParserContext,
yBlock: YBlock | undefined,
yBlocks: YBlocks // all blocks
): ParsedBlock | null {
if (!yBlock) {
return null;
}
const deltaConverters = getConverters({
convertInlineReferenceLink: ref => {
return {
title: ref.title || context.renderDocTitle?.(ref.pageId) || '',
link: context.buildDocUrl(ref.pageId),
};
},
});
const id = yBlock.get('sys:id') as string;
const flavour = yBlock.get('sys:flavour') as Flavour;
const type = yBlock.get('prop:type') as string;
const toMd = () =>
deltaToMd((yBlock.get('prop:text') as YText).toDelta(), deltaConverters);
const hidden = yBlock.get('prop:hidden') as boolean;
const displayMode = yBlock.get('prop:displayMode') as string;
const childrenIds =
yBlock.get('sys:children') instanceof YArray
? (yBlock.get('sys:children') as YArray<string>).toJSON()
: [];
let result: ParsedBlock = {
id,
flavour,
content: '',
children: [],
type,
};
if (hidden || displayMode === 'edgeless') {
return result;
}
try {
switch (flavour) {
case 'affine:paragraph': {
let initial = '';
if (type === 'h1') {
initial = '# ';
} else if (type === 'h2') {
initial = '## ';
} else if (type === 'h3') {
initial = '### ';
} else if (type === 'h4') {
initial = '#### ';
} else if (type === 'h5') {
initial = '##### ';
} else if (type === 'h6') {
initial = '###### ';
} else if (type === 'quote') {
initial = '> ';
}
result.content = initial + toMd() + '\n';
break;
}
case 'affine:divider': {
result.content = '\n---\n\n';
break;
}
case 'affine:list': {
result.content = (type === 'bulleted' ? '* ' : '1. ') + toMd() + '\n';
break;
}
case 'affine:code': {
const lang =
(yBlock.get('prop:language') as string)?.toLowerCase() || 'txt';
// do not transform to delta for code block
const caption = yBlock.get('prop:caption') as string;
result.content =
'```' +
lang +
(caption ? ` ${caption}` : '') +
'\n' +
(yBlock.get('prop:text') as YText).toJSON() +
'\n```\n\n';
break;
}
case 'affine:image': {
const sourceId = yBlock.get('prop:sourceId') as string;
const width = yBlock.get('prop:width');
const height = yBlock.get('prop:height');
// fixme: this may not work if workspace is not public
const blobUrl = context.buildBlobUrl(sourceId);
const caption = yBlock.get('prop:caption') as string;
if (width || height || caption) {
result.content =
`<img
src="${blobUrl}"
alt="${caption}"
width="${width || 'auto'}"
height="${height || 'auto'}"
/>
` + '\n\n';
} else {
result.content = `\n![${caption || sourceId}](${blobUrl})\n\n`;
}
Object.assign(result, {
sourceId,
width,
height,
caption,
blobUrl,
});
break;
}
case 'affine:attachment': {
const sourceId = yBlock.get('prop:sourceId') as string;
const blobUrl = context.buildBlobUrl(sourceId);
const caption = yBlock.get('prop:caption') as string;
if (type.startsWith('video')) {
result.content =
`<video muted autoplay loop preload="auto" playsinline>
<source src="${blobUrl}" type="${type}" />
</video>
` + '\n\n';
} else {
// assume it is an image
result.content = `\n![${caption || sourceId}](${blobUrl})\n\n`;
}
Object.assign(result, {
sourceId,
blobUrl,
caption,
});
break;
}
case 'affine:embed-youtube': {
const videoId = yBlock.get('prop:videoId') as string;
// prettier-ignore
result.content = `
<iframe
type="text/html"
width="100%"
height="410px"
src="https://www.youtube.com/embed/${videoId}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
credentialless>
</iframe>` + '\n\n';
break;
}
case 'affine:bookmark': {
const url = yBlock.get('prop:url') as string;
const caption = yBlock.get('prop:caption') as string;
result.content = `\n[](Bookmark,${url})\n\n`;
Object.assign(result, {
url,
caption,
});
break;
}
case 'affine:embed-linked-doc':
case 'affine:embed-synced-doc': {
const pageId = yBlock.get('prop:pageId') as string;
const caption = yBlock.get('prop:caption') as string;
result.content = `\n[${caption}](${context.buildDocUrl(pageId)})\n\n`;
Object.assign(result, {
pageId,
caption,
});
break;
}
case 'affine:surface':
case 'affine:page':
case 'affine:note':
case 'affine:frame': {
result.content = '';
break;
}
case 'affine:database': {
const title = (yBlock.get('prop:title') as YText).toJSON();
const childrenTitleById = Object.fromEntries(
childrenIds.map(cid => {
const child = parseBlock(
context,
yBlocks.get(cid) as YBlock | undefined,
yBlocks
);
if (!child) {
return [cid, ''];
}
return [cid, parseBlockToMd(child)] as const;
})
);
const cols = (
yBlock.get('prop:columns') as YArray<ColumnDataType>
).toJSON() as ColumnDataType[];
const cells = (
yBlock.get('prop:cells') as YMap<SerializedCells>
).toJSON() as SerializedCells;
const optionToTagHtml = (option: any) => {
return `<span data-affine-option data-value="${option.id}" data-option-color="${option.color}">${option.value}</span>`;
};
const dbRows: string[][] = childrenIds
.map(cid => {
const row = cells[cid];
return cols.map(col => {
const value = row?.[col.id]?.value;
if (col.type !== 'title' && !value) {
return '';
}
switch (col.type) {
case 'title':
return childrenTitleById[cid];
case 'select':
return optionToTagHtml(
(col.data['options'] as any).find(
(opt: any) => opt.id === value
)
);
case 'multi-select':
return (col.data['options'] as any)
.filter((opt: any) => (value as string[]).includes(opt.id))
.map(optionToTagHtml)
.join('');
default:
return value ?? '';
}
});
})
.filter(row => !row.every(v => !v));
const header = cols.map(col => {
return col.name;
});
const divider = cols.map(() => {
return '---';
});
// convert to markdown table
result.content =
[header, divider, ...dbRows]
.map(row => {
return (
'|' +
row
.map(cell => String(cell || '')?.trim())
.join('|')
.replace(/\n+/g, '<br />') +
'|'
);
})
.join('\n') + '\n\n';
Object.assign(result, {
title,
rows: dbRows.map(row => {
return Object.fromEntries(row.map((v, i) => [cols[i].name, v]));
}),
});
break;
}
case 'affine:table': {
// Extract row IDs and their order
const rowEntries = Object.entries(yBlock.toJSON())
.filter(
([key]) => key.startsWith('prop:rows.') && key.endsWith('.rowId')
)
.map(([key, value]) => {
const rowId = value as string;
const orderKey = key.replace('.rowId', '.order');
const order = yBlock.get(orderKey) as string;
const backgroundColor = yBlock.get(
key.replace('.rowId', '.backgroundColor')
) as string | undefined;
return { rowId, order, backgroundColor };
})
.sort((a, b) => a.order.localeCompare(b.order));
// Extract column IDs and their order
const columnEntries = Object.entries(yBlock.toJSON())
.filter(
([key]) =>
key.startsWith('prop:columns.') && key.endsWith('.columnId')
)
.map(([key, value]) => {
const columnId = value as string;
const orderKey = key.replace('.columnId', '.order');
const order = yBlock.get(orderKey) as string;
return { columnId, order };
})
.sort((a, b) => a.order.localeCompare(b.order));
// Build the table rows with cell data
const tableRows = rowEntries.map(({ rowId }) => {
return columnEntries.map(({ columnId }) => {
const cellKey = `prop:cells.${rowId}:${columnId}.text`;
const cellText = yBlock.get(cellKey) as string | undefined;
return cellText || '';
});
});
// Store column IDs for reference
const columnIds = columnEntries.map(({ columnId }) => columnId);
// Use the first row as header and the rest as data rows
if (tableRows.length > 0) {
const headerRow = tableRows[0];
const dataRows = tableRows.slice(1);
const separators = headerRow.map(() => '---');
// Convert to markdown table with first row as header
result.content =
[headerRow, separators, ...dataRows]
.map(row => {
return (
'|' +
row
.map(cell => String(cell || '')?.trim())
.join('|')
.replace(/\n+/g, '<br />') +
'|'
);
})
.join('\n') + '\n\n';
} else {
// Handle empty table case
result.content = '';
}
Object.assign(result, {
columns: columnIds,
rows: tableRows,
});
break;
}
default: {
// console.warn("Unknown or unsupported flavour", flavour);
}
}
result.children =
flavour !== 'affine:database'
? childrenIds
.map(cid =>
parseBlock(
context,
yBlocks.get(cid) as YBlock | undefined,
yBlocks
)
)
.filter(
(block): block is ParsedBlock =>
!!block &&
!(block.content === '' && block.children.length === 0)
)
: [];
} catch (e) {
console.warn('Error converting block to md', e);
}
return result;
}
export const parsePageDoc = (ctx: ParserContext): ParsedDoc => {
// we assume that the first block is the page block
const yBlocks: YBlocks = ctx.doc.getMap('blocks');
const maybePageBlock = Object.entries(yBlocks.toJSON()).findLast(
([_, b]) => b['sys:flavour'] === 'affine:page'
);
// there are cases that the page is empty due to some weird issues
if (!maybePageBlock) {
return {
title: '',
md: '',
};
} else {
const yPage = yBlocks.get(maybePageBlock[0]) as YBlock;
const title = yPage.get('prop:title') as YText;
const rootBlock = parseBlock(ctx, yPage, yBlocks);
if (!rootBlock) {
return {
title: '',
md: '',
};
}
rootBlock.children = rootBlock.children.filter(
(block): block is BaseParsedBlock => block.flavour === 'affine:note'
);
const md = parseBlockToMd(rootBlock);
return {
title: title.toJSON(),
parsedBlock: rootBlock,
md,
};
}
};
@@ -1,152 +0,0 @@
import { type CellDataType } from '@blocksuite/affine/model';
import { type Doc as YDoc, type Map as YMap } from 'yjs';
export interface WorkspacePage {
id: string;
guid: string;
title: string;
createDate: number;
trash?: boolean;
favorite?: boolean;
properties?: Record<string, any>;
}
export type BaseFlavour<T extends string> = `affine:${T}`;
export type Flavour = BaseFlavour<
| 'page'
| 'frame'
| 'paragraph'
| 'code'
| 'note'
| 'list'
| 'divider'
| 'embed'
| 'image'
| 'surface'
| 'database'
| 'table'
| 'attachment'
| 'bookmark'
| 'embed-youtube'
| 'embed-linked-doc'
| 'embed-synced-doc'
>;
export interface BaseParsedBlock {
id: string;
flavour: Flavour;
content: string;
children: BaseParsedBlock[];
type?: string;
}
export interface ParsedDoc {
title: string;
md: string;
parsedBlock?: ParsedBlock;
}
export interface ParagraphBlock extends BaseParsedBlock {
flavour: 'affine:paragraph';
type: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'quote';
}
export interface DividerBlock extends BaseParsedBlock {
flavour: 'affine:divider';
}
export interface ListBlock extends BaseParsedBlock {
flavour: 'affine:list';
type: 'bulleted' | 'numbered';
}
export interface CodeBlock extends BaseParsedBlock {
flavour: 'affine:code';
language: string;
}
export interface ImageBlock extends BaseParsedBlock {
flavour: 'affine:image';
sourceId: string;
blobUrl: string;
width?: number;
height?: number;
caption?: string;
}
export interface AttachmentBlock extends BaseParsedBlock {
flavour: 'affine:attachment';
type: string;
sourceId: string;
}
export interface EmbedYoutubeBlock extends BaseParsedBlock {
flavour: 'affine:embed-youtube';
videoId: string;
}
export interface BookmarkBlock extends BaseParsedBlock {
flavour: 'affine:bookmark';
url: string;
}
export interface EmbedLinkedDocBlock extends BaseParsedBlock {
flavour: 'affine:embed-linked-doc';
pageId: string;
}
export interface EmbedSyncedDocBlock extends BaseParsedBlock {
flavour: 'affine:embed-synced-doc';
pageId: string;
}
export interface DatabaseBlock extends BaseParsedBlock {
title: string;
flavour: 'affine:database';
rows: Record<string, string>[];
}
export interface TableBlock extends BaseParsedBlock {
flavour: 'affine:table';
rows: string[][];
columns: string[];
}
export type ParsedBlock =
| ParagraphBlock
| DividerBlock
| ListBlock
| CodeBlock
| ImageBlock
| AttachmentBlock
| EmbedYoutubeBlock
| BookmarkBlock
| DatabaseBlock
| TableBlock
| BaseParsedBlock;
export interface ParsedDoc {
title: string;
md: string;
parsedBlock?: ParsedBlock;
}
export type SerializedCells = {
// row
[key: string]: {
// column
[key: string]: CellDataType;
};
};
export type YBlock = YMap<unknown>;
export type YBlocks = YMap<YBlock>;
export interface ParserContext {
workspaceId: string;
doc: YDoc;
buildBlobUrl: (blobId: string) => string;
buildDocUrl: (docId: string) => string;
renderDocTitle?: (docId: string) => string;
}

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