Compare commits

...
11 Commits
Author SHA1 Message Date
Nathan 4354227a05 chore: enable select source when user is not anon 2025-05-07 14:31:08 +08:00
Nathan.fooo f7b556677f Custom langchain rust (#7887)
* chore: implement custom memory store chain

* chore: do not allow edit embedding model
2025-05-07 13:37:44 +08:00
Lucas 19b1bccb03 chore: upgrade editor version (#7886) 2025-05-07 11:03:47 +08:00
Nathan.fooo 0f5699571c chore: filter out space when indexing search (#7882) 2025-05-06 20:49:44 +08:00
Nathan.fooo 53cbd22111 chore: local ai chat with pages (#7881)
* chore: local ai chat with page

* chore: temp

* chore: clippy

* chore: fix ui bugs

* chore: fix ui bugs

* chore: fix view title

* chore: cloud id

* chore: bump server

* chore: bump server
2025-05-06 20:32:10 +08:00
Lucas 926a5b31bb refacotr: ai chat page folder strcture (#7879)
* refacotr: ai chat page folder strcture

* feat: add non-reversed animated chat list

* chore: add scroll animation

* feat: add fade animation

* fix: refresh issue

* feat: add flag to control the ai chat animation

* chore: enable reversed list
2025-05-06 13:10:14 +08:00
Nathan.fooo bdac73e934 chore: refactor local ai (#7880)
* chore: refactor local ai

* chore: ios build
2025-05-05 20:27:29 +08:00
Nathan.fooo 20f3116bd9 refactor: Model select (#7875)
* refactor: model select

* refactor: add test

* fix: add source

* fix: add source

* chore: notify all unset source

* chore: fix test
2025-05-02 15:37:40 +08:00
Lucas a2b1f8c325 chore: pick commits from release/0.9.1 (#7874)
* chore: update i18n

* chore: remove sentry

* fix: allow the member to get the workspace usage

* fix: integration test
2025-05-02 14:48:26 +08:00
Nathan.fooo 612d652320 feat: support local ai embedding, local ai search, local document content search (#7839)
* chore: generate embeddings

* chore: save embedding

* chore: vec sqlite

* chore: clippy

* chore: init vector

* chore: create vector db

* chore: periodically write embedding

* chore: fix compile

* chore: skip write

* chore: impl search

* fix: test

* fix: stop scheduler

* fix: search

* chore: add test

* chore: update schema

* chore: index all

* chore: index

* chore: search document content

* chore: index document content and title

* chore: index all view

* chore: rename trait

* refactor: remove folder index manager

* chore: index folder changes

* chore: delete folder search

* chore: update logs

* chore: update logs

* chore: search

* chore: add search test

* chore: fmt

* chore: fix test

* chore: fix ios build
2025-05-02 08:21:42 +08:00
Nathan.foooandLucas.Xu 7750f2257f fix: anon user can not locate its data (#7873)
* fix: anon user can not locate its data

* chore: add test

* chore: bump version 0.9.1

* chore: remove self-hosted ios ci

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-05-01 15:06:56 +08:00
238 changed files with 13653 additions and 6477 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
+2 -2
View File
@@ -26,9 +26,9 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.9.37-amd64
CLOUD_VERSION: 0.9.49-amd64
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+1 -22
View File
@@ -19,35 +19,14 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-self-hosted:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
steps:
- name: Checkout source code
uses: actions/checkout@v2
- name: Build AppFlowy
working-directory: frontend
run: |
cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios
cargo make --profile development-ios-arm64-sim code_generation
- uses: futureware-tech/simulator-action@v3
id: simulator-action
with:
model: "iPhone 15"
shutdown_after_job: false
integration-tests:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: macos-latest
steps:
+11 -11
View File
@@ -7,7 +7,7 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
jobs:
create-release:
@@ -232,10 +232,10 @@ jobs:
matrix:
job:
- {
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -336,12 +336,12 @@ jobs:
matrix:
job:
- {
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
+3 -3
View File
@@ -18,8 +18,8 @@ on:
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.8.3-amd64
RUST_TOOLCHAIN: "1.81.0"
CLOUD_VERSION: 0.9.49-amd64
RUST_TOOLCHAIN: "1.84.0"
jobs:
ubuntu-job:
@@ -106,7 +106,7 @@ jobs:
af_cloud_test_ws_url: ws://localhost/ws/v1
af_cloud_test_gotrue_url: http://localhost/gotrue
run: |
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart"
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart" -- --skip local_ollama_test
- name: rustfmt rust-lib
run: cargo fmt --all -- --check
+1 -1
View File
@@ -11,7 +11,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
jobs:
tests:
+22
View File
@@ -1,4 +1,26 @@
# Release Notes
## Version 0.9.1 - 01/05/2025
### Desktop
#### New Features
- Added AppFlowy Prompt Library to AI Chat and Document's Ask AI
- Revamped the desktop in-app notification center
- Supported login with password, as well as forgot and change password options
- Supported copying link to invite members
- Improved the Settings' Members tab with new metadata: member avatar and joined time
#### Bug Fixes
- Fixed data loss when using anonymous local
- Fixed crash when trying to delete an emoji
- Fixed Windows scaling issue
- Correctly displayed mention text by decoding web content
### Mobile
#### New Features
- Supported workspace search
- Improved UX for links in documents
- Supported changing password in Mobile Settings
- Added support for inviting members via links
#### Bug Fixes
- Correctly displayed mention text by decoding web content
## Version 0.9.0 - 30/04/2025
### Desktop
#### New Features
+36 -35
View File
@@ -1,16 +1,16 @@
#https://github.com/sagiegurari/cargo-make
extend = [
{ path = "scripts/makefile/desktop.toml" },
{ path = "scripts/makefile/mobile.toml" },
{ path = "scripts/makefile/protobuf.toml" },
{ path = "scripts/makefile/tests.toml" },
{ path = "scripts/makefile/docker.toml" },
{ path = "scripts/makefile/env.toml" },
{ path = "scripts/makefile/flutter.toml" },
{ path = "scripts/makefile/tool.toml" },
{ path = "scripts/makefile/tauri.toml" },
{ path = "scripts/makefile/web.toml" },
{ path = "scripts/makefile/desktop.toml" },
{ path = "scripts/makefile/mobile.toml" },
{ path = "scripts/makefile/protobuf.toml" },
{ path = "scripts/makefile/tests.toml" },
{ path = "scripts/makefile/docker.toml" },
{ path = "scripts/makefile/env.toml" },
{ path = "scripts/makefile/flutter.toml" },
{ path = "scripts/makefile/tool.toml" },
{ path = "scripts/makefile/tauri.toml" },
{ path = "scripts/makefile/web.toml" },
]
[config]
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.9.0"
APPFLOWY_VERSION = "0.9.1"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -228,30 +228,31 @@ script = ['''
echo APP_ENVIRONMENT: ${APP_ENVIRONMENT}
echo BUILD_ARCHS: ${BUILD_ARCHS}
echo BUILD_VERSION: ${BUILD_VERSION}
echo RUST_VERSION: "$(rustc --version)"
''']
script_runner = "@shell"
[tasks.setup-crate-type]
private = true
script = [
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} "staticlib" ${CRATE_TYPE}
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} "staticlib" ${CRATE_TYPE}
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
]
script_runner = "@duckscript"
[tasks.restore-crate-type]
private = true
script = [
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} ${CRATE_TYPE} "staticlib"
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} ${CRATE_TYPE} "staticlib"
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
]
script_runner = "@duckscript"
@@ -279,24 +280,24 @@ TEST_COMPILE_TARGET = "x86_64-pc-windows-msvc"
[tasks.setup-test-crate-type]
private = true
script = [
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} "staticlib" ${TEST_CRATE_TYPE}
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} "staticlib" ${TEST_CRATE_TYPE}
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
]
script_runner = "@duckscript"
[tasks.restore-test-crate-type]
private = true
script = [
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} ${TEST_CRATE_TYPE} "staticlib"
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
"""
toml = readfile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml
val = replace ${toml} ${TEST_CRATE_TYPE} "staticlib"
result = writefile ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/rust-lib/${CARGO_MAKE_CRATE_NAME}/Cargo.toml ${val}
assert ${result}
""",
]
script_runner = "@duckscript"
@@ -8,7 +8,7 @@
"{""data"":""Juan"",""last_modified"":1726063423,""created_at"":1726063423,""field_type"":0}","{""created_at"":1726122510,""reminder_id"":"""",""include_time"":false,""is_range"":true,""last_modified"":1726122515,""data"":""1725604115"",""end_timestamp"":""1725776915"",""field_type"":2}","{""field_type"":1,""created_at"":1726063677,""last_modified"":1726063677,""data"":""93100""}","{""field_type"":1,""data"":""4.86"",""created_at"":1726062597,""last_modified"":1726062597}",,"{""last_modified"":1726062377,""field_type"":5,""data"":""Yes"",""created_at"":1726062377}","{""last_modified"":1726064412,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""tTDq\"",\""name\"":\""complete onboarding\"",\""color\"":\""Purple\""},{\""id\"":\""E8Ds\"",\""name\"":\""contact support\"",\""color\"":\""Purple\""},{\""id\"":\""RoGN\"",\""name\"":\""get started\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""tTDq\"",\""E8Ds\""]}"",""created_at"":1726064396}",,"{""created_at"":1726065278,""field_type"":3,""data"":""qnja"",""last_modified"":1726065278}","{""data"":""R9I7,yORP,1i4f"",""field_type"":4,""created_at"":1726105126,""last_modified"":1726105127}","{""data"":""1726122515"",""field_type"":8}","{""data"":""1726060541"",""field_type"":9}"
"{""data"":""Alex"",""created_at"":1726063432,""last_modified"":1726063432,""field_type"":0}","{""reminder_id"":"""",""data"":""1725292800"",""include_time"":true,""last_modified"":1726122448,""created_at"":1726122422,""is_range"":true,""end_timestamp"":""1725551940"",""field_type"":2}","{""field_type"":1,""last_modified"":1726063683,""created_at"":1726063683,""data"":""3560""}","{""created_at"":1726062561,""data"":""1.96"",""last_modified"":1726062561,""field_type"":1}","{""last_modified"":1726063952,""created_at"":1726063931,""data"":""al3x1343@protonmail.com"",""field_type"":6}","{""last_modified"":1726062375,""field_type"":5,""created_at"":1726062375,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""qNyr\"",\""name\"":\""finish reading book\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064616,""last_modified"":1726064616,""field_type"":7}",,"{""data"":""qnja"",""created_at"":1726065272,""last_modified"":1726065272,""field_type"":3}","{""created_at"":1726105180,""last_modified"":1726105180,""field_type"":4,""data"":""R9I7,1i4f""}","{""field_type"":8,""data"":""1726122448""}","{""field_type"":9,""data"":""1726060541""}"
"{""last_modified"":1726063478,""created_at"":1726063436,""field_type"":0,""data"":""Alexander""}",,"{""field_type"":1,""last_modified"":1726063691,""created_at"":1726063691,""data"":""2073""}","{""field_type"":1,""data"":""0.5"",""last_modified"":1726062577,""created_at"":1726062577}","{""last_modified"":1726063991,""field_type"":6,""created_at"":1726063991,""data"":""alexandernotthedra@gmail.com""}","{""field_type"":5,""last_modified"":1726062378,""created_at"":1726062377,""data"":""No""}",,,"{""created_at"":1726065291,""data"":""GSf_"",""last_modified"":1726065291,""field_type"":3}","{""last_modified"":1726105142,""created_at"":1726105133,""data"":""SEUo"",""field_type"":4}","{""field_type"":8,""data"":""1726105142""}","{""field_type"":9,""data"":""1726060542""}"
"{""field_type"":0,""created_at"":1726063454,""last_modified"":1726063454,""data"":""George""}","{""created_at"":1726122467,""end_timestamp"":""1726468070"",""include_time"":false,""is_range"":true,""reminder_id"":"""",""field_type"":2,""data"":""1726295270"",""last_modified"":1726122470}",,,"{""field_type"":6,""data"":""george.aq@appflowy.io"",""last_modified"":1726064104,""created_at"":1726064016}","{""last_modified"":1726062376,""created_at"":1726062376,""field_type"":5,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""s_dQ\"",\""name\"":\""bug triage\"",\""color\"":\""Purple\""},{\""id\"":\""-Zfo\"",\""name\"":\""fix bugs\"",\""color\"":\""Purple\""},{\""id\"":\""wsDN\"",\""name\"":\""attend meetings\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""s_dQ\"",\""-Zfo\""]}"",""last_modified"":1726064468,""created_at"":1726064424,""field_type"":7}","{""data"":[""{\""id\"":\""8a77f84d-64e9-4e67-b902-fa23980459ec\"",\""name\"":\""BQdTmxpRI6f.png\"",\""url\"":\""https://static.cdninstagram.com/rsrc.php/v3/ym/r/BQdTmxpRI6f.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}""],""field_type"":14,""created_at"":1726122956,""last_modified"":1726122956}","{""field_type"":3,""data"":""qnja"",""created_at"":1726065313,""last_modified"":1726065313}","{""data"":""R9I7,yORP"",""field_type"":4,""last_modified"":1726105198,""created_at"":1726105187}","{""data"":""1726122956"",""field_type"":8}","{""data"":""1726060543"",""field_type"":9}"
"{""field_type"":0,""created_at"":1726063454,""last_modified"":1726063454,""data"":""George""}","{""created_at"":1726122467,""end_timestamp"":""1726468070"",""include_time"":false,""is_range"":true,""reminder_id"":"""",""field_type"":2,""data"":""1726295270"",""last_modified"":1726122470}",,,"{""field_type"":6,""data"":""george.aq@appflowy.io"",""last_modified"":1726064104,""created_at"":1726064016}","{""last_modified"":1726062376,""created_at"":1726062376,""field_type"":5,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""s_dQ\"",\""name\"":\""bug triage\"",\""color\"":\""Purple\""},{\""id\"":\""-Zfo\"",\""name\"":\""fix bugs\"",\""color\"":\""Purple\""},{\""id\"":\""wsDN\"",\""name\"":\""attend meetings\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""s_dQ\"",\""-Zfo\""]}"",""last_modified"":1726064468,""created_at"":1726064424,""field_type"":7}","{""data"":[""{\""id\"":\""8a77f84d-64e9-4e67-b902-fa23980459ec\"",\""name\"":\""BQdTmxpRI6f.png\"",\""url\"":\""https://file-examples.com/wp-content/storage/2017/10/file_example_PNG_500kB.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}""],""field_type"":14,""created_at"":1726122956,""last_modified"":1726122956}","{""field_type"":3,""data"":""qnja"",""created_at"":1726065313,""last_modified"":1726065313}","{""data"":""R9I7,yORP"",""field_type"":4,""last_modified"":1726105198,""created_at"":1726105187}","{""data"":""1726122956"",""field_type"":8}","{""data"":""1726060543"",""field_type"":9}"
"{""field_type"":0,""last_modified"":1726063467,""data"":""Joanna"",""created_at"":1726063467}","{""include_time"":false,""end_timestamp"":""1727072893"",""is_range"":true,""last_modified"":1726122493,""created_at"":1726122483,""data"":""1726554493"",""field_type"":2,""reminder_id"":""""}","{""last_modified"":1726065463,""data"":""16470"",""field_type"":1,""created_at"":1726065463}","{""created_at"":1726062626,""field_type"":1,""last_modified"":1726062626,""data"":""-5.36""}","{""last_modified"":1726064069,""data"":""joannastrawberry29+hello@gmail.com"",""created_at"":1726064069,""field_type"":6}",,"{""field_type"":7,""created_at"":1726064444,""last_modified"":1726064460,""data"":""{\""options\"":[{\""id\"":\""ZxJz\"",\""name\"":\""post on Twitter\"",\""color\"":\""Purple\""},{\""id\"":\""upwi\"",\""name\"":\""watch Youtube videos\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""upwi\""]}""}",,"{""created_at"":1726065317,""last_modified"":1726065317,""field_type"":3,""data"":""qnja""}","{""field_type"":4,""last_modified"":1726105173,""data"":""uRAO,yORP"",""created_at"":1726105170}","{""data"":""1726122493"",""field_type"":8}","{""data"":""1726060545"",""field_type"":9}"
"{""last_modified"":1726063457,""created_at"":1726063457,""data"":""George"",""field_type"":0}","{""include_time"":true,""reminder_id"":"""",""field_type"":2,""is_range"":true,""created_at"":1726122521,""end_timestamp"":""1725829200"",""data"":""1725822900"",""last_modified"":1726122535}","{""last_modified"":1726065493,""field_type"":1,""data"":""9500"",""created_at"":1726065493}","{""last_modified"":1726062680,""created_at"":1726062680,""field_type"":1,""data"":""1.7""}","{""data"":""plgeorgebball@gmail.com"",""field_type"":6,""last_modified"":1726064087,""created_at"":1726064036}",,"{""last_modified"":1726064513,""data"":""{\""options\"":[{\""id\"":\""zy0x\"",\""name\"":\""game vs celtics\"",\""color\"":\""Purple\""},{\""id\"":\""WJsv\"",\""name\"":\""training\"",\""color\"":\""Purple\""},{\""id\"":\""w-f8\"",\""name\"":\""game vs spurs\"",\""color\"":\""Purple\""},{\""id\"":\""p1VQ\"",\""name\"":\""game vs knicks\"",\""color\"":\""Purple\""},{\""id\"":\""VjUA\"",\""name\"":\""recovery\"",\""color\"":\""Purple\""},{\""id\"":\""sQ8X\"",\""name\"":\""don't get injured\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064486,""field_type"":7}",,"{""field_type"":3,""last_modified"":1726065310,""data"":""qnja"",""created_at"":1726065310}","{""created_at"":1726105205,""field_type"":4,""last_modified"":1726105249,""data"":""R9I7,1i4f,yORP,SEUo""}","{""data"":""1726122535"",""field_type"":8}","{""field_type"":9,""data"":""1726060546""}"
"{""data"":""Judy"",""created_at"":1726063475,""field_type"":0,""last_modified"":1726063487}","{""end_timestamp"":"""",""reminder_id"":"""",""data"":""1726640950"",""field_type"":2,""include_time"":false,""created_at"":1726122550,""last_modified"":1726122550,""is_range"":false}",,,"{""created_at"":1726063882,""field_type"":6,""last_modified"":1726064000,""data"":""judysmithjr@outlook.com""}","{""last_modified"":1726062712,""field_type"":5,""data"":""Yes"",""created_at"":1726062712}","{""created_at"":1726064549,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""j8cC\"",\""name\"":\""finish training\"",\""color\"":\""Purple\""},{\""id\"":\""SmSk\"",\""name\"":\""brainwash\"",\""color\"":\""Purple\""},{\""id\"":\""mnf5\"",\""name\"":\""welcome to ba sing se\"",\""color\"":\""Purple\""},{\""id\"":\""hcrj\"",\""name\"":\""don't mess up\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""j8cC\"",\""SmSk\"",\""mnf5\"",\""hcrj\""]}"",""last_modified"":1726064591}",,,"{""field_type"":4,""last_modified"":1726105152,""created_at"":1726105152,""data"":""R9I7""}","{""field_type"":8,""data"":""1726122550""}","{""field_type"":9,""data"":""1726060549""}"
@@ -70,11 +70,6 @@ class AIModelStateNotifier {
stateCallback: (state) async {
_localAIState = state;
_updateAll();
if (state.state == RunningStatePB.Running ||
state.state == RunningStatePB.Stopped) {
await _loadModelSelection();
_updateAll();
}
},
);
}
@@ -190,7 +185,7 @@ class AIModelStateNotifier {
}
final enabled = _localAIState!.enabled;
final running = _localAIState!.state == RunningStatePB.Running;
final running = _localAIState!.isReady;
final hintKey = enabled
? (running
? LocaleKeys.chat_inputLocalAIMessageHint
@@ -1,5 +1,6 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_user_cubit.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
@@ -47,6 +48,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
final layerLink = LayerLink();
final overlayController = OverlayPortalController();
final inputControlCubit = ChatInputControlCubit();
final chatUserCubit = ChatUserCubit();
final focusNode = FocusNode();
late SendButtonState sendButtonState;
@@ -55,6 +57,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
@override
void initState() {
super.initState();
chatUserCubit.fetchUserProfile();
widget.textController.addListener(handleTextControllerChanged);
focusNode
@@ -88,13 +91,17 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
focusNode.dispose();
widget.textController.removeListener(handleTextControllerChanged);
inputControlCubit.close();
chatUserCubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: inputControlCubit,
return MultiBlocProvider(
providers: [
BlocProvider.value(value: inputControlCubit),
BlocProvider.value(value: chatUserCubit),
],
child: BlocListener<ChatInputControlCubit, ChatInputControlState>(
listener: (context, state) {
state.maybeWhen(
@@ -634,7 +641,9 @@ class _PromptBottomActions extends StatelessWidget {
const Spacer(),
if (state.modelState.type == AiType.cloud) _selectSourcesButton(),
if (context.read<ChatUserCubit>().supportSelectSource())
_selectSourcesButton(),
if (extraBottomActionButton != null) extraBottomActionButton!,
// _mentionButton(context),
if (state.supportChatWithFile) _attachmentButton(context),
@@ -27,7 +27,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:sentry/sentry.dart';
class MobileHomeScreen extends StatelessWidget {
const MobileHomeScreen({super.key});
@@ -65,14 +64,6 @@ class MobileHomeScreen extends StatelessWidget {
return const WorkspaceFailedScreen();
}
Sentry.configureScope(
(scope) => scope.setUser(
SentryUser(
id: userProfile.id.toString(),
),
),
);
return Scaffold(
body: SafeArea(
bottom: false,
@@ -10,7 +10,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'mobile_search_ask_ai_entrance.dart';
import 'mobile_search_result.dart';
@@ -54,13 +53,6 @@ class MobileSearchScreen extends StatelessWidget {
return const WorkspaceFailedScreen();
}
Sentry.configureScope(
(scope) => scope.setUser(
SentryUser(
id: userProfile.id.toString(),
),
),
);
return Provider.value(
value: userProfile,
child: MobileSearchPage(
@@ -0,0 +1,15 @@
export 'ai_model_switch_listener.dart';
export 'chat_ai_message_bloc.dart';
export 'chat_bloc.dart';
export 'chat_edit_document_service.dart';
export 'chat_entity.dart';
export 'chat_input_control_cubit.dart';
export 'chat_input_file_bloc.dart';
export 'chat_member_bloc.dart';
export 'chat_message_listener.dart';
export 'chat_message_service.dart';
export 'chat_message_stream.dart';
export 'chat_notification.dart';
export 'chat_select_message_bloc.dart';
export 'chat_select_sources_cubit.dart';
export 'chat_user_message_bloc.dart';
@@ -0,0 +1,78 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// ChatUserCubit is responsible for fetching and storing the user profile
class ChatUserCubit extends Cubit<ChatUserState> {
ChatUserCubit() : super(ChatUserLoadingState()) {
fetchUserProfile();
}
/// Fetches the user profile from the AuthService
Future<void> fetchUserProfile() async {
emit(ChatUserLoadingState());
final userOrFailure = await getIt<AuthService>().getUser();
userOrFailure.fold(
(userProfile) => emit(ChatUserSuccessState(userProfile)),
(error) => emit(ChatUserFailureState(error)),
);
}
bool supportSelectSource() {
if (state is ChatUserSuccessState) {
final userProfile = (state as ChatUserSuccessState).userProfile;
if (userProfile.userAuthType == AuthTypePB.Server) {
return true;
}
}
return false;
}
bool isValueWorkspace() {
if (state is ChatUserSuccessState) {
final userProfile = (state as ChatUserSuccessState).userProfile;
return userProfile.workspaceType == WorkspaceTypePB.LocalW &&
userProfile.userAuthType != AuthTypePB.Local;
}
return false;
}
/// Refreshes the user profile data
Future<void> refresh() async {
await fetchUserProfile();
}
}
/// Base state class for ChatUserCubit
abstract class ChatUserState extends Equatable {
const ChatUserState();
@override
List<Object?> get props => [];
}
/// Loading state when fetching user profile
class ChatUserLoadingState extends ChatUserState {}
/// Success state when user profile is fetched successfully
class ChatUserSuccessState extends ChatUserState {
const ChatUserSuccessState(this.userProfile);
final UserProfilePB userProfile;
@override
List<Object?> get props => [userProfile];
}
/// Failure state when fetching user profile fails
class ChatUserFailureState extends ChatUserState {
const ChatUserFailureState(this.error);
final FlowyError error;
@override
List<Object?> get props => [error];
}
@@ -1,39 +1,15 @@
import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
hide ChatAnimatedListReversed;
import 'package:string_validator/string_validator.dart';
import 'package:universal_platform/universal_platform.dart';
import 'package:url_launcher/url_launcher.dart';
import 'application/chat_bloc.dart';
import 'application/chat_entity.dart';
import 'application/chat_member_bloc.dart';
import 'application/chat_select_message_bloc.dart';
import 'application/chat_message_stream.dart';
import 'presentation/animated_chat_list.dart';
import 'presentation/chat_input/mobile_chat_input.dart';
import 'presentation/chat_related_question.dart';
import 'presentation/chat_welcome_page.dart';
import 'presentation/layout_define.dart';
import 'presentation/message/ai_text_message.dart';
import 'presentation/message/error_text_message.dart';
import 'presentation/message/message_util.dart';
import 'presentation/message/user_text_message.dart';
import 'presentation/scroll_to_bottom.dart';
class AIChatPage extends StatelessWidget {
const AIChatPage({
@@ -49,15 +25,6 @@ class AIChatPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// if (userProfile.authenticator != AuthTypePB.Server) {
// return Center(
// child: FlowyText(
// LocaleKeys.chat_unsupportedCloudPrompt.tr(),
// fontSize: 20,
// ),
// );
// }
return MultiBlocProvider(
providers: [
/// [ChatBloc] is used to handle chat messages including send/receive message
@@ -111,7 +78,7 @@ class AIChatPage extends StatelessWidget {
return KeyEventResult.ignored;
},
child: _ChatContentPage(
child: ChatContentPage(
view: view,
userProfile: userProfile,
),
@@ -122,371 +89,3 @@ class AIChatPage extends StatelessWidget {
);
}
}
class _ChatContentPage extends StatelessWidget {
const _ChatContentPage({
required this.view,
required this.userProfile,
});
final UserProfilePB userProfile;
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return switch (state.loadingState) {
LoadChatMessageStatus.ready => Column(
children: [
ChatMessageSelectorBanner(
view: view,
allMessages: context.read<ChatBloc>().chatController.messages,
),
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: _wrapConstraints(
SelectionArea(
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(scrollbars: false),
child: Chat(
chatController:
context.read<ChatBloc>().chatController,
user: User(id: userProfile.id.toString()),
darkTheme:
ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: _buildTextMessage,
chatMessageBuilder: _buildChatMessage,
scrollToBottomBuilder: _buildScrollToBottom,
chatAnimatedListBuilder: _buildChatAnimatedList,
),
),
),
),
),
),
),
_wrapConstraints(
_Input(view: view),
),
],
),
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
},
);
}
Widget _wrapConstraints(Widget child) {
return Container(
constraints: const BoxConstraints(maxWidth: 784),
margin: UniversalPlatform.isDesktop
? const EdgeInsets.symmetric(horizontal: 60.0)
: null,
child: child,
);
}
Widget _buildTextMessage(
BuildContext context,
TextMessage message,
) {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.error) {
return ChatErrorMessageWidget(
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) {
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
);
}
if (message.author.id == userProfile.id.toString() ||
isOtherUserMessage(message)) {
return ChatUserMessageWidget(
user: message.author,
message: message,
);
}
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: message,
stream: stream is AnswerStream ? stream : null,
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming:
state.promptResponseState != PromptResponseState.ready,
isLastMessage: isLastMessage,
isSelectingMessages: isSelectingMessages,
onSelectedMetadata: (metadata) =>
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
onChangeModel: (model) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
onStopStream: () => context.read<ChatBloc>().add(
const ChatEvent.stopStream(),
),
);
},
);
},
);
}
Widget _buildChatMessage(
BuildContext context,
Message message,
Animation<double> animation,
Widget child,
) {
return ChatMessage(
message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 18.0),
receivedMessageScaleAnimationAlignment: Alignment.center,
child: child,
);
}
Widget _buildScrollToBottom(
BuildContext context,
Animation<double> animation,
VoidCallback onPressed,
) {
return CustomScrollToBottom(
animation: animation,
onPressed: onPressed,
);
}
Widget _buildChatAnimatedList(
BuildContext context,
ScrollController scrollController,
ChatItem itemBuilder,
) {
final bloc = context.read<ChatBloc>();
if (bloc.chatController.messages.isEmpty) {
return ChatWelcomePage(
userProfile: userProfile,
onSelectedQuestion: (question) {
final aiPromptInputBloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats =
aiPromptInputBloc.state.showPredefinedFormats;
final predefinedFormat = aiPromptInputBloc.state.predefinedFormat;
bloc.add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
);
}
context
.read<ChatSelectMessageBloc>()
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return ChatAnimatedListReversed(
scrollController: scrollController,
itemBuilder: itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
},
);
}
void _onSelectMetadata(
BuildContext context,
ChatMessageRefSource metadata,
) async {
// When the source of metatdata is appflowy, which means it is a appflowy page
if (metadata.source == "appflowy") {
final sidebarView =
await ViewBackendService.getView(metadata.id).toNullable();
if (context.mounted) {
openPageFromMessage(context, sidebarView);
}
return;
}
if (metadata.source == "web") {
if (isURL(metadata.name)) {
late Uri uri;
try {
uri = Uri.parse(metadata.name);
// `Uri` identifies `localhost` as a scheme
if (!uri.hasScheme || uri.scheme == 'localhost') {
uri = Uri.parse("http://${metadata.name}");
await InternetAddress.lookup(uri.host);
}
await launchUrl(uri);
} catch (err) {
Log.error("failed to open url $err");
}
}
return;
}
}
}
class _Input extends StatefulWidget {
const _Input({
required this.view,
});
final ViewPB view;
@override
State<_Input> createState() => _InputState();
}
class _InputState extends State<_Input> {
final textController = AiPromptInputTextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) {
return NonClippingSizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
);
},
child: isSelectingMessages
? const SizedBox.shrink()
: Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return state.promptResponseState ==
PromptResponseState.ready;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
return UniversalPlatform.isDesktop
? DesktopPromptInput(
isStreaming: !canSendMessage,
textController: textController,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier:
chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
)
: MobileChatInput(
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier:
chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
},
),
),
);
},
);
}
}
@@ -11,8 +11,8 @@ import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
import 'package:provider/provider.dart';
class ChatAnimatedListReversed extends StatefulWidget {
const ChatAnimatedListReversed({
class ChatAnimatedList extends StatefulWidget {
const ChatAnimatedList({
super.key,
required this.scrollController,
required this.itemBuilder,
@@ -22,6 +22,7 @@ class ChatAnimatedListReversed extends StatefulWidget {
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
this.bottomPadding = 8,
this.onLoadPreviousMessages,
this.scrollBottomPadding = 440,
});
final ScrollController scrollController;
@@ -32,16 +33,19 @@ class ChatAnimatedListReversed extends StatefulWidget {
final Duration scrollToBottomAppearanceDelay;
final double? bottomPadding;
final VoidCallback? onLoadPreviousMessages;
final double scrollBottomPadding;
@override
ChatAnimatedListReversedState createState() =>
ChatAnimatedListReversedState();
ChatAnimatedListState createState() => ChatAnimatedListState();
}
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
class ChatAnimatedListState extends State<ChatAnimatedList>
with SingleTickerProviderStateMixin {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
late ChatController _chatController;
late final ChatController _chatController = Provider.of<ChatController>(
context,
listen: false,
);
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
@@ -51,12 +55,12 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
bool _userHasScrolled = false;
bool _isScrollingToBottom = false;
String _lastInsertedMessageId = '';
// String _lastInsertedMessageId = '';
@override
void initState() {
super.initState();
_chatController = Provider.of<ChatController>(context, listen: false);
// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
@@ -109,12 +113,14 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
);
widget.scrollController.addListener(_handleLoadPreviousMessages);
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleLoadPreviousMessages();
});
@@ -132,6 +138,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
final height = MediaQuery.of(context).size.height;
return NotificationListener<Notification>(
onNotification: (notification) {
@@ -142,7 +149,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.minScrollExtent) {
notification.metrics.maxScrollExtent) {
_userHasScrolled = false;
}
}
@@ -158,8 +165,8 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
child: Stack(
children: [
CustomScrollView(
reverse: true,
controller: widget.scrollController,
physics: ClampingScrollPhysics(),
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.only(
@@ -174,15 +181,22 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
int index,
Animation<double> animation,
) {
final message = _chatController.messages[
max(_chatController.messages.length - 1 - index, 0)];
return widget.itemBuilder(
final message = _chatController.messages[index];
final child = widget.itemBuilder(
context,
animation,
message,
);
return RepaintBoundary(
child: child,
);
},
),
SliverPadding(
padding: EdgeInsets.only(
bottom: max(0, height - widget.scrollBottomPadding),
),
),
],
),
builders.scrollToBottomBuilder?.call(
@@ -199,53 +213,62 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
);
}
void _subsequentScrollToEnd(Message data) async {
final user = Provider.of<User>(context, listen: false);
// void _subsequentScrollToEnd(Message data) async {
// final user = Provider.of<User>(context, listen: false);
// We only want to scroll to the bottom if user has not scrolled up
// or if the message is sent by the current user.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
// // We only want to scroll to the bottom if user has not scrolled up
// // or if the message is sent by the current user.
// if (data.id == _lastInsertedMessageId &&
// widget.scrollController.offset <
// widget.scrollController.position.maxScrollExtent &&
// (user.id != data.author.id && !_userHasScrolled)) {
// if (widget.scrollToEndAnimationDuration == Duration.zero) {
// widget.scrollController
// .jumpTo(widget.scrollController.position.maxScrollExtent);
// } else {
// await widget.scrollController.animateTo(
// widget.scrollController.position.maxScrollExtent,
// duration: widget.scrollToEndAnimationDuration,
// curve: Curves.linearToEaseOut,
// );
// }
if (!widget.scrollController.hasClients || !mounted) return;
// if (!widget.scrollController.hasClients || !mounted) return;
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
// we need an additional jump to the end. Sometimes Flutter
// will not scroll to the very end. Sometimes it will not scroll to the
// very end even with this, so this is something that needs to be
// addressed by the Flutter team.
//
// Additionally here we have a check for the message id, because
// if new message arrives in the meantime it will trigger another
// scroll to the end animation, making this logic redundant.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
}
}
}
// // Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
// // we need an additional jump to the end. Sometimes Flutter
// // will not scroll to the very end. Sometimes it will not scroll to the
// // very end even with this, so this is something that needs to be
// // addressed by the Flutter team.
// //
// // Additionally here we have a check for the message id, because
// // if new message arrives in the meantime it will trigger another
// // scroll to the end animation, making this logic redundant.
// if (data.id == _lastInsertedMessageId &&
// widget.scrollController.offset <
// widget.scrollController.position.maxScrollExtent &&
// (user.id == data.author.id && _userHasScrolled)) {
// widget.scrollController
// .jumpTo(widget.scrollController.position.maxScrollExtent);
// }
// }
// }
void _scrollToEnd(Message data) {
void _scrollToTop(Message data) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
_subsequentScrollToEnd(data);
final user = Provider.of<User>(context, listen: false);
final lastMessage = _chatController.messages.last;
if (lastMessage.author.id == user.id) {
// scroll the current message to the top
widget.scrollController.animateTo(
widget.scrollController.position.maxScrollExtent,
duration: Durations.medium4,
curve: Curves.linearToEaseOut,
);
}
},
);
}
@@ -257,12 +280,16 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!widget.scrollController.hasClients || !mounted) return;
final height = MediaQuery.of(context).size.height;
final paddingHeight = height - widget.scrollBottomPadding;
final offset =
widget.scrollController.position.maxScrollExtent - paddingHeight;
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
widget.scrollController.jumpTo(offset);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
offset,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
@@ -270,13 +297,6 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollController.offset <
widget.scrollController.position.minScrollExtent) {
widget.scrollController.jumpTo(
widget.scrollController.position.minScrollExtent,
);
}
_isScrollingToBottom = false;
});
}
@@ -286,9 +306,11 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
return;
}
final paddingHeight =
MediaQuery.of(context).size.height - widget.scrollBottomPadding;
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
if (widget.scrollController.offset <
widget.scrollController.position.maxScrollExtent - paddingHeight) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
@@ -312,8 +334,8 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
// animation is triggered.
if (position == 0 &&
_userHasScrolled &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
widget.scrollController.offset <
widget.scrollController.position.maxScrollExtent) {
_userHasScrolled = false;
}
@@ -323,10 +345,11 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
);
// Used later to trigger scroll to end only for the last inserted message.
_lastInsertedMessageId = data.id;
// _lastInsertedMessageId = data.id;
if (position == _oldList.length) {
_scrollToEnd(data);
_scrollToTop(data);
// _scrollToEnd(data);
}
}
@@ -0,0 +1,375 @@
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:math';
import 'package:diffutil_dart/diffutil.dart' as diffutil;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
import 'package:provider/provider.dart';
class ChatAnimatedListReversed extends StatefulWidget {
const ChatAnimatedListReversed({
super.key,
required this.scrollController,
required this.itemBuilder,
this.insertAnimationDuration = const Duration(milliseconds: 250),
this.removeAnimationDuration = const Duration(milliseconds: 250),
this.scrollToEndAnimationDuration = const Duration(milliseconds: 250),
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
this.bottomPadding = 8,
this.onLoadPreviousMessages,
});
final ScrollController scrollController;
final ChatItem itemBuilder;
final Duration insertAnimationDuration;
final Duration removeAnimationDuration;
final Duration scrollToEndAnimationDuration;
final Duration scrollToBottomAppearanceDelay;
final double? bottomPadding;
final VoidCallback? onLoadPreviousMessages;
@override
ChatAnimatedListReversedState createState() =>
ChatAnimatedListReversedState();
}
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
with SingleTickerProviderStateMixin {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
late final ChatController _chatController = Provider.of<ChatController>(
context,
listen: false,
);
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
late final AnimationController _scrollToBottomController;
late final Animation<double> _scrollToBottomAnimation;
Timer? _scrollToBottomShowTimer;
bool _userHasScrolled = false;
bool _isScrollingToBottom = false;
String _lastInsertedMessageId = '';
@override
void initState() {
super.initState();
// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
switch (event.type) {
case ChatOperationType.insert:
assert(
event.index != null,
'Index must be provided when inserting a message.',
);
assert(
event.message != null,
'Message must be provided when inserting a message.',
);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
case ChatOperationType.remove:
assert(
event.index != null,
'Index must be provided when removing a message.',
);
assert(
event.message != null,
'Message must be provided when removing a message.',
);
_onRemoved(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
case ChatOperationType.set:
final newList = _chatController.messages;
final updates = diffutil
.calculateDiff<Message>(
MessageListDiff(_oldList, newList),
)
.getUpdatesWithData();
for (var i = updates.length - 1; i >= 0; i--) {
_onDiffUpdate(updates.elementAt(i));
}
_oldList = List.from(newList);
break;
default:
break;
}
});
_scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
);
widget.scrollController.addListener(_handleLoadPreviousMessages);
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleLoadPreviousMessages();
});
}
@override
void dispose() {
super.dispose();
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.dispose();
_operationsSubscription.cancel();
widget.scrollController.removeListener(_handleLoadPreviousMessages);
}
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
return NotificationListener<Notification>(
onNotification: (notification) {
if (notification is UserScrollNotification) {
// When user scrolls up, save it to `_userHasScrolled`
if (notification.direction == ScrollDirection.reverse) {
_userHasScrolled = true;
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.minScrollExtent) {
_userHasScrolled = false;
}
}
}
if (notification is ScrollUpdateNotification) {
_handleToggleScrollToBottom();
}
// Allow other listeners to get the notification
return false;
},
child: Stack(
children: [
CustomScrollView(
reverse: true,
controller: widget.scrollController,
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.only(
top: widget.bottomPadding ?? 0,
),
),
SliverAnimatedList(
key: _listKey,
initialItemCount: _chatController.messages.length,
itemBuilder: (
BuildContext context,
int index,
Animation<double> animation,
) {
final message = _chatController.messages[
max(_chatController.messages.length - 1 - index, 0)];
return widget.itemBuilder(
context,
animation,
message,
);
},
),
],
),
builders.scrollToBottomBuilder?.call(
context,
_scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: _scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
),
],
),
);
}
void _subsequentScrollToEnd(Message data) async {
final user = Provider.of<User>(context, listen: false);
// We only want to scroll to the bottom if user has not scrolled up
// or if the message is sent by the current user.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
if (!widget.scrollController.hasClients || !mounted) return;
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
// we need an additional jump to the end. Sometimes Flutter
// will not scroll to the very end. Sometimes it will not scroll to the
// very end even with this, so this is something that needs to be
// addressed by the Flutter team.
//
// Additionally here we have a check for the message id, because
// if new message arrives in the meantime it will trigger another
// scroll to the end animation, making this logic redundant.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
}
}
}
void _scrollToEnd(Message data) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
_subsequentScrollToEnd(data);
},
);
}
void _handleScrollToBottom() {
_isScrollingToBottom = true;
_scrollToBottomController.reverse();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollController.offset <
widget.scrollController.position.minScrollExtent) {
widget.scrollController.jumpTo(
widget.scrollController.position.minScrollExtent,
);
}
_isScrollingToBottom = false;
});
}
void _handleToggleScrollToBottom() {
if (_isScrollingToBottom) {
return;
}
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
}
});
} else {
if (_scrollToBottomController.status != AnimationStatus.completed) {
_scrollToBottomController.stop();
}
_scrollToBottomController.reverse();
}
}
void _onInserted(final int position, final Message data) {
// There is a scroll notification listener the controls the
// `_userHasScrolled` variable.
//
// If for some reason `_userHasScrolled` is true and the user is not at the
// bottom of the list, set `_userHasScrolled` to false so that the scroll
// animation is triggered.
if (position == 0 &&
_userHasScrolled &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_userHasScrolled = false;
}
_listKey.currentState!.insertItem(
0,
duration: widget.insertAnimationDuration,
);
// Used later to trigger scroll to end only for the last inserted message.
_lastInsertedMessageId = data.id;
if (position == _oldList.length) {
_scrollToEnd(data);
}
}
void _onRemoved(final int position, final Message data) {
final visualPosition = max(_oldList.length - position - 1, 0);
_listKey.currentState!.removeItem(
visualPosition,
(context, animation) => widget.itemBuilder(
context,
animation,
data,
isRemoved: true,
),
duration: widget.removeAnimationDuration,
);
}
void _onChanged(int position, Message oldData, Message newData) {
_onRemoved(position, oldData);
_listKey.currentState!.insertItem(
max(_oldList.length - position - 1, 0),
duration: widget.insertAnimationDuration,
);
}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
update.when<void>(
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
remove: (pos, data) => _onRemoved(pos, data),
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
move: (_, __, ___) => throw UnimplementedError('unused'),
);
}
void _handleLoadPreviousMessages() {
if (widget.scrollController.offset >=
widget.scrollController.position.maxScrollExtent) {
widget.onLoadPreviousMessages?.call();
}
}
}
@@ -0,0 +1,89 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/animated_chat_list.dart';
import 'package:appflowy/plugins/ai_chat/presentation/animated_chat_list_reversed.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_welcome_page.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
class ChatAnimationListWidget extends StatefulWidget {
const ChatAnimationListWidget({
super.key,
required this.userProfile,
required this.scrollController,
required this.itemBuilder,
this.enableReversedList = false,
});
final UserProfilePB userProfile;
final ScrollController scrollController;
final ChatItem itemBuilder;
final bool enableReversedList;
@override
State<ChatAnimationListWidget> createState() =>
_ChatAnimationListWidgetState();
}
class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
@override
Widget build(BuildContext context) {
final bloc = context.read<ChatBloc>();
// this logic is quite weird, why don't we just get the message from the state?
if (bloc.chatController.messages.isEmpty) {
return ChatWelcomePage(
userProfile: widget.userProfile,
onSelectedQuestion: (question) {
final aiPromptInputBloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats =
aiPromptInputBloc.state.showPredefinedFormats;
final predefinedFormat = aiPromptInputBloc.state.predefinedFormat;
bloc.add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
);
}
// don't call this in the build method
context
.read<ChatSelectMessageBloc>()
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
// final bool reversed = false;
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return widget.enableReversedList
? ChatAnimatedListReversed(
scrollController: widget.scrollController,
itemBuilder: widget.itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
)
: ChatAnimatedList(
scrollController: widget.scrollController,
itemBuilder: widget.itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
},
);
}
}
@@ -0,0 +1,33 @@
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/load_chat_message_status_ready.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ChatContentPage extends StatelessWidget {
const ChatContentPage({
super.key,
required this.view,
required this.userProfile,
});
final UserProfilePB userProfile;
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return switch (state.loadingState) {
LoadChatMessageStatus.ready => LoadChatMessageStatusReady(
view: view,
userProfile: userProfile,
chatController: context.read<ChatBloc>().chatController,
),
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
},
);
}
}
@@ -0,0 +1,137 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
class ChatFooter extends StatefulWidget {
const ChatFooter({
super.key,
required this.view,
});
final ViewPB view;
@override
State<ChatFooter> createState() => _ChatFooterState();
}
class _ChatFooterState extends State<ChatFooter> {
final textController = AiPromptInputTextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) {
return NonClippingSizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
);
},
child: isSelectingMessages
? const SizedBox.shrink()
: Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return state.promptResponseState ==
PromptResponseState.ready;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
return UniversalPlatform.isDesktop
? _buildDesktopInput(
context,
chatBloc,
canSendMessage,
)
: _buildMobileInput(
context,
chatBloc,
canSendMessage,
);
},
),
),
);
},
);
}
Widget _buildDesktopInput(
BuildContext context,
ChatBloc chatBloc,
bool canSendMessage,
) {
return DesktopPromptInput(
isStreaming: !canSendMessage,
textController: textController,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier: chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
}
Widget _buildMobileInput(
BuildContext context,
ChatBloc chatBloc,
bool canSendMessage,
) {
return MobileChatInput(
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier: chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
}
}
@@ -0,0 +1,117 @@
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_footer.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/text_message_widget.dart';
import 'package:appflowy/plugins/ai_chat/presentation/scroll_to_bottom.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:universal_platform/universal_platform.dart';
class LoadChatMessageStatusReady extends StatelessWidget {
const LoadChatMessageStatusReady({
super.key,
required this.view,
required this.userProfile,
required this.chatController,
});
final ViewPB view;
final UserProfilePB userProfile;
final ChatController chatController;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Chat header, banner
_buildHeader(context),
// Chat body, a list of messages
_buildBody(context),
// Chat footer, a text input field with toolbar, send button, etc.
_buildFooter(context),
],
);
}
Widget _buildHeader(BuildContext context) {
return ChatMessageSelectorBanner(
view: view,
allMessages: chatController.messages,
);
}
Widget _buildBody(BuildContext context) {
return Expanded(
child: Align(
alignment: Alignment.topCenter,
child: _wrapConstraints(
SelectionArea(
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
scrollbars: false,
),
child: Chat(
chatController: chatController,
user: User(id: userProfile.id.toString()),
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
// we have a custom input builder, so we don't need the default one
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: (context, message) => TextMessageWidget(
message: message,
userProfile: userProfile,
view: view,
),
chatMessageBuilder: (context, message, animation, child) =>
ChatMessage(
message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 18.0),
receivedMessageScaleAnimationAlignment: Alignment.center,
child: child,
),
scrollToBottomBuilder: (context, animation, onPressed) =>
CustomScrollToBottom(
animation: animation,
onPressed: onPressed,
),
chatAnimatedListBuilder: (
context,
scrollController,
itemBuilder,
) =>
ChatAnimationListWidget(
userProfile: userProfile,
scrollController: scrollController,
itemBuilder: itemBuilder,
enableReversedList: true,
),
),
),
),
),
),
),
);
}
Widget _buildFooter(BuildContext context) {
return _wrapConstraints(
ChatFooter(view: view),
);
}
Widget _wrapConstraints(Widget child) {
return Container(
constraints: const BoxConstraints(maxWidth: 784),
margin: UniversalPlatform.isDesktop
? const EdgeInsets.symmetric(horizontal: 60.0)
: null,
child: child,
);
}
}
@@ -0,0 +1,154 @@
import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_text_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/error_text_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/message_util.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/user_text_message.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:string_validator/string_validator.dart';
import 'package:url_launcher/url_launcher.dart';
class TextMessageWidget extends StatelessWidget {
const TextMessageWidget({
super.key,
required this.message,
required this.userProfile,
required this.view,
this.enableAnimation = true,
});
final TextMessage message;
final UserProfilePB userProfile;
final ViewPB view;
final bool enableAnimation;
@override
Widget build(BuildContext context) {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.error) {
return ChatErrorMessageWidget(
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) {
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
);
}
if (message.author.id == userProfile.id.toString() ||
isOtherUserMessage(message)) {
return ChatUserMessageWidget(
user: message.author,
message: message,
);
}
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: message,
stream: stream is AnswerStream ? stream : null,
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming:
state.promptResponseState != PromptResponseState.ready,
isLastMessage: isLastMessage,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
onSelectedMetadata: (metadata) =>
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
onChangeModel: (model) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
onStopStream: () => context.read<ChatBloc>().add(
const ChatEvent.stopStream(),
),
);
},
);
},
);
}
void _onSelectMetadata(
BuildContext context,
ChatMessageRefSource metadata,
) async {
// When the source of metatdata is appflowy, which means it is a appflowy page
if (metadata.source == "appflowy") {
final sidebarView =
await ViewBackendService.getView(metadata.id).toNullable();
if (context.mounted) {
openPageFromMessage(context, sidebarView);
}
return;
}
if (metadata.source == "web") {
if (isURL(metadata.name)) {
late Uri uri;
try {
uri = Uri.parse(metadata.name);
// `Uri` identifies `localhost` as a scheme
if (!uri.hasScheme || uri.scheme == 'localhost') {
uri = Uri.parse("http://${metadata.name}");
await InternetAddress.lookup(uri.host);
}
await launchUrl(uri);
} catch (err) {
Log.error("failed to open url $err");
}
}
return;
}
}
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
@@ -17,16 +19,21 @@ class AIMarkdownText extends StatelessWidget {
const AIMarkdownText({
super.key,
required this.markdown,
this.withAnimation = false,
});
final String markdown;
final bool withAnimation;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DocumentPageStyleBloc(view: ViewPB())
..add(const DocumentPageStyleEvent.initial()),
child: _AppFlowyEditorMarkdown(markdown: markdown),
child: _AppFlowyEditorMarkdown(
markdown: markdown,
withAnimation: withAnimation,
),
);
}
}
@@ -34,19 +41,28 @@ class AIMarkdownText extends StatelessWidget {
class _AppFlowyEditorMarkdown extends StatefulWidget {
const _AppFlowyEditorMarkdown({
required this.markdown,
this.withAnimation = false,
});
// the text should be the markdown format
final String markdown;
/// Whether to animate the text.
final bool withAnimation;
@override
State<_AppFlowyEditorMarkdown> createState() =>
_AppFlowyEditorMarkdownState();
}
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown>
with TickerProviderStateMixin {
late EditorState editorState;
late EditorScrollController scrollController;
late Timer markdownOutputTimer;
int offset = 0;
final Map<String, (AnimationController, Animation<double>)> _animations = {};
@override
void initState() {
@@ -57,13 +73,46 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
editorState: editorState,
shrinkWrap: true,
);
if (widget.withAnimation) {
markdownOutputTimer =
Timer.periodic(const Duration(milliseconds: 60), (timer) {
if (offset >= widget.markdown.length || !widget.withAnimation) {
return;
}
final markdown = widget.markdown.substring(0, offset);
offset += 30;
final editorState = _parseMarkdown(
markdown,
previousDocument: this.editorState.document,
);
final lastCurrentNode = editorState.document.last;
final lastPreviousNode = this.editorState.document.last;
if (lastCurrentNode?.id != lastPreviousNode?.id ||
lastCurrentNode?.type != lastPreviousNode?.type ||
lastCurrentNode?.delta?.toPlainText() !=
lastPreviousNode?.delta?.toPlainText()) {
setState(() {
this.editorState.dispose();
this.editorState = editorState;
scrollController.dispose();
scrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: true,
);
});
}
});
}
}
@override
void didUpdateWidget(covariant _AppFlowyEditorMarkdown oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.markdown != widget.markdown) {
if (oldWidget.markdown != widget.markdown && !widget.withAnimation) {
final editorState = _parseMarkdown(
widget.markdown.trim(),
previousDocument: this.editorState.document,
@@ -83,6 +132,13 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
scrollController.dispose();
editorState.dispose();
if (widget.withAnimation) {
markdownOutputTimer.cancel();
for (final controller in _animations.values.map((e) => e.$1)) {
controller.dispose();
}
}
super.dispose();
}
@@ -120,6 +176,33 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
commandShortcutEvents: [customCopyCommand],
disableAutoScroll: true,
editorState: editorState,
blockWrapper: (
context, {
required Node node,
required Widget child,
}) {
if (!widget.withAnimation) {
return child;
}
if (!_animations.containsKey(node.id)) {
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
);
final fade = Tween<double>(
begin: 0,
end: 1,
).animate(controller);
_animations[node.id] = (controller, fade);
controller.forward();
}
final (controller, fade) = _animations[node.id]!;
return _AnimatedWrapper(
fade: fade,
child: child,
);
},
contextMenuItems: [
[
ContextMenuItem(
@@ -161,3 +244,39 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
return editorState;
}
}
class _AnimatedWrapper extends StatelessWidget {
const _AnimatedWrapper({
required this.fade,
required this.child,
});
final Animation<double> fade;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: fade,
builder: (context, childWidget) {
return ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
stops: [fade.value, fade.value],
colors: const [
Colors.white,
Colors.transparent,
],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: Opacity(
opacity: fade.value,
child: childWidget,
),
);
},
child: child,
);
}
}
@@ -23,9 +23,9 @@ import 'package:go_router/go_router.dart';
import 'package:universal_platform/universal_platform.dart';
import '../layout_define.dart';
import 'ai_change_format_bottom_sheet.dart';
import 'ai_change_model_bottom_sheet.dart';
import 'ai_message_action_bar.dart';
import 'ai_change_format_bottom_sheet.dart';
import 'message_util.dart';
/// Wraps an AI response message with the avatar and actions. On desktop,
@@ -132,7 +132,7 @@ class ChatAIBottomInlineActions extends StatelessWidget {
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
),
const VSpace(32.0),
const VSpace(16.0),
],
);
}
@@ -41,6 +41,7 @@ class ChatAIMessageWidget extends StatelessWidget {
this.isLastMessage = false,
this.isStreaming = false,
this.isSelectingMessages = false,
this.enableAnimation = true,
});
final User user;
@@ -59,6 +60,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final bool isStreaming;
final bool isLastMessage;
final bool isSelectingMessages;
final bool enableAnimation;
@override
Widget build(BuildContext context) {
@@ -96,41 +98,27 @@ class ChatAIMessageWidget extends StatelessWidget {
),
ready: () {
return state.text.isEmpty
? ChatAIMessageBubble(
? _LoadingMessage(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
loadingText: loadingText,
)
: ChatAIMessageBubble(
: _NonEmptyMessage(
user: user,
messageUserId: messageUserId,
message: message,
isLastMessage: isLastMessage,
showActions: stream == null &&
state.text.isNotEmpty &&
!isStreaming,
isSelectingMessages: isSelectingMessages,
stream: stream,
questionId: questionId,
chatId: chatId,
refSourceJsonString: refSourceJsonString,
onStopStream: onStopStream,
onSelectedMetadata: onSelectedMetadata,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AIMarkdownText(
markdown: state.text,
),
if (state.sources.isNotEmpty)
SelectionContainer.disabled(
child: AIMessageMetadata(
sources: state.sources,
onSelectedMetadata: onSelectedMetadata,
),
),
if (state.sources.isNotEmpty && !isLastMessage)
const VSpace(8.0),
],
),
isLastMessage: isLastMessage,
isStreaming: isStreaming,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
);
},
onError: (error) {
@@ -171,3 +159,96 @@ class ChatAIMessageWidget extends StatelessWidget {
);
}
}
class _LoadingMessage extends StatelessWidget {
const _LoadingMessage({
required this.message,
required this.loadingText,
});
final Message message;
final String loadingText;
@override
Widget build(BuildContext context) {
return ChatAIMessageBubble(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
);
}
}
class _NonEmptyMessage extends StatelessWidget {
const _NonEmptyMessage({
required this.user,
required this.messageUserId,
required this.message,
required this.stream,
required this.questionId,
required this.chatId,
required this.refSourceJsonString,
required this.onStopStream,
this.onSelectedMetadata,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
this.isLastMessage = false,
this.isStreaming = false,
this.isSelectingMessages = false,
this.enableAnimation = true,
});
final User user;
final String messageUserId;
final Message message;
final AnswerStream? stream;
final Int64? questionId;
final String chatId;
final String? refSourceJsonString;
final ValueChanged<ChatMessageRefSource>? onSelectedMetadata;
final VoidCallback? onRegenerate;
final VoidCallback onStopStream;
final ValueChanged<PredefinedFormat>? onChangeFormat;
final ValueChanged<AIModelPB>? onChangeModel;
final bool isStreaming;
final bool isLastMessage;
final bool isSelectingMessages;
final bool enableAnimation;
@override
Widget build(BuildContext context) {
final state = context.read<ChatAIMessageBloc>().state;
final showActions = stream == null && state.text.isNotEmpty && !isStreaming;
return ChatAIMessageBubble(
message: message,
isLastMessage: isLastMessage,
showActions: showActions,
isSelectingMessages: isSelectingMessages,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AIMarkdownText(
markdown: state.text,
withAnimation: enableAnimation && stream != null,
),
if (state.sources.isNotEmpty)
SelectionContainer.disabled(
child: AIMessageMetadata(
sources: state.sources,
onSelectedMetadata: onSelectedMetadata,
),
),
if (state.sources.isNotEmpty && !isLastMessage) const VSpace(8.0),
],
),
);
}
}
@@ -15,6 +15,7 @@ class CustomPageBlockComponentBuilder extends BlockComponentBuilder {
node: blockComponentContext.node,
header: blockComponentContext.header,
footer: blockComponentContext.footer,
wrapper: blockComponentContext.wrapper,
);
}
}
@@ -29,10 +30,12 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
super.configuration = const BlockComponentConfiguration(),
this.header,
this.footer,
this.wrapper,
});
final Widget? header;
final Widget? footer;
final BlockComponentWrapper? wrapper;
@override
Widget build(BuildContext context) {
@@ -52,14 +55,20 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
children: [
if (header != null) header!,
...items.map(
(e) => Container(
constraints: BoxConstraints(
maxWidth:
editorState.editorStyle.maxWidth ?? double.infinity,
),
padding: editorState.editorStyle.padding,
child: editorState.renderer.build(context, e),
),
(e) {
Widget child = editorState.renderer.build(context, e);
if (wrapper != null) {
child = wrapper!(context, node: e, child: child);
}
return Container(
constraints: BoxConstraints(
maxWidth:
editorState.editorStyle.maxWidth ?? double.infinity,
),
padding: editorState.editorStyle.padding,
child: child,
);
},
),
if (footer != null) footer!,
],
@@ -92,6 +101,11 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
final childNode = items[index - (header != null ? 1 : 0)];
final isOverflowType = overflowTypes.contains(childNode.type);
Widget child = editorState.renderer.build(context, childNode);
if (wrapper != null) {
child = wrapper!(context, node: childNode, child: child);
}
final item = Container(
constraints: BoxConstraints(
maxWidth: editorState.editorStyle.maxWidth ?? double.infinity,
@@ -99,10 +113,7 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
padding: isOverflowType
? EdgeInsets.zero
: editorState.editorStyle.padding,
child: editorState.renderer.build(
context,
childNode,
),
child: child,
);
return isOverflowType ? item : Center(child: item);
@@ -121,7 +121,6 @@ class FlowyRunner {
// don't catch errors in test mode
if (!mode.isUnitTest && !mode.isIntegrationTest)
const PlatformErrorCatcherTask(),
if (!mode.isUnitTest) const InitSentryTask(),
// this task should be second task, for handling memory leak.
// there's a flag named _enable in memory_leak_detector.dart. If it's false, the task will be ignored.
MemoryLeakDetectorTask(),
@@ -13,5 +13,4 @@ export 'platform_error_catcher.dart';
export 'platform_service.dart';
export 'recent_service_task.dart';
export 'rust_sdk.dart';
export 'sentry.dart';
export 'windows.dart';
@@ -1,30 +0,0 @@
import 'package:appflowy/env/env.dart';
import 'package:appflowy_backend/log.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../startup.dart';
class InitSentryTask extends LaunchTask {
const InitSentryTask();
@override
Future<void> initialize(LaunchContext context) async {
await super.initialize(context);
const dsn = Env.sentryDsn;
if (dsn.isEmpty) {
Log.info('Sentry DSN is not set, skipping initialization');
return;
}
Log.info('Initializing Sentry');
await SentryFlutter.init(
(options) {
options.dsn = dsn;
options.tracesSampleRate = 0.1;
options.profilesSampleRate = 0.1;
},
);
}
}
@@ -49,7 +49,6 @@ class CommandPaletteBloc
final TrashService _trashService = TrashService();
final TrashListener _trashListener = TrashListener();
String? _activeQuery;
String? _workspaceId;
@override
Future<void> close() {
@@ -115,7 +114,6 @@ class CommandPaletteBloc
unawaited(
SearchBackendService.performSearch(
event.search,
workspaceId: _workspaceId,
).then(
(result) => result.fold(
(stream) {
@@ -161,6 +159,7 @@ class CommandPaletteBloc
onServerItems: (items, searchId, searching, generatingAIOverview) =>
_handleResultsUpdate(
searchId: searchId,
summaries: [], // when got server search result, summaries should be empty
serverItems: items,
searching: searching,
generatingAIOverview: generatingAIOverview,
@@ -263,7 +262,6 @@ class CommandPaletteBloc
_WorkspaceChanged event,
Emitter<CommandPaletteState> emit,
) {
_workspaceId = event.workspaceId;
emit(
state.copyWith(
query: '',
@@ -8,28 +8,23 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/query.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/search_filter.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:nanoid/nanoid.dart';
import 'package:fixnum/fixnum.dart';
class SearchBackendService {
static Future<FlowyResult<SearchResponseStream, FlowyError>> performSearch(
String keyword, {
String? workspaceId,
}) async {
final searchId = nanoid(6);
String keyword,
) async {
final searchId = DateTime.now().millisecondsSinceEpoch.toString();
final stream = SearchResponseStream(searchId: searchId);
final filter = SearchFilterPB(workspaceId: workspaceId);
final request = SearchQueryPB(
search: keyword,
filter: filter,
searchId: searchId,
streamPort: Int64(stream.nativePort),
);
unawaited(SearchEventSearch(request).send());
unawaited(SearchEventStreamSearch(request).send());
return FlowyResult.success(stream);
}
}
@@ -39,8 +39,7 @@ class LocalAiPluginBloc extends Bloc<LocalAiPluginEvent, LocalAiPluginState> {
emit(
LocalAiPluginState.ready(
isEnabled: aiState.enabled,
version: aiState.pluginVersion,
runningState: aiState.state,
isReady: aiState.isReady,
lackOfResource:
aiState.hasLackOfResource() ? aiState.lackOfResource : null,
),
@@ -116,8 +115,7 @@ class LocalAiPluginState with _$LocalAiPluginState {
const factory LocalAiPluginState.ready({
required bool isEnabled,
required String version,
required RunningStatePB runningState,
required bool isReady,
required LackOfAIResourcePB? lackOfResource,
}) = ReadyLocalAiPluginState;
@@ -125,15 +123,15 @@ class LocalAiPluginState with _$LocalAiPluginState {
bool get isEnabled {
return maybeWhen(
ready: (isEnabled, _, __, ___) => isEnabled,
ready: (isEnabled, _, ___) => isEnabled,
orElse: () => false,
);
}
bool get showIndicator {
return maybeWhen(
ready: (isEnabled, _, runningState, lackOfResource) =>
runningState != RunningStatePB.Running || lackOfResource != null,
ready: (isEnabled, isReady, lackOfResource) =>
isReady || lackOfResource != null,
orElse: () => false,
);
}
@@ -150,14 +150,16 @@ class SettingItem extends Equatable {
required this.content,
required this.hintText,
required this.settingType,
this.editable = true,
});
final String content;
final String hintText;
final SettingType settingType;
final bool editable;
@override
List<Object?> get props => [content, settingType];
List<Object?> get props => [content, settingType, editable];
}
/// Items pending submission
@@ -232,6 +234,7 @@ extension on LocalAISettingPB {
content: embeddingModelName,
hintText: kDefaultEmbeddingModel,
settingType: SettingType.embeddingModel,
editable: false, // embedding model is not editable
),
];
@@ -11,7 +11,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'settings_ai_bloc.freezed.dart';
const String aiModelsGlobalActiveModel = "ai_models_global_active_model";
const String aiModelsGlobalActiveModel = "global_active_model";
class SettingsAIBloc extends Bloc<SettingsAIEvent, SettingsAIState> {
SettingsAIBloc(
@@ -75,9 +75,6 @@ class SettingsAIBloc extends Bloc<SettingsAIEvent, SettingsAIState> {
);
},
selectModel: (AIModelPB model) async {
if (!model.isLocal) {
await _updateUserWorkspaceSetting(model: model.name);
}
await AIEventUpdateSelectedModel(
UpdateSelectedModelPB(
source: aiModelsGlobalActiveModel,
@@ -194,8 +194,18 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
).getWorkspaceUsage().then((result) {
result.fold(
(usage) {
if (!isClosed && usage != null) {
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
if (!isClosed) {
// if the user cannot fetch the workspace usage,
// clear the tier indicator
if (usage == null) {
add(
const SidebarPlanEvent.updateTierIndicator(
SidebarToastTierIndicator.loading(),
),
);
} else {
add(SidebarPlanEvent.updateWorkspaceUsage(usage));
}
}
},
(error) => Log.error("Failed to get workspace usage: $error"),
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
@@ -86,17 +85,6 @@ class WorkspaceService {
}
Future<FlowyResult<WorkspaceUsagePB?, FlowyError>> getWorkspaceUsage() async {
final request = WorkspaceMemberIdPB()..uid = userId;
final result = await UserEventGetMemberInfo(request).send();
final isOwner = result.fold(
(member) => member.role.isOwner,
(_) => false,
);
if (!isOwner) {
return FlowyResult.success(null);
}
final payload = UserWorkspaceIdPB(workspaceId: workspaceId);
return UserEventGetWorkspaceUsage(payload).send();
}
@@ -26,7 +26,6 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
import 'package:flowy_infra_ui/style_widget/container.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sentry/sentry.dart';
import 'package:sized_context/sized_context.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -70,14 +69,6 @@ class DesktopHomeScreen extends StatelessWidget {
return const WorkspaceFailedScreen();
}
Sentry.configureScope(
(scope) => scope.setUser(
SentryUser(
id: userProfile.id.toString(),
),
),
);
return AFFocusManager(
child: MultiBlocProvider(
key: ValueKey(userProfile.id),
@@ -182,6 +182,7 @@ class SpaceCancelOrConfirmButton extends StatelessWidget {
final String confirmButtonName;
final Color? confirmButtonColor;
final WidgetBuilder? confirmButtonBuilder;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
@@ -208,8 +209,16 @@ class SpaceCancelOrConfirmButton extends StatelessWidget {
borderRadius: BorderRadius.circular(8),
),
),
child: AFFilledTextButton.destructive(
text: confirmButtonName,
child: FlowyButton(
useIntrinsicWidth: true,
margin:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 9.0),
radius: BorderRadius.circular(8),
text: FlowyText.regular(
confirmButtonName,
lineHeight: 1.0,
color: Theme.of(context).colorScheme.onPrimary,
),
onTap: onConfirm,
),
),
@@ -720,7 +720,8 @@ class _SingleInnerViewItemState extends State<SingleInnerViewItem> {
final viewBloc = context.read<ViewBloc>();
// the name of new document should be empty
final viewName = pluginBuilder.layoutType != ViewLayoutPB.Document
final viewName = ![ViewLayoutPB.Document, ViewLayoutPB.Chat]
.contains(pluginBuilder.layoutType)
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: '';
viewBloc.add(
@@ -17,8 +17,6 @@ class AIModelSelection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<SettingsAIBloc, SettingsAIState>(
buildWhen: (previous, current) =>
previous.availableModels != current.availableModels,
builder: (context, state) {
final models = state.availableModels?.models;
if (models == null) {
@@ -44,7 +42,7 @@ class AIModelSelection extends StatelessWidget {
),
Flexible(
child: SettingsDropdown<AIModelPB>(
key: const Key('_AIModelSelection'),
key: ValueKey(selectedModel.name),
onChanged: (model) => context
.read<SettingsAIBloc>()
.add(SettingsAIEvent.selectModel(model)),
@@ -70,15 +70,26 @@ class _SettingItemWidget extends StatelessWidget {
const VSpace(4),
SizedBox(
height: 32,
child: FlowyTextField(
autoFocus: false,
hintText: item.hintText,
text: item.content,
onChanged: (content) {
context.read<OllamaSettingBloc>().add(
OllamaSettingEvent.onEdit(content, item.settingType),
);
},
child: FlowyTooltip(
message: item.editable
? null
: LocaleKeys.settings_aiPage_keys_readOnlyField.tr(),
child: FlowyTextField(
autoFocus: false,
hintText: item.hintText,
text: item.content,
readOnly: !item.editable,
onChanged: item.editable
? (content) {
context.read<OllamaSettingBloc>().add(
OllamaSettingEvent.onEdit(
content,
item.settingType,
),
);
}
: null,
),
),
),
],
@@ -19,19 +19,14 @@ class LocalAIStatusIndicator extends StatelessWidget {
return BlocBuilder<LocalAiPluginBloc, LocalAiPluginState>(
builder: (context, state) {
return state.maybeWhen(
ready: (_, version, runningState, lackOfResource) {
ready: (_, isReady, lackOfResource) {
if (lackOfResource != null) {
return _LackOfResource(resource: lackOfResource);
}
return switch (runningState) {
RunningStatePB.ReadyToRun => const _ReadyToRun(),
RunningStatePB.Connecting ||
RunningStatePB.Connected =>
_Initializing(),
RunningStatePB.Running => _LocalAIRunning(version: version),
RunningStatePB.Stopped => const _RestartPluginButton(),
_ => const SizedBox.shrink(),
return switch (isReady) {
true => const _LocalAIRunning(),
false => const _RestartPluginButton(),
};
},
orElse: () => const SizedBox.shrink(),
@@ -41,66 +36,6 @@ class LocalAIStatusIndicator extends StatelessWidget {
}
}
class _ReadyToRun extends StatelessWidget {
const _ReadyToRun();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
const SizedBox.square(
dimension: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
strokeAlign: BorderSide.strokeAlignInside,
),
),
const HSpace(8.0),
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIStart.tr(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _Initializing extends StatelessWidget {
const _Initializing();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
const SizedBox.square(
dimension: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
strokeAlign: BorderSide.strokeAlignInside,
),
),
HSpace(8),
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _RestartPluginButton extends StatelessWidget {
const _RestartPluginButton();
@@ -163,16 +98,11 @@ class _RestartPluginButton extends StatelessWidget {
}
class _LocalAIRunning extends StatelessWidget {
const _LocalAIRunning({
required this.version,
});
final String version;
const _LocalAIRunning();
@override
Widget build(BuildContext context) {
final runningText = LocaleKeys.settings_aiPage_keys_localAIRunning.tr();
final text = version.isEmpty ? runningText : "$runningText ($version)";
return Container(
decoration: const BoxDecoration(
@@ -189,7 +119,7 @@ class _LocalAIRunning extends StatelessWidget {
const HSpace(6),
Expanded(
child: FlowyText(
text,
runningText,
color: const Color(0xFF1E4620),
maxLines: 3,
),
@@ -236,7 +236,7 @@ class _FreePlanUpgradeButton extends StatelessWidget {
return Container(
alignment: Alignment.centerLeft,
child: FlowyTooltip(
message: LocaleKeys.settings_sites_namespace_upgradeToPro.tr(),
message: LocaleKeys.settings_sites_homePage_upgradeToPro.tr(),
child: PrimaryRoundedButton(
text: 'Pro ↗',
fontSize: 12.0,
@@ -255,7 +255,7 @@ class _FreePlanUpgradeButton extends StatelessWidget {
showToastNotification(
message:
LocaleKeys.settings_sites_namespace_redirectToPayment.tr(),
type: ToastificationType.info,
type: ToastificationType.error,
);
context.read<SettingsSitesBloc>().add(
@@ -266,7 +266,7 @@ class _FreePlanUpgradeButton extends StatelessWidget {
message: LocaleKeys
.settings_sites_namespace_pleaseAskOwnerToSetHomePage
.tr(),
type: ToastificationType.info,
type: ToastificationType.error,
);
}
},
@@ -4,6 +4,7 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/gestures.dart';
@@ -86,11 +87,14 @@ class _Description extends StatelessWidget {
Future<void> _onGenerateInviteLink(BuildContext context) async {
final state = context.read<WorkspaceMemberBloc>().state;
final subscriptionInfo = state.subscriptionInfo;
final inviteLink = state.inviteLink;
// check the current workspace member count, if it exceed the limit, show a upgrade dialog.
// prevent hard code here, because the member count may exceed the limit after the invite link is generated.
if (inviteLink == null && state.members.length >= 3) {
if (inviteLink == null &&
subscriptionInfo?.plan == WorkspacePlanPB.FreePlan &&
state.members.length >= 2) {
await showConfirmDialog(
context: context,
title:
@@ -165,9 +169,11 @@ class _CopyLinkButtonState extends State<_CopyLinkButton> {
),
onTap: () async {
final state = context.read<WorkspaceMemberBloc>().state;
final subscriptionInfo = state.subscriptionInfo;
// check the current workspace member count, if it exceed the limit, show a upgrade dialog.
// prevent hard code here, because the member count may exceed the limit after the invite link is generated.
if (state.members.length >= 3) {
if (subscriptionInfo?.plan == WorkspacePlanPB.FreePlan &&
state.members.length >= 2) {
await showConfirmDialog(
context: context,
title: LocaleKeys
+22 -33
View File
@@ -36,11 +36,6 @@ PODS:
- ReachabilitySwift (5.2.4)
- screen_retriever_macos (0.0.1):
- FlutterMacOS
- Sentry/HybridSDK (8.46.0)
- sentry_flutter (8.14.2):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.46.0)
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
@@ -77,7 +72,6 @@ DEPENDENCIES:
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
@@ -90,7 +84,6 @@ SPEC REPOS:
trunk:
- HotKey
- ReachabilitySwift
- Sentry
- Sparkle
EXTERNAL SOURCES:
@@ -126,8 +119,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
screen_retriever_macos:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
sentry_flutter:
:path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
@@ -144,34 +135,32 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468
appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9
connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e
app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9
auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d
bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
Sparkle: 9c328bdcfbcaf8f030c4b678eadfd0fcb03822d8
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
window_manager: e8d0b1431ab6c454f2b5c9ae26004bbfa43469aa
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
window_manager: 990c8e348c4da2a93b81da638245d40554ec9436
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823
+2 -18
View File
@@ -98,8 +98,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: e56746a
resolved-ref: e56746ac65681fb002ff169be3de15ae4f5aad60
ref: "470c4e7"
resolved-ref: "470c4e77c71b63f693ce0923a927afcd667d6f3b"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "5.2.0"
@@ -1851,22 +1851,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.8"
sentry:
dependency: "direct main"
description:
name: sentry
sha256: "599701ca0693a74da361bc780b0752e1abc98226cf5095f6b069648116c896bb"
url: "https://pub.dev"
source: hosted
version: "8.14.2"
sentry_flutter:
dependency: "direct main"
description:
name: sentry_flutter
sha256: "5ba2cf40646a77d113b37a07bd69f61bb3ec8a73cbabe5537b05a7c89d2656f8"
url: "https://pub.dev"
source: hosted
version: "8.14.2"
share_plus:
dependency: "direct main"
description:
+2 -4
View File
@@ -4,7 +4,7 @@ description: Bring projects, wikis, and teams together with AI. AppFlowy is an
your data. The best open source alternative to Notion.
publish_to: "none"
version: 0.9.0
version: 0.9.1
environment:
flutter: ">=3.27.4"
@@ -124,8 +124,6 @@ dependencies:
scaled_app: ^2.3.0
scroll_to_index: ^3.0.1
scrollable_positioned_list: ^0.3.8
sentry: 8.14.2
sentry_flutter: 8.14.2
share_plus: ^10.0.2
shared_preferences: ^2.2.2
sheet:
@@ -187,7 +185,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "e56746a"
ref: "470c4e7"
appflowy_editor_plugins:
git:
+5 -1
View File
@@ -561,6 +561,9 @@
"selectHomePage": "Select a page",
"clearHomePage": "Clear the home page for this namespace",
"customUrl": "Custom URL",
"homePage": {
"upgradeToPro": "Upgrade to Pro Plan to set a homepage"
},
"namespace": {
"description": "This change will apply to all the published pages live on this namespace",
"tooltip": "We reserve the rights to remove any inappropriate namespaces",
@@ -889,6 +892,7 @@
"loginToEnableAIFeature": "AI features are only enabled after logging in with @:appName Cloud. If you don't have an @:appName account, go to 'My Account' to sign up",
"llmModel": "Language Model",
"globalLLMModel": "Global Language Model",
"readOnlyField": "This field is read-only",
"llmModelType": "Language Model Type",
"downloadLLMPrompt": "Download {}",
"downloadAppFlowyOfflineAI": "Downloading AI offline package will enable AI to run on your device. Do you want to continue?",
@@ -3382,4 +3386,4 @@
"rewrite": "Rewrite",
"insertBelow": "Insert below"
}
}
}
+1176 -178
View File
File diff suppressed because it is too large. Load diff
+18 -14
View File
@@ -29,7 +29,7 @@ members = [
"flowy-search-pub",
"flowy-ai",
"flowy-ai-pub",
"flowy-storage-pub",
"flowy-storage-pub", "flowy-sqlite-vec",
]
resolver = "2"
@@ -82,7 +82,8 @@ uuid = { version = "1.5.0", features = ["serde", "v4", "v5"] }
serde_repr = "0.1"
futures = "0.3.31"
tokio = "1.38.0"
tokio-stream = "0.1.14"
tokio-stream = "0.1.17"
ollama-rs = { version = "0.3.0" }
async-trait = "0.1.81"
chrono = { version = "0.4.31", default-features = false, features = ["clock"] }
collab = { version = "0.2" }
@@ -99,19 +100,20 @@ tokio-util = "0.7.11"
zip = "2.2.0"
dashmap = "6.0.1"
derive_builder = "0.20.2"
tantivy = { version = "0.24.0" }
tantivy = { version = "0.24.1" }
af-plugin = { version = "0.1" }
af-local-ai = { version = "0.1" }
num_enum = "0.7.3"
flowy-sqlite-vec = { path = "flowy-sqlite-vec" }
# Please using the following command to update the revision id
# Current directory: frontend
# Run the script.add_workspace_members:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6ed0f4e" }
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6ed0f4e" }
workspace-template = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6ed0f4e" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9e16ff714903823ed68a001e78607da5c7f5c93a" }
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9e16ff714903823ed68a001e78607da5c7f5c93a" }
workspace-template = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9e16ff714903823ed68a001e78607da5c7f5c93a" }
[profile.dev]
opt-level = 0
@@ -146,14 +148,14 @@ rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120
# To switch to the local path, run:
# scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
# Working directory: frontend
# To update the commit ID, run:
@@ -162,3 +164,5 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl
af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" }
af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" }
af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" }
langchain-rust = { version = "4.6.0", git = "https://github.com/appflowy/langchain-rust", branch = "af" }
@@ -53,7 +53,7 @@ impl PartialEq<Symbol> for Ident {
}
}
impl<'a> PartialEq<Symbol> for &'a Ident {
impl PartialEq<Symbol> for &Ident {
fn eq(&self, word: &Symbol) -> bool {
*self == word.0
}
@@ -65,7 +65,7 @@ impl PartialEq<Symbol> for Path {
}
}
impl<'a> PartialEq<Symbol> for &'a Path {
impl PartialEq<Symbol> for &Path {
fn eq(&self, word: &Symbol) -> bool {
self.is_ident(word.0)
}
@@ -22,10 +22,12 @@ tracing.workspace = true
tokio = { workspace = true, features = ["sync"] }
lib-infra = { workspace = true }
arc-swap = "1.7"
flowy-sqlite = { workspace = true }
diesel.workspace = true
flowy-error.workspace = true
uuid.workspace = true
flowy-ai-pub.workspace = true
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
twox-hash = { version = "2.1.0", features = ["xxhash64"] }
[features]
default = []
@@ -33,7 +33,9 @@ use collab_plugins::local_storage::kv::KVTransactionDB;
use collab_plugins::local_storage::CollabPersistenceConfig;
use collab_user::core::{UserAwareness, UserAwarenessNotifier};
use crate::instant_indexed_data_provider::InstantIndexedDataWriter;
use flowy_error::FlowyError;
use lib_infra::util::get_operating_system;
use lib_infra::{if_native, if_wasm};
use tracing::{error, instrument, trace, warn};
use uuid::Uuid;
@@ -79,14 +81,17 @@ pub struct AppFlowyCollabBuilder {
#[cfg(not(target_arch = "wasm32"))]
rocksdb_backup: ArcSwapOption<Arc<dyn RocksdbBackup>>,
workspace_integrate: Arc<dyn WorkspaceCollabIntegrate>,
embeddings_writer: Option<Weak<InstantIndexedDataWriter>>,
}
impl AppFlowyCollabBuilder {
pub fn new(
storage_provider: impl CollabCloudPluginProvider + 'static,
workspace_integrate: impl WorkspaceCollabIntegrate + 'static,
embeddings_writer: Option<Weak<InstantIndexedDataWriter>>,
) -> Self {
Self {
embeddings_writer,
network_reachability: CollabConnectReachability::new(),
plugin_provider: ArcSwap::new(Arc::new(Arc::new(storage_provider))),
snapshot_persistence: Default::default(),
@@ -306,6 +311,19 @@ impl AppFlowyCollabBuilder {
where
T: BorrowMut<Collab> + Send + Sync + 'static,
{
if get_operating_system().is_desktop() {
let cloned_object = object.clone();
let weak_collab = Arc::downgrade(&collab);
let weak_embedding_writer = self.embeddings_writer.clone();
tokio::spawn(async move {
if let Some(embedding_writer) = weak_embedding_writer.and_then(|w| w.upgrade()) {
embedding_writer
.queue_collab_embed(cloned_object, weak_collab)
.await;
}
});
}
let mut write_collab = collab.try_write()?;
let has_cloud_plugin = write_collab.borrow().has_cloud_plugin();
if has_cloud_plugin {
@@ -0,0 +1,335 @@
use collab::core::collab::DataSource;
use collab::core::origin::CollabOrigin;
use collab::entity::EncodedCollab;
use collab::lock::RwLock;
use collab::preclude::{Collab, Transact};
use collab_document::document::DocumentBody;
use collab_entity::{CollabObject, CollabType};
use flowy_ai_pub::entities::{UnindexedCollab, UnindexedCollabMetadata, UnindexedData};
use flowy_error::{FlowyError, FlowyResult};
use lib_infra::async_trait::async_trait;
use lib_infra::util::get_operating_system;
use std::borrow::BorrowMut;
use std::collections::HashMap;
use std::sync::{Arc, Weak};
use std::time::Duration;
use tokio::runtime::Runtime;
use tokio::time::interval;
use tracing::{error, info, trace};
use uuid::Uuid;
pub struct WriteObject {
pub collab_object: CollabObject,
pub collab: Weak<dyn CollabIndexedData>,
}
pub struct InstantIndexedDataWriter {
collab_by_object: Arc<RwLock<HashMap<String, WriteObject>>>,
consumers: Arc<RwLock<Vec<Box<dyn InstantIndexedDataConsumer>>>>,
}
impl Default for InstantIndexedDataWriter {
fn default() -> Self {
Self::new()
}
}
impl InstantIndexedDataWriter {
pub fn new() -> InstantIndexedDataWriter {
let collab_by_object = Arc::new(RwLock::new(HashMap::<String, WriteObject>::new()));
let consumers = Arc::new(RwLock::new(
Vec::<Box<dyn InstantIndexedDataConsumer>>::new(),
));
InstantIndexedDataWriter {
collab_by_object,
consumers,
}
}
pub async fn num_consumers(&self) -> usize {
let consumers = self.consumers.read().await;
consumers.len()
}
pub async fn clear_consumers(&self) {
let mut consumers = self.consumers.write().await;
consumers.clear();
info!("[Indexing] Cleared all instant index consumers");
}
pub async fn register_consumer(&self, consumer: Box<dyn InstantIndexedDataConsumer>) {
info!(
"[Indexing] Registering instant index consumer: {}",
consumer.consumer_id()
);
let mut guard = self.consumers.write().await;
guard.push(consumer);
}
pub async fn spawn_instant_indexed_provider(&self, runtime: &Runtime) -> FlowyResult<()> {
if !get_operating_system().is_desktop() {
return Ok(());
}
let weak_collab_by_object = Arc::downgrade(&self.collab_by_object);
let consumers_weak = Arc::downgrade(&self.consumers);
let interval_dur = Duration::from_secs(30);
runtime.spawn(async move {
let mut ticker = interval(interval_dur);
ticker.tick().await;
loop {
ticker.tick().await;
// Upgrade our state holders
let collab_by_object = match weak_collab_by_object.upgrade() {
Some(m) => m,
None => break, // provider dropped
};
let consumers = match consumers_weak.upgrade() {
Some(c) => c,
None => break,
};
// Snapshot keys and consumers under read locks
let (object_ids, mut to_remove) = {
let guard = collab_by_object.read().await;
let keys: Vec<_> = guard.keys().cloned().collect();
(keys, Vec::new())
};
let guard = collab_by_object.read().await;
for id in object_ids {
// Check if the collab is still alive
match guard.get(&id) {
Some(wo) => {
if let Some(collab_rc) = wo.collab.upgrade() {
if let Some(data) = collab_rc
.get_unindexed_data(&wo.collab_object.collab_type)
.await
{
// Snapshot consumers
let consumers_guard = consumers.read().await;
for consumer in consumers_guard.iter() {
let workspace_id = match Uuid::parse_str(&wo.collab_object.workspace_id) {
Ok(id) => id,
Err(err) => {
error!(
"Invalid workspace_id {}: {}",
wo.collab_object.workspace_id, err
);
continue;
},
};
let object_id = match Uuid::parse_str(&wo.collab_object.object_id) {
Ok(id) => id,
Err(err) => {
error!("Invalid object_id {}: {}", wo.collab_object.object_id, err);
continue;
},
};
match consumer
.consume_collab(
&workspace_id,
data.clone(),
&object_id,
wo.collab_object.collab_type,
)
.await
{
Ok(is_indexed) => {
if is_indexed {
trace!("[Indexing] {} consumed {}", consumer.consumer_id(), id);
}
},
Err(err) => {
error!(
"Consumer {} failed on {}: {}",
consumer.consumer_id(),
id,
err
);
},
}
}
}
} else {
// Mark for removal if collab was dropped
to_remove.push(id);
}
},
None => continue,
}
}
if !to_remove.is_empty() {
let mut guard = collab_by_object.write().await;
guard.retain(|k, _| !to_remove.contains(k));
trace!("[Indexing] Removed {} stale entries", to_remove.len());
}
}
info!("[Indexing] Instant indexed data provider stopped");
});
Ok(())
}
pub fn support_collab_type(&self, t: &CollabType) -> bool {
matches!(t, CollabType::Document)
}
pub async fn index_encoded_collab(
&self,
workspace_id: Uuid,
object_id: Uuid,
data: EncodedCollab,
collab_type: CollabType,
) -> FlowyResult<()> {
match unindexed_collab_from_encoded_collab(workspace_id, object_id, data, collab_type) {
None => Err(FlowyError::internal().with_context("Failed to create unindexed collab")),
Some(data) => {
self.index_unindexed_collab(data).await?;
Ok(())
},
}
}
pub async fn index_unindexed_collab(&self, data: UnindexedCollab) -> FlowyResult<()> {
let consumers_guard = self.consumers.read().await;
for consumer in consumers_guard.iter() {
match consumer
.consume_collab(
&data.workspace_id,
data.data.clone(),
&data.object_id,
data.collab_type,
)
.await
{
Ok(is_indexed) => {
if is_indexed {
trace!(
"[Indexing] {} consumed {}",
consumer.consumer_id(),
data.object_id
);
}
},
Err(err) => {
error!(
"Consumer {} failed on {}: {}",
consumer.consumer_id(),
data.object_id,
err
);
},
}
}
Ok(())
}
pub async fn queue_collab_embed(
&self,
collab_object: CollabObject,
collab: Weak<dyn CollabIndexedData>,
) {
if !get_operating_system().is_desktop() {
return;
}
if !self.support_collab_type(&collab_object.collab_type) {
return;
}
let mut map = self.collab_by_object.write().await;
map.insert(
collab_object.object_id.clone(),
WriteObject {
collab_object,
collab,
},
);
}
}
pub fn unindexed_data_form_collab(
collab: &Collab,
collab_type: &CollabType,
) -> Option<UnindexedData> {
match collab_type {
CollabType::Document => {
let txn = collab.doc().try_transact().ok()?;
let doc = DocumentBody::from_collab(collab)?;
let paras = doc.paragraphs(txn);
Some(UnindexedData::Paragraphs(paras))
},
_ => None,
}
}
pub fn unindexed_collab_from_encoded_collab(
workspace_id: Uuid,
object_id: Uuid,
encoded_collab: EncodedCollab,
collab_type: CollabType,
) -> Option<UnindexedCollab> {
match collab_type {
CollabType::Document => {
let collab = Collab::new_with_source(
CollabOrigin::Empty,
&object_id.to_string(),
DataSource::DocStateV1(encoded_collab.doc_state.to_vec()),
vec![],
false,
)
.ok()?;
let data = unindexed_data_form_collab(&collab, &collab_type)?;
Some(UnindexedCollab {
workspace_id,
object_id,
collab_type,
data,
metadata: UnindexedCollabMetadata::default(), // default means do not update metadata
})
},
_ => None,
}
}
#[async_trait]
pub trait CollabIndexedData: Send + Sync + 'static {
async fn get_unindexed_data(&self, collab_type: &CollabType) -> Option<UnindexedData>;
}
#[async_trait]
impl<T> CollabIndexedData for RwLock<T>
where
T: BorrowMut<Collab> + Send + Sync + 'static,
{
async fn get_unindexed_data(&self, collab_type: &CollabType) -> Option<UnindexedData> {
let collab = self.try_read().ok()?;
unindexed_data_form_collab(collab.borrow(), collab_type)
}
}
/// writer interface
#[async_trait]
pub trait InstantIndexedDataConsumer: Send + Sync + 'static {
fn consumer_id(&self) -> String;
async fn consume_collab(
&self,
workspace_id: &Uuid,
data: UnindexedData,
object_id: &Uuid,
collab_type: CollabType,
) -> Result<bool, FlowyError>;
async fn did_delete_collab(
&self,
workspace_id: &Uuid,
object_id: &Uuid,
) -> Result<(), FlowyError>;
}
@@ -4,7 +4,7 @@ pub use collab_plugins::CollabKVDB;
pub mod collab_builder;
pub mod config;
pub mod persistence;
pub mod instant_indexed_data_provider;
mod plugin_provider;
pub use collab_plugins::local_storage::kv::doc::CollabKVAction;
@@ -1 +0,0 @@
pub mod collab_metadata_sql;
+19 -7
View File
@@ -84,13 +84,25 @@ impl DartAppFlowyCore {
ret: Option<mpsc::Sender<AFPluginEventResponse>>,
) {
if let Ok(sender_guard) = self.sender.read() {
if let Err(e) = sender_guard.as_ref().unwrap().send(Task {
dispatcher: self.dispatcher().unwrap(),
request,
port,
ret,
}) {
error!("Failed to send task: {}", e);
let dispatcher = match self.dispatcher() {
Some(dispatcher) => dispatcher,
None => {
error!("Failed to get dispatcher: dispatcher is None");
return;
},
};
if let Some(sender) = sender_guard.as_ref() {
if let Err(e) = sender.send(Task {
dispatcher,
request,
port,
ret,
}) {
error!("Failed to send task: {}", e);
}
} else {
error!("Failed to send task: sender is None");
}
} else {
warn!("Failed to acquire read lock for sender");
@@ -48,9 +48,8 @@ zip.workspace = true
walkdir = "2.5.0"
futures = "0.3.31"
flowy-ai-pub = { workspace = true }
serial_test = "2.0.0"
[features]
default = ["supabase_cloud_test"]
dart = ["flowy-core/dart"]
supabase_cloud_test = []
single_thread = []
single_thread = []
@@ -87,4 +87,11 @@ impl EventIntegrationTest {
.await
.parse::<ChatMessageListPB>()
}
pub async fn toggle_local_ai(&self) {
EventBuilder::new(self.clone())
.event(AIEvent::ToggleLocalAI)
.async_send()
.await;
}
}
@@ -112,6 +112,15 @@ impl EventIntegrationTest {
.parse::<WorkspacePB>()
}
pub async fn get_workspace_id(&self) -> Uuid {
let a = EventBuilder::new(self.clone())
.event(FolderEvent::ReadCurrentWorkspace)
.async_send()
.await
.parse::<WorkspacePB>();
Uuid::from_str(&a.id).unwrap()
}
pub async fn get_user_workspace(&self, workspace_id: &str) -> UserWorkspacePB {
let payload = UserWorkspaceIdPB {
workspace_id: workspace_id.to_string(),
@@ -124,7 +133,7 @@ impl EventIntegrationTest {
.parse::<UserWorkspacePB>()
}
pub fn get_folder_search_handler(&self) -> &Arc<dyn SearchHandler> {
pub fn get_folder_search_handler(&self) -> Arc<dyn SearchHandler> {
self
.appflowy_core
.search_manager
@@ -10,6 +10,8 @@ use tokio::sync::broadcast::{channel, Sender};
use tracing::error;
use uuid::Uuid;
use crate::event_builder::EventBuilder;
use crate::EventIntegrationTest;
use flowy_folder::event_map::FolderEvent;
use flowy_notification::entities::SubscribeObject;
use flowy_notification::NotificationSender;
@@ -27,9 +29,6 @@ use flowy_user::event_map::UserEvent;
use flowy_user_pub::entities::WorkspaceType;
use lib_dispatch::prelude::{AFPluginDispatcher, AFPluginRequest, ToBytes};
use crate::event_builder::EventBuilder;
use crate::EventIntegrationTest;
impl EventIntegrationTest {
pub async fn enable_encryption(&self) -> String {
let config = EventBuilder::new(self.clone())
@@ -438,7 +437,10 @@ pub struct SignUpContext {
pub user_profile: UserProfilePB,
pub password: String,
}
pub async fn use_local_mode() {
AuthenticatorType::Local.write_env();
AFCloudConfiguration::default().write_env();
}
pub async fn use_localhost_af_cloud() {
AuthenticatorType::AppFlowyCloud.write_env();
let base_url =
@@ -1,8 +1,6 @@
mod local_test;
mod af_cloud_test;
// #[cfg(feature = "supabase_cloud_test")]
// mod supabase_test;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
@@ -1,3 +1 @@
mod local_test;
// #[cfg(feature = "supabase_cloud_test")]
// mod supabase_test;
@@ -2,8 +2,7 @@ mod database;
mod document;
mod folder;
// TODO(Mathias): Enable tests for search
// mod search;
mod search;
mod sql_test;
mod user;
@@ -0,0 +1,207 @@
use crate::util::unzip;
use event_integration_test::EventIntegrationTest;
use flowy_core::DEFAULT_NAME;
use flowy_search::entities::SearchResponsePB;
use flowy_search::services::manager::SearchType;
use flowy_user::errors::FlowyResult;
use flowy_user_pub::entities::WorkspaceType;
use futures::StreamExt;
use std::time::{Duration, Instant};
use uuid::Uuid;
// Helper function to wait for search indexing to complete
async fn wait_for_indexing(test: &EventIntegrationTest) {
let mut rx = test
.user_manager
.app_life_cycle
.read()
.await
.subscribe_full_indexed_finish()
.unwrap();
let _ = rx.changed().await;
}
// Helper function to perform search and collect results
async fn perform_search(
test: &EventIntegrationTest,
query: &str,
workspace_id: &Uuid,
) -> Vec<FlowyResult<SearchResponsePB>> {
let search_handler = test
.search_manager
.get_handler(SearchType::DocumentLocal)
.unwrap();
let stream = search_handler
.perform_search(query.to_string(), workspace_id)
.await;
stream.collect().await
}
// Helper function to wait for document to be indexed with a specified query
async fn wait_for_document_indexing(
test: &EventIntegrationTest,
query: &str,
workspace_id: &Uuid,
document_name: &str,
timeout_secs: u64,
) -> Vec<FlowyResult<SearchResponsePB>> {
let start_time = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut result = Vec::new();
while start_time.elapsed() < timeout {
result = perform_search(test, query, workspace_id).await;
if let Some(Ok(search_result)) = result.first() {
if let Some(local) = &search_result.local_search_result {
if local
.items
.iter()
.any(|item| item.display_name.contains(document_name))
{
break;
}
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
result
}
#[tokio::test]
async fn anon_user_multiple_workspace_search_test() {
// SETUP: Initialize test environment with test data
let user_db_path = unzip("./tests/asset", "090_anon_search").unwrap();
let test =
EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await;
let first_workspace_id = test.get_workspace_id().await;
// Wait for initial indexing to complete
wait_for_indexing(&test).await;
// TEST CASE 1: Search by page title
let result = perform_search(&test, "japan", &first_workspace_id).await;
let local = result[0]
.as_ref()
.unwrap()
.local_search_result
.as_ref()
.expect("expected a local_search_result");
assert_eq!(
local.items.len(),
2,
"Should find 2 pages with 'japan' in the title"
);
assert_eq!(
local.items[0].display_name, "Japan Skiing",
"First result should be 'Japan Skiing'"
);
assert_eq!(
local.items[1].display_name, "Japan Food",
"Second result should be 'Japan Food'"
);
// TEST CASE 2: Search by page content
let result = perform_search(&test, "Niseko", &first_workspace_id).await;
let local = result[0]
.as_ref()
.unwrap()
.local_search_result
.as_ref()
.expect("expected a local_search_result");
assert_eq!(
local.items.len(),
1,
"Should find 1 page with 'Niseko' in the content"
);
assert_eq!(
local.items[0].display_name, "Japan Skiing",
"The page should be 'Japan Skiing'"
);
// TEST CASE 3: Create a new document and verify it becomes searchable
// Create and add content to new document
let document_title = "My dog";
let view = test
.create_and_open_document(
&first_workspace_id.to_string(),
document_title.to_string(),
vec![],
)
.await;
test
.insert_document_text(
&view.id,
"I have maltese dog, he love eating food all the time",
0,
)
.await;
// Wait for document to be indexed and searchable
let result = wait_for_document_indexing(
&test,
"maltese dog",
&first_workspace_id,
document_title,
30,
)
.await;
let local = result[0]
.as_ref()
.unwrap()
.local_search_result
.as_ref()
.expect("expected a local_search_result");
assert!(
local
.items
.iter()
.any(|item| item.display_name.contains(document_title)),
"New document should be found when searching for its content"
);
// TEST CASE 4: Create and search in a second workspace
// Create and open a new workspace
let second_workspace_id = Uuid::parse_str(
&test
.create_workspace("my second workspace", WorkspaceType::Local)
.await
.workspace_id,
)
.unwrap();
test
.open_workspace(
&second_workspace_id.to_string(),
WorkspaceType::Local.into(),
)
.await;
// Wait for indexing in the new workspace
wait_for_indexing(&test).await;
// Search in second workspace
let result = perform_search(&test, "japan", &second_workspace_id).await;
assert!(
result[0].as_ref().unwrap().local_search_result.is_none(),
"Empty workspace should not have results for 'japan'"
);
// TEST CASE 5: Return to first workspace and verify search still works
test
.open_workspace(&first_workspace_id.to_string(), WorkspaceType::Local.into())
.await;
wait_for_indexing(&test).await;
let result = perform_search(&test, "japan", &first_workspace_id).await;
assert!(
!result.is_empty(),
"First workspace should still have search results after switching workspaces"
);
}
@@ -1,215 +0,0 @@
use crate::util::{unzip_test_asset, zip};
use collab_folder::View;
use event_integration_test::EventIntegrationTest;
use flowy_core::DEFAULT_NAME;
use flowy_folder::entities::UpdateViewPayloadPB;
use flowy_folder_pub::folder_builder::{FlattedViews, NestedViewBuilder};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::test]
async fn test_folder_index_all_startup() {
let folder_name = "folder_1000_view";
// comment out the following line to create a test asset if you modify the test data
// don't forget to delete unnecessary test assets
// create_folder_test_data(folder_name).await;
let (cleaner, user_db_path) = unzip_test_asset(folder_name).unwrap();
let test =
EventIntegrationTest::new_with_user_data_path(user_db_path.clone(), DEFAULT_NAME.to_string())
.await;
let first_level_views = test.get_all_workspace_views().await;
assert_eq!(first_level_views.len(), 3);
assert_eq!(first_level_views[1].name, "1");
assert_eq!(first_level_views[2].name, "2");
let view_1 = test.get_view(&first_level_views[1].id).await;
assert_eq!(view_1.child_views.len(), 500);
let folder_data = test.get_folder_data();
// Get started + 1002 Views
assert_eq!(folder_data.views.len(), 1003);
// Wait for the index to be created/updated
sleep(Duration::from_secs(1)).await;
let folder_search_manager = test.get_folder_search_handler();
let num_docs = folder_search_manager.index_count();
assert_eq!(num_docs, 1004);
drop(cleaner);
}
#[tokio::test]
async fn test_folder_index_create_20_views() {
let test = EventIntegrationTest::new_anon().await;
let folder_search_manager = test.get_folder_search_handler();
// Wait for the index to be created/updated
sleep(Duration::from_secs(1)).await;
let workspace_id = test.get_current_workspace().await.id;
for i in 0..20 {
let view = test.create_view(&workspace_id, format!("View {}", i)).await;
sleep(Duration::from_millis(500)).await;
assert_eq!(view.name, format!("View {}", i));
}
// Wait for the index update to finish
sleep(Duration::from_secs(2)).await;
let num_docs = folder_search_manager.index_count();
// Workspace + Get started + 20 Views
assert_eq!(num_docs, 22);
}
#[tokio::test]
async fn test_folder_index_create_view() {
let test = EventIntegrationTest::new_anon().await;
let folder_search_manager = test.get_folder_search_handler();
// Wait for the index to be created/updated
sleep(Duration::from_secs(1)).await;
let workspace_id = test.get_current_workspace().await.id;
let view = test.create_view(&workspace_id, "Flowers".to_owned()).await;
// Wait for the index to be updated
sleep(Duration::from_millis(500)).await;
let results = folder_search_manager.perform_search(view.name.clone(), None);
if let Err(e) = results {
panic!("Error performing search: {:?}", e);
}
let results = results.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].data, view.name);
}
#[tokio::test]
async fn test_folder_index_rename_view() {
let test = EventIntegrationTest::new_anon().await;
let folder_search_manager = test.get_folder_search_handler();
// Wait for the index to be created/updated
sleep(Duration::from_secs(1)).await;
let workspace_id = test.get_current_workspace().await.id;
let view = test.create_view(&workspace_id, "Flowers".to_owned()).await;
// Wait for the index to be updated
sleep(Duration::from_millis(500)).await;
let new_view_name = "Bouquets".to_string();
let update_payload = UpdateViewPayloadPB {
view_id: view.id,
name: Some(new_view_name.clone()),
..Default::default()
};
test.update_view(update_payload).await;
// Wait for the index to be updated
sleep(Duration::from_millis(500)).await;
let first = folder_search_manager.perform_search(view.name, None);
if let Err(e) = first {
panic!("Error performing search: {:?}", e);
}
let second = folder_search_manager.perform_search(new_view_name.clone(), None);
if let Err(e) = second {
panic!("Error performing search: {:?}", e);
}
let first = first.unwrap();
assert_eq!(first.len(), 0);
let second = second.unwrap();
assert_eq!(second.len(), 1);
assert_eq!(second[0].data, new_view_name);
}
/// Using this method to create a folder test asset. Only use when you want to create a new asset.
/// The file will be created at tests/asset/{file_name}.zip and it will be committed to the repo.
///
#[allow(dead_code)]
async fn create_folder_test_data(file_name: &str) {
let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await;
test.sign_up_as_anon().await;
let uid = test.get_user_profile().await.unwrap().id;
let workspace_id = test.get_current_workspace().await.id;
let views = create_1002_views(uid, workspace_id.clone()).await;
test.create_views(views).await;
let first_level_views = test.get_all_workspace_views().await;
assert_eq!(first_level_views.len(), 3);
assert_eq!(first_level_views[1].name, "1");
assert_eq!(first_level_views[2].name, "2");
let view_1 = test.get_view(&first_level_views[1].id).await;
assert_eq!(view_1.child_views.len(), 500);
let folder_data = test.get_folder_data();
// Get started + 1002 Views
assert_eq!(folder_data.views.len(), 1003);
let data_path = test.config.application_path.clone();
zip(
data_path.into(),
format!("tests/asset/{}.zip", file_name).into(),
)
.unwrap();
sleep(Duration::from_secs(2)).await;
}
/// Create view without create the view's content(document/database).
/// workspace
/// - get_started
/// - view_1
/// - view_1_1
/// - view_1_2
/// - view_2
/// - view_2_1
/// - view_2_2
async fn create_1002_views(uid: i64, workspace_id: String) -> Vec<View> {
let mut builder = NestedViewBuilder::new(workspace_id.clone(), uid);
builder
.with_view_builder(|view_builder| async {
let mut builder = view_builder.with_name("1");
for i in 0..500 {
builder = builder
.with_child_view_builder(|child_view_builder| async {
child_view_builder.with_name(format!("1_{}", i)).build()
})
.await;
}
builder.build()
})
.await;
builder
.with_view_builder(|view_builder| async {
let mut builder = view_builder.with_name("2");
for i in 0..500 {
builder = builder
.with_child_view_builder(|child_view_builder| async {
child_view_builder.with_name(format!("2_{}", i)).build()
})
.await;
}
builder.build()
})
.await;
// The output views should be:
// view_1
// view_1_1
// view_1_x
// view_2
// view_2_1
// view_2_x
let views = builder.build();
FlattedViews::flatten_views(views)
}
@@ -1 +0,0 @@
mod folder_search_test;
@@ -1 +1 @@
mod local_test;
mod document_title_content_search;
@@ -1,10 +1,12 @@
use crate::user::af_cloud_test::util::get_synced_workspaces;
use crate::util::unzip;
use collab::core::collab::DataSource::DocStateV1;
use collab::core::origin::CollabOrigin;
use collab_entity::CollabType;
use collab_folder::Folder;
use event_integration_test::user_event::use_localhost_af_cloud;
use event_integration_test::user_event::{use_local_mode, use_localhost_af_cloud};
use event_integration_test::EventIntegrationTest;
use flowy_core::DEFAULT_NAME;
use flowy_user::entities::AFRolePB;
use flowy_user_pub::cloud::UserCloudServiceProvider;
use flowy_user_pub::entities::{AuthType, WorkspaceType};
@@ -415,3 +417,33 @@ async fn af_cloud_create_local_workspace_test() {
"Server should only see 2 workspaces (the default and server workspace, not the local one)"
);
}
#[tokio::test]
async fn af_cloud_open_089_anon_user_data_folder_test() {
let user_db_path = unzip("./tests/asset", "089_local").unwrap();
use_localhost_af_cloud().await;
let test =
EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await;
// After 0.8.9, we store user workspace into user_workspace_table and refactor the Session serde struct
// So, if everything is correct, we should be able to open the workspace and get the views
let workspaces = test.get_all_workspaces().await.items;
let views = test.get_all_views().await;
dbg!(&views);
assert!(views.iter().any(|view| view.name == "Anon 089 document"));
assert_eq!(workspaces.len(), 1);
}
#[tokio::test]
async fn open_089_anon_user_data_folder_test() {
use_local_mode().await;
// Almost same as af_cloud_open_089_anon_user_data_folder_test but doesn't use af_cloud as the backend
let user_db_path = unzip("./tests/asset", "089_local").unwrap();
let test =
EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await;
let workspaces = test.get_all_workspaces().await.items;
let views = test.get_all_views().await;
dbg!(&views);
assert!(views.iter().any(|view| view.name == "Anon 089 document"));
assert_eq!(workspaces.len(), 1);
}
+4 -1
View File
@@ -13,4 +13,7 @@ futures.workspace = true
serde_json.workspace = true
serde.workspace = true
uuid.workspace = true
flowy-sqlite = { workspace = true }
flowy-sqlite = { workspace = true }
chrono.workspace = true
twox-hash = { version = "2.1.0", features = ["xxhash64"] }
tracing.workspace = true
+19 -3
View File
@@ -33,6 +33,17 @@ pub struct AIModel {
pub desc: String,
}
impl AIModel {
/// Create a new model instance
pub fn new(name: impl Into<String>, description: impl Into<String>, is_local: bool) -> Self {
Self {
name: name.into(),
desc: description.into(),
is_local,
}
}
}
impl From<AvailableModel> for AIModel {
fn from(value: AvailableModel) -> Self {
let desc = value
@@ -112,7 +123,7 @@ pub trait ChatCloudService: Send + Sync + 'static {
chat_id: &Uuid,
question_id: i64,
format: ResponseFormat,
ai_model: Option<AIModel>,
ai_model: AIModel,
) -> Result<StreamAnswer, FlowyError>;
async fn get_answer(
@@ -142,14 +153,14 @@ pub trait ChatCloudService: Send + Sync + 'static {
workspace_id: &Uuid,
chat_id: &Uuid,
message_id: i64,
ai_model: Option<AIModel>,
ai_model: AIModel,
) -> Result<RepeatedRelatedQuestion, FlowyError>;
async fn stream_complete(
&self,
workspace_id: &Uuid,
params: CompleteTextParams,
ai_model: Option<AIModel>,
ai_model: AIModel,
) -> Result<StreamComplete, FlowyError>;
async fn embed_file(
@@ -175,4 +186,9 @@ pub trait ChatCloudService: Send + Sync + 'static {
async fn get_available_models(&self, workspace_id: &Uuid) -> Result<ModelList, FlowyError>;
async fn get_workspace_default_model(&self, workspace_id: &Uuid) -> Result<String, FlowyError>;
async fn set_workspace_default_model(
&self,
workspace_id: &Uuid,
model: &str,
) -> Result<(), FlowyError>;
}
@@ -0,0 +1,84 @@
use crate::cloud::workspace_dto::ViewIcon;
use crate::cloud::CollabType;
use twox_hash::xxhash64::Hasher;
use uuid::Uuid;
pub const RAG_IDS: &str = "rag_ids";
pub const SOURCE_ID: &str = "id";
pub const SOURCE: &str = "source";
pub const SOURCE_NAME: &str = "name";
pub struct EmbeddingRecord {
pub workspace_id: Uuid,
pub object_id: Uuid,
pub chunks: Vec<EmbeddedChunk>,
}
#[derive(Debug, Clone, Default)]
pub struct UnindexedCollabMetadata {
pub name: Option<String>,
pub icon: Option<ViewIcon>,
}
#[derive(Debug, Clone)]
pub struct UnindexedCollab {
pub workspace_id: Uuid,
pub object_id: Uuid,
pub collab_type: CollabType,
pub data: UnindexedData,
pub metadata: UnindexedCollabMetadata,
}
#[derive(Debug, Clone)]
pub enum UnindexedData {
Text(String),
Paragraphs(Vec<String>),
}
impl UnindexedData {
pub fn is_empty(&self) -> bool {
match self {
UnindexedData::Text(text) => text.is_empty(),
UnindexedData::Paragraphs(paragraphs) => paragraphs.is_empty(),
}
}
pub fn into_string(&self) -> String {
match self {
UnindexedData::Text(text) => text.clone(),
UnindexedData::Paragraphs(paragraphs) => paragraphs.join("\n"),
}
}
pub fn content_hash(&self) -> String {
match self {
UnindexedData::Text(text) => {
let h = Hasher::oneshot(0, text.as_bytes());
format!("{:016x}", h)
},
UnindexedData::Paragraphs(paragraphs) => {
let combined = paragraphs.join("");
let h = Hasher::oneshot(0, combined.as_bytes());
format!("{:016x}", h)
},
}
}
}
#[derive(Debug, Clone)]
pub struct EmbeddedChunk {
pub fragment_id: String,
pub object_id: String,
pub content_type: i32,
pub content: Option<String>,
pub metadata: Option<String>,
pub fragment_index: i32,
pub embedder_type: i32,
pub embeddings: Option<Vec<f32>>,
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub oid: Uuid,
pub content: String,
pub metadata: Option<serde_json::Value>,
pub score: f32,
}
@@ -1,3 +1,4 @@
pub mod cloud;
pub mod entities;
pub mod persistence;
pub mod user_service;
@@ -1,3 +1,4 @@
use crate::cloud::chat_dto::ChatAuthorType;
use crate::cloud::MessageCursor;
use client_api::entity::chat_dto::ChatMessage;
use flowy_error::{FlowyError, FlowyResult};
@@ -10,7 +11,7 @@ use flowy_sqlite::{
Queryable,
};
#[derive(Queryable, Insertable, Identifiable)]
#[derive(Queryable, Insertable, Identifiable, Debug)]
#[diesel(table_name = chat_message_table)]
#[diesel(primary_key(message_id))]
pub struct ChatMessageTable {
@@ -40,6 +41,19 @@ impl ChatMessageTable {
}
}
pub fn select_latest_user_message(
mut conn: DBConnection,
chat_id_val: &str,
auth_type: ChatAuthorType,
) -> FlowyResult<ChatMessageTable> {
dsl::chat_message_table
.filter(chat_message_table::chat_id.eq(chat_id_val))
.filter(chat_message_table::author_type.eq(auth_type as i64))
.order(chat_message_table::created_at.desc())
.first::<ChatMessageTable>(&mut *conn)
.map_err(FlowyError::from)
}
pub fn update_chat_message_is_sync(
mut conn: DBConnection,
chat_id_val: &str,
@@ -59,6 +73,7 @@ pub fn upsert_chat_messages(
mut conn: DBConnection,
new_messages: &[ChatMessageTable],
) -> FlowyResult<()> {
//trace!("Upserting chat messages: {:?}", new_messages);
conn.immediate_transaction(|conn| {
for message in new_messages {
let _ = insert_into(chat_message_table::table)
@@ -18,10 +18,10 @@ use uuid::Uuid;
pub struct ChatTable {
pub chat_id: String,
pub created_at: i64,
pub name: String,
pub metadata: String,
pub rag_ids: Option<String>,
pub is_sync: bool,
pub summary: String,
}
impl ChatTable {
@@ -32,10 +32,10 @@ impl ChatTable {
Self {
chat_id,
created_at: timestamp(),
name: "".to_string(),
metadata,
rag_ids,
is_sync,
summary: "".to_string(),
}
}
}
@@ -66,10 +66,33 @@ pub struct ChatTableFile {
#[diesel(primary_key(chat_id))]
pub struct ChatTableChangeset {
pub chat_id: String,
pub name: Option<String>,
pub metadata: Option<String>,
pub rag_ids: Option<String>,
pub is_sync: Option<bool>,
pub summary: Option<String>,
}
impl ChatTableChangeset {
pub fn summary(chat_id: String, summary: String) -> Self {
Self {
chat_id,
metadata: None,
rag_ids: None,
is_sync: None,
summary: Some(summary),
}
}
pub fn rag_ids(chat_id: String, rag_ids: Vec<String>) -> Self {
let rag_ids = Some(serialize_rag_ids(&rag_ids));
Self {
chat_id,
metadata: None,
rag_ids,
is_sync: None,
summary: None,
}
}
}
pub fn serialize_rag_ids(rag_ids: &[String]) -> String {
@@ -104,7 +127,6 @@ pub fn upsert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult<
.do_update()
.set((
chat_table::created_at.eq(excluded(chat_table::created_at)),
chat_table::name.eq(excluded(chat_table::name)),
chat_table::metadata.eq(excluded(chat_table::metadata)),
chat_table::rag_ids.eq(excluded(chat_table::rag_ids)),
chat_table::is_sync.eq(excluded(chat_table::is_sync)),
@@ -112,13 +134,33 @@ pub fn upsert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult<
.execute(&mut *conn)
}
pub fn update_chat(
conn: &mut SqliteConnection,
changeset: ChatTableChangeset,
) -> QueryResult<usize> {
let filter = dsl::chat_table.filter(chat_table::chat_id.eq(changeset.chat_id.clone()));
let affected_row = diesel::update(filter).set(changeset).execute(conn)?;
Ok(affected_row)
pub fn update_chat(mut conn: DBConnection, changeset: ChatTableChangeset) -> QueryResult<usize> {
// Check if the chat exists
let chat_exists = dsl::chat_table
.filter(chat_table::chat_id.eq(&changeset.chat_id))
.first::<ChatTable>(&mut *conn)
.is_ok();
if chat_exists {
// Update existing chat
let filter = dsl::chat_table.filter(chat_table::chat_id.eq(changeset.chat_id.clone()));
diesel::update(filter).set(changeset).execute(&mut *conn)
} else {
// Create a new chat row with default values
let chat = ChatTable {
chat_id: changeset.chat_id.clone(),
created_at: timestamp(),
metadata: changeset.metadata.unwrap_or_else(|| "{}".to_string()),
rag_ids: changeset.rag_ids,
is_sync: changeset.is_sync.unwrap_or(false),
summary: changeset.summary.unwrap_or_default(),
};
// Insert the new row
diesel::insert_into(chat_table::table)
.values(&chat)
.execute(&mut *conn)
}
}
pub fn update_chat_is_sync(
@@ -131,14 +173,22 @@ pub fn update_chat_is_sync(
.execute(&mut *conn)
}
pub fn read_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult<ChatTable> {
pub fn select_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult<ChatTable> {
let row = dsl::chat_table
.filter(chat_table::chat_id.eq(chat_id_val))
.first::<ChatTable>(&mut *conn)?;
Ok(row)
}
pub fn read_chat_rag_ids(
pub fn select_chat_summary(conn: &mut DBConnection, chat_id_val: &Uuid) -> QueryResult<String> {
let summary = dsl::chat_table
.select(chat_table::summary)
.filter(chat_table::chat_id.eq(chat_id_val.to_string()))
.first::<String>(conn)?;
Ok(summary)
}
pub fn select_chat_rag_ids(
conn: &mut SqliteConnection,
chat_id_val: &str,
) -> FlowyResult<Vec<String>> {
@@ -149,7 +199,7 @@ pub fn read_chat_rag_ids(
Ok(deserialize_rag_ids(&chat.rag_ids))
}
pub fn read_chat_metadata(
pub fn select_chat_metadata(
conn: &mut SqliteConnection,
chat_id_val: &str,
) -> FlowyResult<ChatTableMetadata> {
@@ -160,17 +210,6 @@ pub fn read_chat_metadata(
Ok(deserialize_chat_metadata(&metadata_str))
}
#[allow(dead_code)]
pub fn update_chat_name(
mut conn: DBConnection,
chat_id_val: &str,
new_name: &str,
) -> QueryResult<usize> {
diesel::update(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val)))
.set(chat_table::name.eq(new_name))
.execute(&mut *conn)
}
#[allow(dead_code)]
pub fn delete_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult<usize> {
diesel::delete(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val))).execute(&mut *conn)
@@ -0,0 +1,97 @@
use diesel::sqlite::SqliteConnection;
use flowy_error::FlowyResult;
use flowy_sqlite::upsert::excluded;
use flowy_sqlite::{
diesel,
query_dsl::*,
schema::{index_collab_record_table, index_collab_record_table::dsl},
AsChangeset, ExpressionMethods, Identifiable, Insertable, Queryable,
};
#[derive(Clone, Debug, Queryable, Insertable, AsChangeset, Identifiable)]
#[diesel(table_name = index_collab_record_table)]
#[diesel(primary_key(oid))]
pub struct IndexCollabRecordTable {
pub oid: String,
pub workspace_id: String,
pub content_hash: String,
}
impl IndexCollabRecordTable {
pub fn new(workspace_id: String, oid: String, content_hash: String) -> Self {
Self {
workspace_id,
oid,
content_hash,
}
}
}
pub fn batch_upsert_index_collab(
conn: &mut SqliteConnection,
rows: Vec<IndexCollabRecordTable>,
) -> FlowyResult<()> {
if rows.is_empty() {
return Ok(());
}
conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| {
for row in rows {
diesel::insert_into(index_collab_record_table::table)
.values(&row)
.on_conflict(index_collab_record_table::oid)
.do_update()
.set(
index_collab_record_table::content_hash
.eq(excluded(index_collab_record_table::content_hash)),
)
.execute(conn)?;
}
Ok(())
})?;
Ok(())
}
pub fn upsert_index_collab(
conn: &mut SqliteConnection,
row: IndexCollabRecordTable,
) -> FlowyResult<()> {
diesel::insert_into(index_collab_record_table::table)
.values(row)
.on_conflict(index_collab_record_table::oid)
.do_update()
.set((
index_collab_record_table::content_hash.eq(excluded(index_collab_record_table::content_hash)),
))
.execute(conn)?;
Ok(())
}
pub fn select_indexed_collab_ids(
conn: &mut SqliteConnection,
workspace_id: String,
) -> FlowyResult<Vec<String>> {
let result = index_collab_record_table::table
.filter(index_collab_record_table::workspace_id.eq(workspace_id))
.select(dsl::oid)
.load::<String>(conn)?;
Ok(result)
}
pub fn select_indexed_collab(
conn: &mut SqliteConnection,
workspace_id: String,
limit: i64,
offset: i64,
) -> FlowyResult<Vec<IndexCollabRecordTable>> {
let result = index_collab_record_table::table
.filter(index_collab_record_table::workspace_id.eq(workspace_id))
.limit(limit)
.offset(offset)
.load::<IndexCollabRecordTable>(conn)?;
Ok(result)
}
@@ -1,7 +1,11 @@
mod chat_message_sql;
mod chat_sql;
mod collab_metadata_sql;
mod collab_sql;
mod local_model_sql;
pub use chat_message_sql::*;
pub use chat_sql::*;
pub use collab_metadata_sql::*;
pub use collab_sql::*;
pub use local_model_sql::*;
+22 -6
View File
@@ -33,7 +33,7 @@ log = "0.4.21"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
anyhow = "1.0.86"
tokio-stream = "0.1.15"
tokio-stream.workspace = true
tokio-util = { workspace = true, features = ["full"] }
af-local-ai = { workspace = true }
af-plugin = { workspace = true }
@@ -43,21 +43,37 @@ base64 = "0.21.5"
futures-util = "0.3.30"
pin-project = "1.1.5"
flowy-storage-pub = { workspace = true }
collab-integrate.workspace = true
ollama-rs = "0.3.0"
ollama-rs.workspace = true
schemars = "0.8.22"
twox-hash = { version = "2.1.0", features = ["xxhash64"] }
async-trait.workspace = true
async-stream = "0.3.6"
flowy-database-pub = { workspace = true }
langchain-rust = { version = "4.6.0", features = ["ollama"] }
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
notify = "6.1.1"
#faiss = { version = "0.12.1" }
af-mcp = { version = "0.1.0" }
text-splitter = { version = "0.25.1" }
flowy-sqlite-vec.workspace = true
lopdf = { version = "0.36.0", optional = true }
pulldown-cmark = { version = "0.13.0", optional = true }
[dev-dependencies]
uuid.workspace = true
tracing-subscriber = { version = "0.3.3", features = ["env-filter"] }
tempfile = "3.8.1"
[build-dependencies]
flowy-codegen.workspace = true
tempdir = "0.3.7"
[features]
default = []
dart = ["flowy-codegen/dart", "flowy-notification/dart"]
local_ai = []
local_ai = []
file_reader = ["lopdf", "pulldown-cmark"]
ai-tool = []
+234 -291
View File
@@ -5,32 +5,34 @@ use crate::entities::{
};
use crate::local_ai::controller::{LocalAIController, LocalAISetting};
use crate::middleware::chat_service_mw::ChatServiceMiddleware;
use flowy_ai_pub::persistence::read_chat_metadata;
use flowy_ai_pub::persistence::{
select_chat_metadata, select_chat_rag_ids, select_chat_summary, update_chat, ChatTableChangeset,
};
use std::collections::HashMap;
use dashmap::DashMap;
use flowy_ai_pub::cloud::{
AIModel, ChatCloudService, ChatSettings, UpdateChatParams, DEFAULT_AI_MODEL_NAME,
};
use flowy_ai_pub::cloud::{AIModel, ChatCloudService, ChatSettings, UpdateChatParams};
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_sqlite::kv::KVStorePreferences;
use crate::model_select::{
LocalAiSource, LocalModelStorageImpl, ModelSelectionControl, ServerAiSource,
ServerModelStorageImpl, SourceKey, GLOBAL_ACTIVE_MODEL_KEY,
};
use crate::notification::{chat_notification_builder, ChatNotification};
use crate::util::ai_available_models_key;
use collab_integrate::persistence::collab_metadata_sql::{
use flowy_ai_pub::persistence::{
batch_insert_collab_metadata, batch_select_collab_metadata, AFCollabMetadata,
};
use flowy_ai_pub::cloud::ai_dto::AvailableModel;
use flowy_ai_pub::user_service::AIUserService;
use flowy_sqlite::DBConnection;
use flowy_storage_pub::storage::StorageService;
use lib_infra::async_trait::async_trait;
use lib_infra::util::timestamp;
use serde_json::json;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{Arc, Weak};
use tokio::sync::RwLock;
use tracing::{error, info, instrument, trace};
use tokio::sync::Mutex;
use tracing::{error, info, instrument, trace, warn};
use uuid::Uuid;
/// AIExternalService is an interface for external services that AI plugin can interact with.
@@ -52,14 +54,6 @@ pub trait AIExternalService: Send + Sync + 'static {
async fn notify_did_send_message(&self, chat_id: &Uuid, message: &str) -> Result<(), FlowyError>;
}
#[derive(Debug, Default)]
struct ServerModelsCache {
models: Vec<AvailableModel>,
timestamp: Option<i64>,
}
pub const GLOBAL_ACTIVE_MODEL_KEY: &str = "global_active_model";
pub struct AIManager {
pub cloud_service_wm: Arc<ChatServiceMiddleware>,
pub user_service: Arc<dyn AIUserService>,
@@ -67,7 +61,7 @@ pub struct AIManager {
chats: Arc<DashMap<Uuid, Arc<Chat>>>,
pub local_ai: Arc<LocalAIController>,
pub store_preferences: Arc<KVStorePreferences>,
server_models: Arc<RwLock<ServerModelsCache>>,
model_control: Mutex<ModelSelectionControl>,
}
impl Drop for AIManager {
fn drop(&mut self) {
@@ -85,11 +79,6 @@ impl AIManager {
local_ai: Arc<LocalAIController>,
) -> AIManager {
let user_service = Arc::new(user_service);
let cloned_local_ai = local_ai.clone();
tokio::spawn(async move {
cloned_local_ai.observe_plugin_resource().await;
});
let external_service = Arc::new(query_service);
let cloud_service_wm = Arc::new(ChatServiceMiddleware::new(
user_service.clone(),
@@ -97,6 +86,10 @@ impl AIManager {
local_ai.clone(),
storage_service,
));
let mut model_control = ModelSelectionControl::new();
model_control.set_local_storage(LocalModelStorageImpl(store_preferences.clone()));
model_control.set_server_storage(ServerModelStorageImpl(cloud_service_wm.clone()));
model_control.add_source(Box::new(ServerAiSource::new(cloud_service_wm.clone())));
Self {
cloud_service_wm,
@@ -105,7 +98,7 @@ impl AIManager {
local_ai,
external_service,
store_preferences,
server_models: Arc::new(Default::default()),
model_control: Mutex::new(model_control),
}
}
@@ -123,21 +116,17 @@ impl AIManager {
let is_enabled = self
.local_ai
.is_enabled_on_workspace(&workspace_id.to_string());
let is_running = self.local_ai.is_running();
let is_ready = self.local_ai.is_ready().await;
info!(
"[AI Manager] Reloading workspace: {}, is_local: {}, is_enabled: {}, is_running: {}",
workspace_id, is_local, is_enabled, is_running
"[AI Manager] Reloading workspace: {}, is_local: {}, is_enabled: {}, is_ready: {}",
workspace_id, is_local, is_enabled, is_ready
);
// Shutdown AI if it's running but shouldn't be (not enabled and not in local mode)
if is_running && !is_enabled && !is_local {
if is_ready && !is_enabled && !is_local {
info!("[AI Manager] Local AI is running but not enabled, shutting it down");
let local_ai = self.local_ai.clone();
tokio::spawn(async move {
// Wait for 5 seconds to allow other services to initialize
// TODO: pick a right time to start plugin service. Maybe [UserStatusCallback::did_launch]
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
if let Err(err) = local_ai.toggle_plugin(false).await {
error!("[AI Manager] failed to shutdown local AI: {:?}", err);
}
@@ -146,14 +135,10 @@ impl AIManager {
}
// Start AI if it's enabled but not running
if is_enabled && !is_running {
if is_enabled && !is_ready {
info!("[AI Manager] Local AI is enabled but not running, starting it now");
let local_ai = self.local_ai.clone();
tokio::spawn(async move {
// Wait for 5 seconds to allow other services to initialize
// TODO: pick a right time to start plugin service. Maybe [UserStatusCallback::did_launch]
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
if let Err(err) = local_ai.toggle_plugin(true).await {
error!("[AI Manager] failed to start local AI: {:?}", err);
}
@@ -162,24 +147,47 @@ impl AIManager {
}
// Log status for other cases
if is_running {
if is_ready {
info!("[AI Manager] Local AI is already running");
}
}
async fn prepare_local_ai(&self, workspace_id: &Uuid) {
self
.local_ai
.reload_ollama_client(&workspace_id.to_string())
.await;
self
.model_control
.lock()
.await
.add_source(Box::new(LocalAiSource::new(self.local_ai.clone())));
}
#[instrument(skip_all, err)]
pub async fn on_launch_if_authenticated(&self, workspace_id: &Uuid) -> Result<(), FlowyError> {
let is_enabled = self
.local_ai
.is_enabled_on_workspace(&workspace_id.to_string());
info!("local is enabled: {}", is_enabled);
if is_enabled {
self.prepare_local_ai(workspace_id).await;
} else {
self.model_control.lock().await.remove_local_source();
}
self.reload_with_workspace_id(workspace_id).await;
Ok(())
}
pub async fn initialize_after_sign_in(&self, workspace_id: &Uuid) -> Result<(), FlowyError> {
self.reload_with_workspace_id(workspace_id).await;
self.on_launch_if_authenticated(workspace_id).await?;
Ok(())
}
pub async fn initialize_after_sign_up(&self, workspace_id: &Uuid) -> Result<(), FlowyError> {
self.reload_with_workspace_id(workspace_id).await;
self.on_launch_if_authenticated(workspace_id).await?;
Ok(())
}
@@ -188,7 +196,7 @@ impl AIManager {
&self,
workspace_id: &Uuid,
) -> Result<(), FlowyError> {
self.reload_with_workspace_id(workspace_id).await;
self.on_launch_if_authenticated(workspace_id).await?;
Ok(())
}
@@ -201,15 +209,27 @@ impl AIManager {
self.cloud_service_wm.clone(),
))
});
if self.local_ai.is_running() {
if self.local_ai.is_enabled() {
let workspace_id = self.user_service.workspace_id()?;
let uid = self.user_service.user_id()?;
let mut conn = self.user_service.sqlite_connection(uid)?;
let rag_ids = self.get_rag_ids(chat_id, &mut conn).await?;
let summary = select_chat_summary(&mut conn, chat_id).unwrap_or_default();
let model = self.get_active_model(&chat_id.to_string()).await;
trace!("[AI Plugin] notify open chat: {}", chat_id);
self.local_ai.open_chat(chat_id);
self
.local_ai
.open_chat(&workspace_id, chat_id, &model.name, rag_ids, summary)
.await?;
}
let user_service = self.user_service.clone();
let cloud_service_wm = self.cloud_service_wm.clone();
let store_preferences = self.store_preferences.clone();
let external_service = self.external_service.clone();
let local_ai = self.local_ai.clone();
let chat_id = *chat_id;
tokio::spawn(async move {
match refresh_chat_setting(
@@ -221,6 +241,7 @@ impl AIManager {
.await
{
Ok(settings) => {
local_ai.set_rag_ids(&chat_id, &settings.rag_ids).await;
let rag_ids = settings
.rag_ids
.into_iter()
@@ -246,11 +267,7 @@ impl AIManager {
pub async fn delete_chat(&self, chat_id: &Uuid) -> Result<(), FlowyError> {
if let Some((_, chat)) = self.chats.remove(chat_id) {
chat.close();
if self.local_ai.is_running() {
info!("[AI Plugin] notify close chat: {}", chat_id);
self.local_ai.close_chat(chat_id);
}
self.local_ai.close_chat(chat_id);
}
Ok(())
}
@@ -258,7 +275,7 @@ impl AIManager {
pub async fn get_chat_info(&self, chat_id: &str) -> FlowyResult<ChatInfoPB> {
let uid = self.user_service.user_id()?;
let mut conn = self.user_service.sqlite_connection(uid)?;
let metadata = read_chat_metadata(&mut conn, chat_id)?;
let metadata = select_chat_metadata(&mut conn, chat_id)?;
let files = metadata
.files
.into_iter()
@@ -332,7 +349,7 @@ impl AIManager {
let model = match model {
None => self.get_active_model(&chat_id.to_string()).await,
Some(model) => Some(model.into()),
Some(model) => model.into(),
};
chat
.stream_regenerate_response(question_message_id, answer_stream_port, format, model)
@@ -341,10 +358,10 @@ impl AIManager {
}
pub async fn update_local_ai_setting(&self, setting: LocalAISetting) -> FlowyResult<()> {
let workspace_id = self.user_service.workspace_id()?;
let old_settings = self.local_ai.get_local_ai_setting();
// Only restart if the server URL has changed and local AI is not running
let need_restart =
old_settings.ollama_server_url != setting.ollama_server_url && !self.local_ai.is_running();
let need_restart = old_settings.ollama_server_url != setting.ollama_server_url;
// Update settings first
self
@@ -353,175 +370,158 @@ impl AIManager {
.await?;
// Handle model change if needed
let model_changed = old_settings.chat_model_name != setting.chat_model_name;
if model_changed {
info!(
"[AI Plugin] update global active model, previous: {}, current: {}",
old_settings.chat_model_name, setting.chat_model_name
);
let model = AIModel::local(setting.chat_model_name, "".to_string());
self
.update_selected_model(GLOBAL_ACTIVE_MODEL_KEY.to_string(), model)
.await?;
}
info!(
"[AI Plugin] update global active model, previous: {}, current: {}",
old_settings.chat_model_name, setting.chat_model_name
);
let model = AIModel::local(setting.chat_model_name, "".to_string());
self
.update_selected_model(GLOBAL_ACTIVE_MODEL_KEY.to_string(), model)
.await?;
if need_restart {
self
.local_ai
.reload_ollama_client(&workspace_id.to_string())
.await;
self.local_ai.restart_plugin().await;
}
Ok(())
}
async fn get_workspace_select_model(&self) -> FlowyResult<String> {
#[instrument(skip_all, level = "debug")]
pub async fn update_selected_model(&self, source: String, model: AIModel) -> FlowyResult<()> {
let workspace_id = self.user_service.workspace_id()?;
let model = self
.cloud_service_wm
.get_workspace_default_model(&workspace_id)
let source_key = SourceKey::new(source.clone());
self
.model_control
.lock()
.await
.set_active_model(&workspace_id, &source_key, model.clone())
.await?;
if model.is_empty() {
return Ok(DEFAULT_AI_MODEL_NAME.to_string());
}
Ok(model)
}
async fn get_server_available_models(&self) -> FlowyResult<Vec<AvailableModel>> {
let workspace_id = self.user_service.workspace_id()?;
let now = timestamp();
// First, try reading from the cache with expiration check
let should_fetch = {
let cached_models = self.server_models.read().await;
cached_models.models.is_empty() || cached_models.timestamp.map_or(true, |ts| now - ts >= 300)
};
if !should_fetch {
// Cache is still valid, return cached data
let cached_models = self.server_models.read().await;
return Ok(cached_models.models.clone());
}
// Cache miss or expired: fetch from the cloud.
match self
.cloud_service_wm
.get_available_models(&workspace_id)
.await
{
Ok(list) => {
let models = list.models;
if let Err(err) = self.update_models_cache(&models, now).await {
error!("Failed to update models cache: {}", err);
}
Ok(models)
},
Err(err) => {
error!("Failed to fetch available models: {}", err);
// Return cached data if available, even if expired
let cached_models = self.server_models.read().await;
if !cached_models.models.is_empty() {
info!("Returning expired cached models due to fetch failure");
return Ok(cached_models.models.clone());
}
// If no cached data, return empty list
Ok(Vec::new())
},
}
}
async fn update_models_cache(
&self,
models: &[AvailableModel],
timestamp: i64,
) -> FlowyResult<()> {
match self.server_models.try_write() {
Ok(mut cache) => {
cache.models = models.to_vec();
cache.timestamp = Some(timestamp);
Ok(())
},
Err(_) => {
// Handle lock acquisition failure
Err(FlowyError::internal().with_context("Failed to acquire write lock for models cache"))
},
}
}
pub async fn update_selected_model(&self, source: String, model: AIModel) -> FlowyResult<()> {
let source_key = ai_available_models_key(&source);
info!(
"[Model Selection] update {} selected model: {:?} for key:{}",
source, model, source_key
"[Model Selection] selected model: {:?} for key:{}",
model,
source_key.storage_id()
);
self
.store_preferences
.set_object::<AIModel>(&source_key, &model)?;
chat_notification_builder(&source_key, ChatNotification::DidUpdateSelectedModel)
.payload(AIModelPB::from(model))
.send();
let mut notify_source = vec![source.clone()];
if source == GLOBAL_ACTIVE_MODEL_KEY {
let ids = self
.model_control
.lock()
.await
.get_all_unset_sources()
.await;
info!("[Model Selection] notify all unset sources: {:?}", ids);
notify_source.extend(ids);
}
trace!("[Model Selection] notify sources: {:?}", notify_source);
for source in notify_source {
chat_notification_builder(&source, ChatNotification::DidUpdateSelectedModel)
.payload(AIModelPB::from(model.clone()))
.send();
}
Ok(())
}
#[instrument(skip_all, level = "debug")]
#[instrument(skip_all, level = "debug", err)]
pub async fn toggle_local_ai(&self) -> FlowyResult<()> {
let enabled = self.local_ai.toggle_local_ai().await?;
let workspace_id = self.user_service.workspace_id()?;
if enabled {
if let Some(name) = self.local_ai.get_plugin_chat_model() {
info!("Set global active model to local ai: {}", name);
self.prepare_local_ai(&workspace_id).await;
if let Some(name) = self.local_ai.get_local_chat_model() {
let model = AIModel::local(name, "".to_string());
self
info!(
"[Model Selection] Set global active model to local ai: {}",
model.name
);
if let Err(err) = self
.update_selected_model(GLOBAL_ACTIVE_MODEL_KEY.to_string(), model)
.await?;
.await
{
error!(
"[Model Selection] Failed to set global active model: {}",
err
);
}
}
} else {
info!("Set global active model to default");
let global_active_model = self.get_workspace_select_model().await?;
let models = self.get_server_available_models().await?;
if let Some(model) = models.into_iter().find(|m| m.name == global_active_model) {
self
.update_selected_model(GLOBAL_ACTIVE_MODEL_KEY.to_string(), AIModel::from(model))
.await?;
let mut model_control = self.model_control.lock().await;
model_control.remove_local_source();
let model = model_control.get_global_active_model(&workspace_id).await;
let mut notify_source = model_control.get_all_unset_sources().await;
notify_source.push(GLOBAL_ACTIVE_MODEL_KEY.to_string());
drop(model_control);
trace!(
"[Model Selection] notify sources: {:?}, model:{}, when disable local ai",
notify_source,
model.name
);
for source in notify_source {
chat_notification_builder(&source, ChatNotification::DidUpdateSelectedModel)
.payload(AIModelPB::from(model.clone()))
.send();
}
}
Ok(())
}
pub async fn get_active_model(&self, source: &str) -> Option<AIModel> {
let mut model = self
.store_preferences
.get_object::<AIModel>(&ai_available_models_key(source));
match model {
None => {
if let Some(local_model) = self.local_ai.get_plugin_chat_model() {
model = Some(AIModel::local(local_model, "".to_string()));
}
model
},
Some(mut model) => {
let models = self.local_ai.get_all_chat_local_models().await;
if !models.contains(&model) {
if let Some(local_model) = self.local_ai.get_plugin_chat_model() {
model = AIModel::local(local_model, "".to_string());
}
}
Some(model)
pub async fn get_active_model(&self, source: &str) -> AIModel {
match self.user_service.workspace_id() {
Ok(workspace_id) => {
let source_key = SourceKey::new(source.to_string());
self
.model_control
.lock()
.await
.get_active_model(&workspace_id, &source_key)
.await
},
Err(_) => AIModel::default(),
}
}
pub async fn get_local_available_models(&self) -> FlowyResult<ModelSelectionPB> {
let setting = self.local_ai.get_local_ai_setting();
let mut models = self.local_ai.get_all_chat_local_models().await;
let selected_model = AIModel::local(setting.chat_model_name, "".to_string());
pub async fn get_local_available_models(
&self,
source: Option<String>,
) -> FlowyResult<ModelSelectionPB> {
let workspace_id = self.user_service.workspace_id()?;
let mut models = self
.model_control
.lock()
.await
.get_local_models(&workspace_id)
.await;
if models.is_empty() {
models.push(selected_model.clone());
}
let selected_model = match source {
None => {
let setting = self.local_ai.get_local_ai_setting();
let selected_model = AIModel::local(setting.chat_model_name, "".to_string());
if models.is_empty() {
models.push(selected_model.clone());
}
selected_model
},
Some(source) => {
let source_key = SourceKey::new(source);
self
.model_control
.lock()
.await
.get_active_model(&workspace_id, &source_key)
.await
},
};
Ok(ModelSelectionPB {
models: models.into_iter().map(AIModelPB::from).collect(),
@@ -536,95 +536,34 @@ impl AIManager {
) -> FlowyResult<ModelSelectionPB> {
let is_local_mode = self.user_service.is_local_model().await?;
if is_local_mode {
return self.get_local_available_models().await;
return self.get_local_available_models(Some(source)).await;
}
// Fetch server models
let mut all_models: Vec<AIModel> = self
.get_server_available_models()
.await?
.into_iter()
.map(AIModel::from)
.collect();
let workspace_id = self.user_service.workspace_id()?;
let local_model_name = if setting_only {
Some(self.local_ai.get_local_ai_setting().chat_model_name)
} else {
None
};
trace!("[Model Selection]: Available models: {:?}", all_models);
// Add local models if enabled
if self.local_ai.is_enabled() {
if setting_only {
let setting = self.local_ai.get_local_ai_setting();
all_models.push(AIModel::local(setting.chat_model_name, "".to_string()));
} else {
all_models.extend(self.local_ai.get_all_chat_local_models().await);
}
}
// Return early if no models available
if all_models.is_empty() {
return Ok(ModelSelectionPB {
models: Vec::new(),
selected_model: AIModelPB::default(),
});
}
// Get server active model (only once)
let server_active_model = self
.get_workspace_select_model()
.await
.map(|m| AIModel::server(m, "".to_string()))
.unwrap_or_else(|_| AIModel::default());
let source_key = SourceKey::new(source);
let model_control = self.model_control.lock().await;
let active_model = model_control
.get_active_model(&workspace_id, &source_key)
.await;
trace!(
"[Model Selection] server active model: {:?}",
server_active_model
"[Model Selection] {} active model: {:?}, global model:{:?}",
source_key.storage_id(),
active_model,
local_model_name
);
// Use server model as default if it exists in available models
let default_model = if all_models
.iter()
.any(|m| m.name == server_active_model.name)
{
server_active_model.clone()
} else {
AIModel::default()
};
let all_models = model_control
.get_models_with_specific_local_model(&workspace_id, local_model_name)
.await;
drop(model_control);
// Get user's previously selected model
let user_selected_model = match self.get_active_model(&source).await {
Some(model) => {
trace!("[Model Selection] user previous select model: {:?}", model);
model
},
None => {
// When no selected model and local AI is active, use local AI model
all_models
.iter()
.find(|m| m.is_local)
.cloned()
.unwrap_or_else(|| default_model.clone())
},
};
// Determine final active model - use user's selection if available, otherwise default
let active_model = all_models
.iter()
.find(|m| m.name == user_selected_model.name)
.cloned()
.unwrap_or(default_model.clone());
// Update stored preference if changed
if active_model.name != user_selected_model.name {
if let Err(err) = self
.update_selected_model(source, active_model.clone())
.await
{
error!("[Model Selection] failed to update selected model: {}", err);
}
}
trace!("[Model Selection] final active model: {:?}", active_model);
// Create response with one transformation pass
Ok(ModelSelectionPB {
models: all_models.into_iter().map(AIModelPB::from).collect(),
selected_model: AIModelPB::from(active_model),
@@ -662,7 +601,6 @@ impl AIManager {
/// - `before_message_id` is the first message ID in the current chat messages.
///
/// 4. `after_message_id` and `before_message_id` cannot be specified at the same time.
pub async fn load_prev_chat_messages(
&self,
chat_id: &Uuid,
@@ -722,12 +660,25 @@ impl AIManager {
Ok(())
}
pub async fn get_rag_ids(&self, chat_id: &Uuid) -> FlowyResult<Vec<String>> {
if let Some(settings) = self
.store_preferences
.get_object::<ChatSettings>(&setting_store_key(chat_id))
{
return Ok(settings.rag_ids);
pub async fn get_rag_ids(
&self,
chat_id: &Uuid,
conn: &mut DBConnection,
) -> FlowyResult<Vec<String>> {
match select_chat_rag_ids(&mut *conn, &chat_id.to_string()) {
Ok(ids) => {
return Ok(ids);
},
Err(_) => {
// we no long use store_preferences to store chat settings
warn!("[Chat] failed to get chat rag ids from sqlite, try to get from store_preferences");
if let Some(settings) = self
.store_preferences
.get_object::<ChatSettings>(&setting_store_key(chat_id))
{
return Ok(settings.rag_ids);
}
},
}
let settings = refresh_chat_setting(
@@ -753,24 +704,17 @@ impl AIManager {
.update_chat_settings(&workspace_id, chat_id, update_setting)
.await?;
let chat_setting_store_key = setting_store_key(chat_id);
if let Some(settings) = self
.store_preferences
.get_object::<ChatSettings>(&chat_setting_store_key)
{
if let Err(err) = self.store_preferences.set_object(
&chat_setting_store_key,
&ChatSettings {
rag_ids: rag_ids.clone(),
..settings
},
) {
error!("failed to set chat settings: {}", err);
}
}
let uid = self.user_service.user_id()?;
let conn = self.user_service.sqlite_connection(uid)?;
update_chat(
conn,
ChatTableChangeset::rag_ids(chat_id.to_string(), rag_ids.clone()),
)?;
let user_service = self.user_service.clone();
let external_service = self.external_service.clone();
self.local_ai.set_rag_ids(chat_id, &rag_ids).await;
let rag_ids = rag_ids
.into_iter()
.flat_map(|r| Uuid::from_str(&r).ok())
@@ -802,7 +746,6 @@ async fn sync_chat_documents(
{
if let Ok(uid) = user_service.user_id() {
if let Ok(conn) = user_service.sqlite_connection(uid) {
info!("sync rag documents success: {}", metadatas.len());
batch_insert_collab_metadata(conn, &metadatas).unwrap();
}
}
@@ -0,0 +1,32 @@
use pulldown_cmark::{html, Options, Parser};
use std::fs;
pub struct MdRead {
path: String,
}
impl MdRead {
/// Creates a new MdRead for the given file path.
pub fn new<P: Into<String>>(path: P) -> Self {
MdRead { path: path.into() }
}
pub fn read_markdown(&self) -> std::io::Result<String> {
fs::read_to_string(&self.path)
}
/// Renders the markdown to HTML, with CommonMark extensions enabled.
pub fn render_html(&self) -> Result<String, Box<dyn std::error::Error>> {
let markdown_input = self.read_markdown()?;
// Set up parser options (tables, footnotes, strikethrough)
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
// Create parser and render HTML
let parser = Parser::new_ext(&markdown_input, options);
let mut html_output = String::with_capacity(markdown_input.len() * 3 / 2);
html::push_html(&mut html_output, parser);
Ok(html_output)
}
}
@@ -0,0 +1,2 @@
pub mod markdown;
pub mod pdf;
@@ -0,0 +1,31 @@
use lopdf::Document;
pub struct PdfRead {
path: String,
}
impl PdfRead {
pub fn new<P: Into<String>>(path: P) -> Self {
PdfRead { path: path.into() }
}
/// Loads the PDF and extracts text from all pages, concatenated with double newlines.
pub fn read_all(&self) -> lopdf::Result<String> {
let doc = Document::load(&self.path)?;
// Get a map of page numbers to object IDs
let pages = doc.get_pages();
let mut texts = Vec::with_capacity(pages.len());
// Iterate pages in ascending order
let mut page_numbers: Vec<u32> = pages.keys().cloned().collect();
page_numbers.sort_unstable();
for page_num in page_numbers {
// Extract text for this page
let page_text = doc.extract_text(&[page_num])?;
texts.push(page_text);
}
// Join all pages with blank line separators
Ok(texts.join("\n\n"))
}
}
+10 -5
View File
@@ -73,7 +73,7 @@ impl Chat {
pub async fn stream_chat_message(
&self,
params: &StreamMessageParams,
preferred_ai_model: Option<AIModel>,
preferred_ai_model: AIModel,
) -> Result<ChatMessagePB, FlowyError> {
trace!(
"[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, format={:?}",
@@ -138,7 +138,7 @@ impl Chat {
question_id: i64,
answer_stream_port: i64,
format: Option<PredefinedFormatPB>,
ai_model: Option<AIModel>,
ai_model: AIModel,
) -> FlowyResult<()> {
trace!(
"[Chat] regenerate and stream chat message: chat_id={}",
@@ -152,7 +152,6 @@ impl Chat {
self.stream_buffer.lock().await.clear();
let format = format.map(Into::into).unwrap_or_default();
let answer_stream_buffer = self.stream_buffer.clone();
let uid = self.user_service.user_id()?;
let workspace_id = self.user_service.workspace_id()?;
@@ -179,7 +178,7 @@ impl Chat {
workspace_id: Uuid,
question_id: i64,
format: ResponseFormat,
ai_model: Option<AIModel>,
ai_model: AIModel,
) {
let stop_stream = self.stop_stream.clone();
let chat_id = self.chat_id;
@@ -503,7 +502,7 @@ impl Chat {
pub async fn get_related_question(
&self,
message_id: i64,
ai_model: Option<AIModel>,
ai_model: AIModel,
) -> Result<RepeatedRelatedQuestionPB, FlowyError> {
let workspace_id = self.user_service.workspace_id()?;
let resp = self
@@ -543,6 +542,12 @@ impl Chat {
limit: u64,
offset: MessageCursor,
) -> Result<Vec<ChatMessagePB>, FlowyError> {
trace!(
"[Chat] Loading messages from disk: chat_id={}, limit={}, offset={:?}",
self.chat_id,
limit,
offset
);
let conn = self.user_service.sqlite_connection(self.uid)?;
let rows = select_chat_messages(conn, &self.chat_id.to_string(), limit, offset)?.messages;
let messages = rows
+4 -4
View File
@@ -40,7 +40,7 @@ impl AICompletion {
pub async fn create_complete_task(
&self,
complete: CompleteTextPB,
preferred_model: Option<AIModel>,
preferred_model: AIModel,
) -> FlowyResult<CompleteTextTaskPB> {
if matches!(complete.completion_type, CompletionTypePB::CustomPrompt)
&& complete.custom_prompt.is_none()
@@ -84,14 +84,14 @@ pub struct CompletionTask {
stop_rx: tokio::sync::mpsc::Receiver<()>,
context: CompleteTextPB,
cloud_service: Weak<dyn ChatCloudService>,
preferred_model: Option<AIModel>,
preferred_model: AIModel,
}
impl CompletionTask {
pub fn new(
workspace_id: Uuid,
context: CompleteTextPB,
preferred_model: Option<AIModel>,
preferred_model: AIModel,
cloud_service: Weak<dyn ChatCloudService>,
stop_rx: tokio::sync::mpsc::Receiver<()>,
) -> Self {
@@ -117,7 +117,7 @@ impl CompletionTask {
CompletionTypePB::MakeLonger => CompletionType::MakeLonger,
CompletionTypePB::ContinueWriting => CompletionType::ContinueWriting,
CompletionTypePB::ExplainSelected => CompletionType::Explain,
CompletionTypePB::UserQuestion => CompletionType::UserQuestion,
CompletionTypePB::UserQuestion => CompletionType::AskAI,
CompletionTypePB::CustomPrompt => CompletionType::CustomPrompt,
};
@@ -0,0 +1,87 @@
use crate::embeddings::scheduler::EmbeddingScheduler;
use arc_swap::ArcSwapOption;
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_sqlite_vec::db::VectorSqliteDB;
use lib_infra::util::get_operating_system;
use ollama_rs::Ollama;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use tracing::{error, info, warn};
pub struct EmbedContext {
ollama: ArcSwapOption<Ollama>,
vector_db: ArcSwapOption<VectorSqliteDB>,
scheduler: ArcSwapOption<EmbeddingScheduler>,
}
impl EmbedContext {
pub fn shared() -> &'static Arc<EmbedContext> {
static INSTANCE: OnceLock<Arc<EmbedContext>> = OnceLock::new();
INSTANCE.get_or_init(|| {
Arc::new(EmbedContext {
ollama: ArcSwapOption::empty(),
vector_db: ArcSwapOption::empty(),
scheduler: Default::default(),
})
})
}
pub fn get_vector_db(&self) -> Option<Arc<VectorSqliteDB>> {
self.vector_db.load_full()
}
pub fn init_vector_db(&self, db_path: PathBuf) {
let sys = get_operating_system();
if !sys.is_desktop() {
warn!("[Embedding] Vector db is not supported on {:?}", sys);
return;
}
info!("Initializing vector db");
match VectorSqliteDB::new(db_path.clone()) {
Ok(db) => {
info!("[Embedding] Vector db created at: {:?}", db_path);
self.vector_db.store(Some(Arc::new(db)));
self.try_create_scheduler();
},
Err(err) => {
error!("[Embedding] Failed to create vector db: {}", err);
},
}
}
pub fn set_ollama(&self, ollama: Option<Arc<Ollama>>) {
if let Some(ollama) = ollama {
self.ollama.store(Some(ollama));
self.try_create_scheduler();
} else {
self.ollama.store(None);
if let Some(s) = self.scheduler.swap(None) {
info!("[Embedding] Stopping scheduler");
let _ = s.stop_tx.send(());
}
}
}
pub fn get_scheduler(&self) -> FlowyResult<Arc<EmbeddingScheduler>> {
self.scheduler.load_full().ok_or_else(|| {
FlowyError::new(
ErrorCode::LocalEmbeddingNotReady,
"Local embedding is not ready. Please check if the Ollama and vector db are initialized.",
)
})
}
fn try_create_scheduler(&self) {
if let (Some(ollama), Some(vector_db)) = (self.ollama.load_full(), self.vector_db.load_full()) {
info!("[Embedding] Creating scheduler");
match EmbeddingScheduler::new(ollama, vector_db) {
Ok(s) => {
info!("[Embedding] create scheduler successfully");
self.scheduler.store(Some(s));
},
Err(err) => error!("[Embedding] Failed to create scheduler: {}", err),
}
}
}
}
@@ -0,0 +1,192 @@
use crate::embeddings::embedder::Embedder;
use crate::embeddings::indexer::{EmbeddingModel, Indexer};
use flowy_ai_pub::entities::{EmbeddedChunk, SOURCE, SOURCE_ID, SOURCE_NAME};
use flowy_error::FlowyError;
use lib_infra::async_trait::async_trait;
use ollama_rs::generation::embeddings::request::{EmbeddingsInput, GenerateEmbeddingsRequest};
use serde_json::json;
use text_splitter::{ChunkConfig, TextSplitter};
use tracing::{debug, error, trace, warn};
use twox_hash::xxhash64::Hasher;
use uuid::Uuid;
pub struct DocumentIndexer;
#[async_trait]
impl Indexer for DocumentIndexer {
fn create_embedded_chunks_from_text(
&self,
object_id: Uuid,
paragraphs: Vec<String>,
model: EmbeddingModel,
) -> Result<Vec<EmbeddedChunk>, FlowyError> {
if paragraphs.is_empty() {
warn!(
"[Embedding] No paragraphs found in document `{}`. Skipping embedding.",
object_id
);
return Ok(vec![]);
}
split_text_into_chunks(&object_id.to_string(), paragraphs, model, 1000, 200)
}
async fn embed(
&self,
embedder: &Embedder,
mut chunks: Vec<EmbeddedChunk>,
) -> Result<Vec<EmbeddedChunk>, FlowyError> {
let mut valid_indices = Vec::new();
for (i, chunk) in chunks.iter().enumerate() {
if let Some(ref content) = chunk.content {
if !content.is_empty() {
valid_indices.push(i);
}
}
}
if valid_indices.is_empty() {
return Ok(vec![]);
}
let mut contents = Vec::with_capacity(valid_indices.len());
for &i in &valid_indices {
contents.push(chunks[i].content.as_ref().unwrap().to_owned());
}
let request = GenerateEmbeddingsRequest::new(
embedder.model().name().to_string(),
EmbeddingsInput::Multiple(contents),
);
let resp = embedder.embed(request).await?;
if resp.embeddings.len() != valid_indices.len() {
error!(
"[Embedding] requested {} embeddings, received {} embeddings",
valid_indices.len(),
resp.embeddings.len()
);
return Err(FlowyError::internal().with_context(format!(
"Mismatch in number of embeddings requested and received: {} vs {}",
valid_indices.len(),
resp.embeddings.len()
)));
}
for (index, embedding) in resp.embeddings.into_iter().enumerate() {
let chunk_idx = valid_indices[index];
chunks[chunk_idx].embeddings = Some(embedding);
}
Ok(chunks)
}
}
/// chunk_size:
/// Small Chunks (50–256 tokens): Best for precision-focused tasks (e.g., Q&A, technical docs) where specific details matter.
/// Medium Chunks (256–1,024 tokens): Ideal for balanced tasks like RAG or contextual search, providing enough context without noise.
/// Large Chunks (1,024–2,048 tokens): Suited for analysis or thematic tasks where broad understanding is key.
///
/// overlap:
/// Add 10–20% overlap for larger chunks (e.g., 50–100 tokens for 512-token chunks) to preserve context across boundaries.
pub fn split_text_into_chunks(
object_id: &str,
paragraphs: Vec<String>,
embedding_model: EmbeddingModel,
chunk_size: usize,
overlap: usize,
) -> Result<Vec<EmbeddedChunk>, FlowyError> {
debug_assert!(matches!(embedding_model, EmbeddingModel::NomicEmbedText));
if paragraphs.is_empty() {
return Ok(vec![]);
}
let split_contents = group_paragraphs_by_max_content_len(paragraphs, chunk_size, overlap);
let metadata = json!({
SOURCE_ID: object_id,
SOURCE: "appflowy",
SOURCE_NAME: "document",
});
let mut seen = std::collections::HashSet::new();
let mut chunks = Vec::new();
for (index, content) in split_contents.into_iter().enumerate() {
let metadata_string = metadata.to_string();
let combined_data = format!("{}{}", content, metadata_string);
let consistent_hash = Hasher::oneshot(0, combined_data.as_bytes());
let fragment_id = format!("{:x}", consistent_hash);
if seen.insert(fragment_id.clone()) {
chunks.push(EmbeddedChunk {
fragment_id,
object_id: object_id.to_string(),
content_type: 0,
content: Some(content),
embeddings: None,
metadata: Some(metadata_string),
fragment_index: index as i32,
embedder_type: 0,
});
} else {
debug!(
"[Embedding] Duplicate fragment_id detected: {}. This fragment will not be added.",
fragment_id
);
}
}
trace!(
"[Embedding] Created {} chunks for object_id `{}`, chunk_size: {}, overlap: {}",
chunks.len(),
object_id,
chunk_size,
overlap
);
Ok(chunks)
}
pub fn group_paragraphs_by_max_content_len(
paragraphs: Vec<String>,
mut context_size: usize,
overlap: usize,
) -> Vec<String> {
if paragraphs.is_empty() {
return vec![];
}
let mut result = Vec::new();
let mut current = String::with_capacity(context_size.min(4096));
if overlap > context_size {
warn!("context_size is smaller than overlap, which may lead to unexpected behavior.");
context_size = 2 * overlap;
}
let chunk_config = ChunkConfig::new(context_size)
.with_overlap(overlap)
.unwrap();
let splitter = TextSplitter::new(chunk_config);
for paragraph in paragraphs {
if current.len() + paragraph.len() > context_size {
if !current.is_empty() {
result.push(std::mem::take(&mut current));
}
if paragraph.len() > context_size {
let paragraph_chunks = splitter.chunks(&paragraph);
result.extend(paragraph_chunks.map(String::from));
} else {
current.push_str(&paragraph);
}
} else {
// Add paragraph to current chunk
current.push_str(&paragraph);
}
}
if !current.is_empty() {
result.push(current);
}
result
}
@@ -0,0 +1,41 @@
use crate::embeddings::indexer::EmbeddingModel;
use flowy_error::FlowyResult;
use ollama_rs::generation::embeddings::request::GenerateEmbeddingsRequest;
use ollama_rs::generation::embeddings::GenerateEmbeddingsResponse;
use ollama_rs::Ollama;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub enum Embedder {
Ollama(OllamaEmbedder),
}
impl Embedder {
pub async fn embed(
&self,
request: GenerateEmbeddingsRequest,
) -> FlowyResult<GenerateEmbeddingsResponse> {
match self {
Embedder::Ollama(ollama) => ollama.embed(request).await,
}
}
pub fn model(&self) -> EmbeddingModel {
EmbeddingModel::NomicEmbedText
}
}
#[derive(Debug, Clone)]
pub struct OllamaEmbedder {
pub ollama: Arc<Ollama>,
}
impl OllamaEmbedder {
pub async fn embed(
&self,
request: GenerateEmbeddingsRequest,
) -> FlowyResult<GenerateEmbeddingsResponse> {
let resp = self.ollama.generate_embeddings(request).await?;
Ok(resp)
}
}
@@ -0,0 +1,70 @@
use crate::embeddings::document_indexer::DocumentIndexer;
use crate::embeddings::embedder::Embedder;
use flowy_ai_pub::cloud::CollabType;
use flowy_ai_pub::entities::EmbeddedChunk;
use flowy_error::FlowyError;
use lib_infra::async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum EmbeddingModel {
NomicEmbedText,
}
impl EmbeddingModel {
pub fn name(&self) -> &'static str {
match self {
EmbeddingModel::NomicEmbedText => "nomic-embed-text",
}
}
#[allow(dead_code)]
pub fn dimension(&self) -> usize {
match self {
// https://ollama.com/library/nomic-embed-text/blobs/970aa74c0a90
EmbeddingModel::NomicEmbedText => 768,
}
}
}
#[async_trait]
pub trait Indexer: Send + Sync {
fn create_embedded_chunks_from_text(
&self,
object_id: Uuid,
paragraphs: Vec<String>,
model: EmbeddingModel,
) -> Result<Vec<EmbeddedChunk>, FlowyError>;
async fn embed(
&self,
embedder: &Embedder,
chunks: Vec<EmbeddedChunk>,
) -> Result<Vec<EmbeddedChunk>, FlowyError>;
}
/// A structure responsible for resolving different [Indexer] types for different [CollabType]s,
/// including access permission checks for the specific workspaces.
pub struct IndexerProvider {
indexer_cache: HashMap<CollabType, Arc<dyn Indexer>>,
}
impl IndexerProvider {
pub fn new() -> Arc<Self> {
let mut cache: HashMap<CollabType, Arc<dyn Indexer>> = HashMap::new();
cache.insert(CollabType::Document, Arc::new(DocumentIndexer));
Arc::new(Self {
indexer_cache: cache,
})
}
/// Returns indexer for a specific type of [Collab] object.
/// If collab of given type is not supported or workspace it belongs to has indexing disabled,
/// returns `None`.
pub fn indexer_for(&self, collab_type: CollabType) -> Option<Arc<dyn Indexer>> {
self.indexer_cache.get(&collab_type).cloned()
}
}
@@ -0,0 +1,6 @@
pub mod context;
mod document_indexer;
mod embedder;
pub(crate) mod indexer;
mod scheduler;
pub mod store;
@@ -0,0 +1,346 @@
use crate::embeddings::embedder::{Embedder, OllamaEmbedder};
use crate::embeddings::indexer::IndexerProvider;
use crate::search::summary::{summarize_documents, LLMDocument};
use flowy_ai_pub::cloud::search_dto::{
SearchContentType, SearchDocumentResponseItem, SearchResult, SearchSummaryResult, Summary,
};
use flowy_ai_pub::entities::{EmbeddingRecord, UnindexedCollab, UnindexedData};
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_sqlite::internal::derives::multiconnection::chrono::Utc;
use flowy_sqlite_vec::db::VectorSqliteDB;
use ollama_rs::generation::embeddings::request::{EmbeddingsInput, GenerateEmbeddingsRequest};
use ollama_rs::Ollama;
use std::sync::{Arc, Weak};
use tokio::select;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use tokio::sync::{broadcast, mpsc};
use tracing::{debug, error, info, trace, warn};
use uuid::Uuid;
type UnindexedCollabContext = UnindexedCollab;
pub struct EmbeddingScheduler {
indexer_provider: Arc<IndexerProvider>,
write_embedding_tx: UnboundedSender<EmbeddingRecord>,
generate_embedding_tx: mpsc::Sender<UnindexedCollab>,
ollama: Arc<Ollama>,
vector_db: Arc<VectorSqliteDB>,
pub(crate) stop_tx: tokio::sync::broadcast::Sender<()>,
}
impl EmbeddingScheduler {
pub fn new(
ollama: Arc<Ollama>,
vector_db: Arc<VectorSqliteDB>,
) -> FlowyResult<Arc<EmbeddingScheduler>> {
let indexer_provider = IndexerProvider::new();
let (write_embedding_tx, write_embedding_rx) = unbounded_channel::<EmbeddingRecord>();
let (generate_embedding_tx, gen_embedding_rx) = mpsc::channel::<UnindexedCollabContext>(100);
let (stop_tx, _) = broadcast::channel::<()>(1);
let this = Arc::new(Self {
indexer_provider,
write_embedding_tx,
generate_embedding_tx,
ollama,
vector_db,
stop_tx,
});
let weak_this = Arc::downgrade(&this);
let stop_rx = this.stop_tx.subscribe();
tokio::spawn(spawn_generate_embeddings(
gen_embedding_rx,
weak_this.clone(),
stop_rx,
));
let weak_this = Arc::downgrade(&this);
let stop_rx = this.stop_tx.subscribe();
tokio::spawn(spawn_write_embeddings(
write_embedding_rx,
weak_this,
stop_rx,
));
Ok(this)
}
pub(crate) fn create_embedder(&self) -> Result<Embedder, FlowyError> {
let embedder = Embedder::Ollama(OllamaEmbedder {
ollama: self.ollama.clone(),
});
Ok(embedder)
}
pub async fn index_collab(&self, data: UnindexedCollab) -> FlowyResult<()> {
trace!("[Embedding] got {} unindexd data", data.object_id);
if let Err(err) = self.generate_embedding_tx.send(data).await {
error!("[Embedding] error generating embedding: {}", err);
}
Ok(())
}
pub async fn delete_collab(&self, workspace_id: &Uuid, object_id: &Uuid) -> FlowyResult<()> {
self
.vector_db
.delete_collab(&workspace_id.to_string(), &object_id.to_string())
.await
.map_err(|err| {
error!("[Embedding] Failed to delete collab: {}", err);
FlowyError::new(ErrorCode::LocalEmbeddingNotReady, "Failed to delete collab")
})?;
Ok(())
}
pub async fn search(
&self,
workspace_id: &Uuid,
query: &str,
) -> FlowyResult<Vec<SearchDocumentResponseItem>> {
let embedder = self.create_embedder()?;
let request = GenerateEmbeddingsRequest::new(
embedder.model().name().to_string(),
EmbeddingsInput::Single(query.to_string()),
);
let resp = embedder.embed(request).await?;
match resp.embeddings.first() {
None => Ok(vec![]),
Some(query_embed) => {
let result = self
.vector_db
.search_with_score(&workspace_id.to_string(), &[], query_embed, 10, 0.4)
.await
.map_err(|err| {
error!("[Embedding] Failed to search: {}", err);
FlowyError::new(ErrorCode::LocalEmbeddingNotReady, "Failed to search")
})?;
let rows = result
.into_iter()
.map(|v| SearchDocumentResponseItem {
object_id: v.oid,
workspace_id: *workspace_id,
score: 1.0,
content_type: Some(SearchContentType::PlainText),
content: v.content,
preview: None,
created_by: "".to_string(),
created_at: Utc::now(),
})
.collect::<Vec<_>>();
Ok(rows)
},
}
}
pub async fn generate_summary(
&self,
question: &str,
model_name: &str,
search_results: Vec<SearchResult>,
) -> FlowyResult<SearchSummaryResult> {
if search_results.is_empty() {
return Ok(SearchSummaryResult { summaries: vec![] });
}
let docs = search_results
.into_iter()
.map(|v| LLMDocument {
content: v.content,
object_id: v.object_id,
})
.collect::<Vec<_>>();
let resp = summarize_documents(&self.ollama, question, model_name, docs)
.await
.map_err(|err| {
error!("[Embedding] Failed to generate summary: {}", err);
FlowyError::new(
ErrorCode::LocalEmbeddingNotReady,
"Failed to generate summary",
)
})?;
let summaries = resp
.summaries
.into_iter()
.flat_map(|s| {
if s.content.is_empty() {
None
} else {
Some(Summary {
content: s.content,
highlights: s.highlights,
sources: s.sources,
})
}
})
.collect::<Vec<_>>();
Ok(SearchSummaryResult { summaries })
}
}
const EMBEDDING_RECORD_BUFFER_SIZE: usize = 10;
pub async fn spawn_write_embeddings(
mut rx: UnboundedReceiver<EmbeddingRecord>,
scheduler: Weak<EmbeddingScheduler>,
mut stop_rx: broadcast::Receiver<()>,
) {
let mut buf = Vec::with_capacity(EMBEDDING_RECORD_BUFFER_SIZE);
loop {
select! {
// Shutdown signal arrives
_ = stop_rx.recv() => {
info!("Received stop signal; shutting down embedding writer");
break;
}
// Next batch from the input channel
n = rx.recv_many(&mut buf, EMBEDDING_RECORD_BUFFER_SIZE) => {
// channel closed
if n == 0 {
info!("Input channel closed; stopping write embeddings");
break;
}
// upgrade scheduler reference
let scheduler = match scheduler.upgrade() {
Some(db) => db,
None => {
error!("EmbeddingScheduler dropped; stopping write embeddings");
break;
}
};
// drain and process exactly `n` records
let records = buf.drain(..n).collect::<Vec<_>>();
for record in records {
debug!("[Embedding] Writing {} chunks for {}", record.chunks.len(), record.object_id);
match scheduler
.vector_db
.upsert_collabs_embeddings(&record.workspace_id.to_string(), &record.object_id.to_string(), record.chunks)
.await
{
Ok(_) => trace!("[Embedding] Successfully wrote embeddings for {}", record.object_id),
Err(err) => error!("[Embedding] Failed to write embeddings for {}: {}", record.object_id, err),
}
}
}
}
}
info!("spawn_write_embeddings exited");
}
async fn spawn_generate_embeddings(
mut rx: mpsc::Receiver<UnindexedCollab>,
scheduler: Weak<EmbeddingScheduler>,
mut stop_rx: broadcast::Receiver<()>,
) {
let mut buf = Vec::with_capacity(EMBEDDING_RECORD_BUFFER_SIZE);
loop {
select! {
_ = stop_rx.recv() => {
info!("Received stop signal; shutting down embedding writer");
break;
}
n = rx.recv_many(&mut buf, EMBEDDING_RECORD_BUFFER_SIZE) => {
let scheduler = match scheduler.upgrade() {
Some(scheduler) => scheduler,
None => {
info!("[Embedding] Failed to upgrade scheduler connection, break loop");
break;
},
};
if n == 0 {
info!("[Embedding] Stop generating embeddings");
break;
}
let records = buf.drain(..n).collect::<Vec<_>>();
let indexer_provider = scheduler.indexer_provider.clone();
let write_embedding_tx = scheduler.write_embedding_tx.clone();
let embedder = scheduler.create_embedder();
match embedder {
Ok(embedder) => {
let params: Vec<_> = records.iter().map(|r| r.object_id.to_string()).collect();
let existing_embeddings = scheduler
.vector_db
.select_collabs_fragment_ids(&params)
.await
.unwrap_or_else(|err| {
error!("[Embedding] failed to get existing embeddings: {}", err);
Default::default()
});
for record in records {
if let Some(indexer) = indexer_provider.indexer_for(record.collab_type) {
let paragraphs = match record.data {
UnindexedData::Paragraphs(paragraphs) => paragraphs,
UnindexedData::Text(text) => text.split('\n').map(|s| s.to_string()).collect(),
};
let embedder = embedder.clone();
match indexer.create_embedded_chunks_from_text(
record.object_id,
paragraphs,
embedder.model(),
) {
Ok(mut chunks) => {
if let Some(fragment_ids) = existing_embeddings.get(&record.object_id) {
for chunk in chunks.iter_mut() {
if fragment_ids.contains(&chunk.fragment_id) {
chunk.content = None;
}
}
}
if chunks.iter().all(|c| c.content.is_none()) {
trace!(
"[Embedding] skip generating embeddings for collab: {}",
record.object_id
);
continue;
}
let result = indexer.embed(&embedder, chunks).await;
match result {
Ok(chunks) => {
let record = EmbeddingRecord {
workspace_id: record.workspace_id,
object_id: record.object_id,
chunks,
};
if let Err(err) = write_embedding_tx.send(record) {
error!("Failed to send embedding record: {}", err);
}
},
Err(err) => {
error!(
"[Embedding] Failed to create embeddings content for collab: {}, error:{}",
record.object_id, err
);
},
}
},
Err(err) => {
warn!(
"Failed to create embedded chunks for collab: {}, error:{}",
record.object_id, err
);
continue;
},
}
}
}
},
Err(err) => error!("[Embedding] Failed to create embedder: {}", err),
}
}
}
}
info!("spawn_generate_embeddings exited");
}
@@ -0,0 +1,269 @@
use crate::embeddings::document_indexer::split_text_into_chunks;
use crate::embeddings::embedder::{Embedder, OllamaEmbedder};
use crate::embeddings::indexer::{EmbeddingModel, IndexerProvider};
use async_trait::async_trait;
use flowy_ai_pub::cloud::CollabType;
use flowy_ai_pub::entities::{RAG_IDS, SOURCE_ID};
use flowy_error::{FlowyError, FlowyResult};
use flowy_sqlite_vec::db::VectorSqliteDB;
use flowy_sqlite_vec::entities::SqliteEmbeddedDocument;
use futures::stream::{self, StreamExt};
use langchain_rust::llm::client::OllamaClient;
use langchain_rust::{
schemas::Document,
vectorstore::{VecStoreOptions, VectorStore},
};
use ollama_rs::generation::embeddings::request::{EmbeddingsInput, GenerateEmbeddingsRequest};
use serde_json::Value;
use std::collections::HashMap;
use std::error::Error;
use std::sync::{Arc, Weak};
use tracing::{error, trace, warn};
use uuid::Uuid;
#[derive(Clone)]
pub struct SqliteVectorStore {
ollama: Weak<OllamaClient>,
vector_db: Weak<VectorSqliteDB>,
indexer_provider: Arc<IndexerProvider>,
}
impl SqliteVectorStore {
pub fn new(ollama: Weak<OllamaClient>, vector_db: Weak<VectorSqliteDB>) -> Self {
Self {
ollama,
vector_db,
indexer_provider: IndexerProvider::new(),
}
}
pub(crate) fn create_embedder(&self) -> Result<Embedder, FlowyError> {
let ollama = self
.ollama
.upgrade()
.ok_or_else(|| FlowyError::internal().with_context("Ollama reference was dropped"))?;
let embedder = Embedder::Ollama(OllamaEmbedder { ollama });
Ok(embedder)
}
pub(crate) async fn select_all_embedded_documents(
&self,
workspace_id: &str,
rag_ids: &[String],
) -> FlowyResult<Vec<SqliteEmbeddedDocument>> {
// Get the vector database
let vector_db = match self.vector_db.upgrade() {
Some(db) => db,
None => return Err(FlowyError::internal().with_context("Vector database not initialized")),
};
vector_db
.select_all_embedded_documents(workspace_id, rag_ids)
.await
.map_err(|err| {
FlowyError::internal().with_context(format!("Failed to select embedded documents: {}", err))
})
}
}
#[async_trait]
impl VectorStore for SqliteVectorStore {
type Options = VecStoreOptions<Value>;
async fn add_documents(
&self,
docs: &[Document],
_opt: &Self::Options,
) -> Result<Vec<String>, Box<dyn Error>> {
let vector_db = match self.vector_db.upgrade() {
Some(db) => db,
None => return Err("Vector database not initialized".into()),
};
let embedder = self.create_embedder()?;
let indexer = self
.indexer_provider
.indexer_for(CollabType::Document)
.ok_or_else(|| Box::<dyn Error>::from("Failed to get indexer for Document"))?;
// Parse documents and filter out invalid ones early
let documents = docs
.iter()
.filter_map(|v| {
let workspace_id = match v.metadata.get("workspace_id") {
Some(value) => value.as_str().and_then(|s| Uuid::parse_str(s).ok()),
None => None,
}?;
let object_id = match v.metadata.get(SOURCE_ID) {
Some(value) => value.as_str().and_then(|s| Uuid::parse_str(s).ok()),
None => None,
}?;
Some((workspace_id, object_id, v.page_content.clone()))
})
.collect::<Vec<(Uuid, Uuid, String)>>();
let concurrency_limit = 4;
let document_ids = stream::iter(documents)
.map(|(workspace_id, object_id, paragraph)| {
// Clone values that need to be moved into the async block
let object_id_str = object_id.to_string();
let workspace_id_str = workspace_id.to_string();
let vector_db_clone = vector_db.clone();
let embedder_clone = embedder.clone();
let indexer_clone = indexer.clone();
async move {
let chunks_result = split_text_into_chunks(
&object_id_str,
vec![paragraph],
EmbeddingModel::NomicEmbedText,
2000,
200,
);
match chunks_result {
Ok(chunks) => match indexer_clone.embed(&embedder_clone, chunks).await {
Ok(chunks) => {
if let Err(e) = vector_db_clone
.upsert_collabs_embeddings(&workspace_id_str, &object_id_str, chunks)
.await
{
error!(
"[Embedding] Failed to upsert document `{}`: {}",
object_id_str, e
);
return None;
}
Some(object_id_str)
},
Err(err) => {
error!(
"[Embedding] Failed to embed document `{}`: {}",
object_id_str, err
);
None
},
},
Err(err) => {
error!(
"[Embedding] Failed to split document `{}`: {}",
object_id_str, err
);
None
},
}
}
})
.buffer_unordered(concurrency_limit)
.filter_map(|result| async move { result })
.collect()
.await;
Ok(document_ids)
}
async fn similarity_search(
&self,
query: &str,
limit: usize,
opt: &Self::Options,
) -> Result<Vec<Document>, Box<dyn Error>> {
// Extract rag_ids from filters
let rag_ids = opt
.filters
.as_ref()
.and_then(|filters| filters.get(RAG_IDS))
.and_then(|value| value.as_array())
.map(|array| {
array
.iter()
.filter_map(|item| item.as_str().map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
// Extract workspace_id from filters
let workspace_id = opt
.filters
.as_ref()
.and_then(|filters| filters.get("workspace_id"))
.and_then(|value| value.as_str())
.and_then(|s| Uuid::parse_str(s).ok());
// Return empty result if workspace_id is missing
let workspace_id = match workspace_id {
Some(id) => id.to_string(),
None => {
warn!("[VectorStore] Missing workspace_id in filters. Returning empty result.");
return Ok(Vec::new());
},
};
// Get the vector database
let vector_db = match self.vector_db.upgrade() {
Some(db) => db,
None => return Err("Vector database not initialized".into()),
};
// Create embedder and generate embedding for query
let embedder = self.create_embedder()?;
let request = GenerateEmbeddingsRequest::new(
embedder.model().name().to_string(),
EmbeddingsInput::Single(query.to_string()),
);
let embedding = match embedder.embed(request).await {
Ok(result) => result.embeddings,
Err(e) => return Err(Box::new(e)),
};
if embedding.is_empty() {
return Ok(Vec::new());
}
let score_threshold = opt.score_threshold.unwrap_or(0.4);
debug_assert!(embedding.len() == 1);
let query_embedding = embedding.first().unwrap();
// Perform similarity search in the database
let results = vector_db
.search_with_score(
&workspace_id,
&rag_ids,
query_embedding,
limit as i32,
score_threshold,
)
.await?;
trace!(
"[VectorStore] Found {} results for query:{}, rag_ids: {:?}, score_threshold: {}",
results.len(),
query,
rag_ids,
score_threshold
);
// Convert results to Documents
let documents = results
.into_iter()
.map(|result| {
let mut metadata = HashMap::new();
if let Some(map) = result.metadata.as_ref().and_then(|v| v.as_object()) {
for (key, value) in map {
metadata.insert(key.clone(), value.clone());
}
}
Document::new(result.content).with_metadata(metadata)
})
.collect();
Ok(documents)
}
}
+1 -7
View File
@@ -590,13 +590,7 @@ pub struct LocalAIPB {
pub lack_of_resource: Option<LackOfAIResourcePB>,
#[pb(index = 3)]
pub state: RunningStatePB,
#[pb(index = 4, one_of)]
pub plugin_version: Option<String>,
#[pb(index = 5)]
pub plugin_downloaded: bool,
pub is_ready: bool,
}
#[derive(Default, ProtoBuf, Validate, Clone, Debug)]
@@ -98,6 +98,7 @@ pub(crate) async fn get_source_model_selection_handler(
data_result_ok(models)
}
#[tracing::instrument(level = "debug", skip_all, err)]
pub(crate) async fn update_selected_model_handler(
data: AFPluginData<UpdateSelectedModelPB>,
ai_manager: AFPluginState<Weak<AIManager>>,
@@ -312,7 +313,9 @@ pub(crate) async fn get_chat_settings_handler(
let chat_id = data.try_into_inner()?.value;
let chat_id = Uuid::from_str(&chat_id)?;
let ai_manager = upgrade_ai_manager(ai_manager)?;
let rag_ids = ai_manager.get_rag_ids(&chat_id).await?;
let uid = ai_manager.user_service.user_id()?;
let mut conn = ai_manager.user_service.sqlite_connection(uid)?;
let rag_ids = ai_manager.get_rag_ids(&chat_id, &mut conn).await?;
let pb = ChatSettingsPB { rag_ids };
data_result_ok(pb)
}
@@ -345,7 +348,7 @@ pub(crate) async fn get_local_ai_models_handler(
ai_manager: AFPluginState<Weak<AIManager>>,
) -> DataResult<ModelSelectionPB, FlowyError> {
let ai_manager = upgrade_ai_manager(ai_manager)?;
let data = ai_manager.get_local_available_models().await?;
let data = ai_manager.get_local_available_models(None).await?;
data_result_ok(data)
}
+51 -1
View File
@@ -10,9 +10,59 @@ pub mod local_ai;
// #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
// pub mod mcp;
#[cfg(feature = "ai-tool")]
mod ai_tool;
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
pub mod embeddings;
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
pub use embeddings::store::SqliteVectorStore;
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
pub use mock::SqliteVectorStore;
#[cfg(all(
not(target_os = "windows"),
not(target_os = "macos"),
not(target_os = "linux")
))]
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
mod mock {
use async_trait::async_trait;
use langchain_rust::schemas::Document;
use langchain_rust::vectorstore::{VecStoreOptions, VectorStore};
use serde_json::Value;
use std::error::Error;
#[derive(Clone)]
pub struct SqliteVectorStore;
#[async_trait]
impl VectorStore for SqliteVectorStore {
type Options = VecStoreOptions<Value>;
async fn add_documents(
&self,
docs: &[Document],
_opt: &Self::Options,
) -> Result<Vec<String>, Box<dyn Error>> {
Ok(vec![])
}
async fn similarity_search(
&self,
query: &str,
limit: usize,
opt: &Self::Options,
) -> Result<Vec<Document>, Box<dyn Error>> {
Ok(vec![])
}
}
}
mod middleware;
mod model_select;
#[cfg(test)]
mod model_select_test;
pub mod notification;
pub mod offline;
mod protobuf;
mod search;
mod stream_message;
mod util;
@@ -0,0 +1,480 @@
use crate::local_ai::chat::llm::LLMOllama;
use async_stream::stream;
use async_trait::async_trait;
use flowy_ai_pub::entities::{RAG_IDS, SOURCE_ID};
use futures::Stream;
use futures_util::{pin_mut, StreamExt};
use langchain_rust::chain::{
Chain, ChainError, CondenseQuestionGeneratorChain, CondenseQuestionPromptBuilder,
StuffDocumentBuilder, StuffQAPromptBuilder,
};
use langchain_rust::language_models::{GenerateResult, TokenUsage};
use langchain_rust::memory::SimpleMemory;
use langchain_rust::prompt::{FormatPrompter, PromptArgs};
use langchain_rust::schemas::{BaseMemory, Document, Message, Retriever, StreamData};
use langchain_rust::vectorstore::{VecStoreOptions, VectorStore};
use serde_json::{json, Value};
use std::error::Error;
use std::{collections::HashMap, pin::Pin, sync::Arc};
use tokio::sync::Mutex;
use tokio_util::either::Either;
use tracing::trace;
pub(crate) const DEFAULT_OUTPUT_KEY: &str = "output";
pub(crate) const DEFAULT_RESULT_KEY: &str = "generate_result";
const CONVERSATIONAL_RETRIEVAL_QA_DEFAULT_SOURCE_DOCUMENT_KEY: &str = "source_documents";
const CONVERSATIONAL_RETRIEVAL_QA_DEFAULT_GENERATED_QUESTION_KEY: &str = "generated_question";
const CONVERSATIONAL_RETRIEVAL_QA_DEFAULT_INPUT_KEY: &str = "question";
pub struct ConversationalRetrieverChain {
pub(crate) ollama: LLMOllama,
pub(crate) retriever: AFRetriever,
pub memory: Arc<Mutex<dyn BaseMemory>>,
pub(crate) combine_documents_chain: Box<dyn Chain>,
pub(crate) condense_question_chain: Box<dyn Chain>,
pub(crate) rephrase_question: bool,
pub(crate) return_source_documents: bool,
pub(crate) input_key: String,
pub(crate) output_key: String,
}
impl ConversationalRetrieverChain {
async fn get_question(
&self,
history: &[Message],
input: &str,
) -> Result<(String, Option<TokenUsage>), ChainError> {
if history.is_empty() {
return Ok((input.to_string(), None));
}
let mut token_usage: Option<TokenUsage> = None;
let question = match self.rephrase_question {
true => {
let result = self
.condense_question_chain
.call(
CondenseQuestionPromptBuilder::new()
.question(input)
.chat_history(history)
.build(),
)
.await?;
if let Some(tokens) = result.tokens {
token_usage = Some(tokens);
};
result.generation
},
false => input.to_string(),
};
Ok((question, token_usage))
}
async fn get_documents_or_result(
&self,
question: &str,
) -> Result<Either<Vec<Document>, GenerateResult>, ChainError> {
let rag_ids = self.retriever.get_rag_ids();
if rag_ids.is_empty() {
Ok(Either::Left(vec![]))
} else {
let documents = self
.retriever
.get_relevant_documents(question)
.await
.map_err(|e| ChainError::RetrieverError(e.to_string()))?;
if documents.is_empty() {
trace!(
"[Embedding] No relevant documents found, but we have RAG IDs:{:?}. return I don't know",
rag_ids
);
return Ok(Either::Right(GenerateResult {
tokens: None,
generation: "I couldn’t find any relevant information in the sources you selected. Please try asking a different question".to_string(),
}));
}
Ok(Either::Left(documents))
}
}
}
#[async_trait]
impl Chain for ConversationalRetrieverChain {
async fn call(&self, input_variables: PromptArgs) -> Result<GenerateResult, ChainError> {
let output = self.execute(input_variables).await?;
let result: GenerateResult = serde_json::from_value(output[DEFAULT_RESULT_KEY].clone())?;
Ok(result)
}
async fn execute(
&self,
input_variables: PromptArgs,
) -> Result<HashMap<String, Value>, ChainError> {
let mut token_usage: Option<TokenUsage> = None;
let input_variable = &input_variables
.get(&self.input_key)
.ok_or(ChainError::MissingInputVariable(self.input_key.clone()))?;
let human_message = Message::new_human_message(input_variable);
let history = {
let memory = self.memory.lock().await;
memory.messages().await
};
let (question, token) = self.get_question(&history, &human_message.content).await?;
if let Some(token) = token {
token_usage = Some(token);
}
let documents = match self.get_documents_or_result(&question).await? {
Either::Left(docs) => docs,
Either::Right(result) => {
let mut memory = self.memory.lock().await;
memory.add_message(human_message).await;
memory
.add_message(Message::new_ai_message(&result.generation))
.await;
let mut output = HashMap::new();
output.insert(self.output_key.clone(), json!(result.generation));
output.insert(DEFAULT_RESULT_KEY.to_string(), json!(result));
return Ok(output);
},
};
let mut output = self
.combine_documents_chain
.call(
StuffQAPromptBuilder::new()
.documents(&documents)
.question(question.clone())
.build(),
)
.await?;
if let Some(tokens) = &output.tokens {
if let Some(mut token_usage) = token_usage {
token_usage.add(tokens);
output.tokens = Some(token_usage)
}
}
{
let mut memory = self.memory.lock().await;
memory.add_message(human_message).await;
memory
.add_message(Message::new_ai_message(&output.generation))
.await;
}
let mut result = HashMap::new();
result.insert(self.output_key.clone(), json!(output.generation));
result.insert(DEFAULT_RESULT_KEY.to_string(), json!(output));
if self.return_source_documents {
result.insert(
CONVERSATIONAL_RETRIEVAL_QA_DEFAULT_SOURCE_DOCUMENT_KEY.to_string(),
json!(documents),
);
}
if self.rephrase_question {
result.insert(
CONVERSATIONAL_RETRIEVAL_QA_DEFAULT_GENERATED_QUESTION_KEY.to_string(),
json!(question),
);
}
Ok(result)
}
async fn stream(
&self,
input_variables: PromptArgs,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamData, ChainError>> + Send>>, ChainError> {
let input_variable = &input_variables
.get(&self.input_key)
.ok_or(ChainError::MissingInputVariable(self.input_key.clone()))?;
let human_message = Message::new_human_message(input_variable);
let history = {
let memory = self.memory.lock().await;
memory.messages().await
};
let (question, _) = self.get_question(&history, &human_message.content).await?;
let documents = match self.get_documents_or_result(&question).await? {
Either::Left(docs) => docs,
Either::Right(result) => {
let mut memory = self.memory.lock().await;
memory.add_message(human_message).await;
memory
.add_message(Message::new_ai_message(&result.generation))
.await;
return Ok(Box::pin(stream! {
yield Ok(StreamData::new(
json!(result),
result.tokens,
result.generation,
));
}));
},
};
let stream = self
.combine_documents_chain
.stream(
StuffQAPromptBuilder::new()
.documents(&documents)
.question(question.clone())
.build(),
)
.await?;
let sources = deduplicate_metadata(&documents);
let memory = self.memory.clone();
let complete_ai_message = Arc::new(Mutex::new(String::new()));
let complete_ai_message_clone = complete_ai_message.clone();
let output_stream = stream! {
pin_mut!(stream);
while let Some(result) = stream.next().await {
match result {
Ok(data) => {
let mut complete_ai_message_clone =
complete_ai_message_clone.lock().await;
complete_ai_message_clone.push_str(&data.content);
yield Ok(data);
},
Err(e) => {
yield Err(e);
}
}
}
for source in sources {
yield Ok(StreamData::new(
json!({"source": source}),
None,
"".to_string(),
));
}
let mut memory = memory.lock().await;
memory.add_message(human_message).await;
memory.add_message(Message::new_ai_message(&complete_ai_message.lock().await)).await;
};
Ok(Box::pin(output_stream))
}
fn get_input_keys(&self) -> Vec<String> {
vec![self.input_key.clone()]
}
fn get_output_keys(&self) -> Vec<String> {
let mut keys = Vec::new();
if self.return_source_documents {
keys.push(CONVERSATIONAL_RETRIEVAL_QA_DEFAULT_SOURCE_DOCUMENT_KEY.to_string());
}
if self.rephrase_question {
keys.push(CONVERSATIONAL_RETRIEVAL_QA_DEFAULT_GENERATED_QUESTION_KEY.to_string());
}
keys.push(self.output_key.clone());
keys.push(DEFAULT_RESULT_KEY.to_string());
keys
}
}
pub struct ConversationalRetrieverChainBuilder {
llm: Option<LLMOllama>,
retriever: Option<AFRetriever>,
memory: Option<Arc<Mutex<dyn BaseMemory>>>,
combine_documents_chain: Option<Box<dyn Chain>>,
condense_question_chain: Option<Box<dyn Chain>>,
prompt: Option<Box<dyn FormatPrompter>>,
rephrase_question: bool,
return_source_documents: bool,
input_key: String,
output_key: String,
}
impl ConversationalRetrieverChainBuilder {
pub fn new() -> Self {
ConversationalRetrieverChainBuilder {
llm: None,
retriever: None,
memory: None,
combine_documents_chain: None,
condense_question_chain: None,
prompt: None,
rephrase_question: true,
return_source_documents: true,
input_key: CONVERSATIONAL_RETRIEVAL_QA_DEFAULT_INPUT_KEY.to_string(),
output_key: DEFAULT_OUTPUT_KEY.to_string(),
}
}
pub fn retriever(mut self, retriever: AFRetriever) -> Self {
self.retriever = Some(retriever);
self
}
///If you want to add a custom prompt,keep in mind which variables are obligatory.
pub fn prompt<P: Into<Box<dyn FormatPrompter>>>(mut self, prompt: P) -> Self {
self.prompt = Some(prompt.into());
self
}
pub fn memory(mut self, memory: Arc<Mutex<dyn BaseMemory>>) -> Self {
self.memory = Some(memory);
self
}
pub fn llm(mut self, llm: LLMOllama) -> Self {
self.llm = Some(llm);
self
}
///Chain designed to take the documents and the question and generate an output
#[allow(dead_code)]
pub fn combine_documents_chain<C: Into<Box<dyn Chain>>>(
mut self,
combine_documents_chain: C,
) -> Self {
self.combine_documents_chain = Some(combine_documents_chain.into());
self
}
///Chain designed to reformulate the question based on the cat history
#[allow(dead_code)]
pub fn condense_question_chain<C: Into<Box<dyn Chain>>>(
mut self,
condense_question_chain: C,
) -> Self {
self.condense_question_chain = Some(condense_question_chain.into());
self
}
pub fn rephrase_question(mut self, rephrase_question: bool) -> Self {
self.rephrase_question = rephrase_question;
self
}
#[allow(dead_code)]
pub fn return_source_documents(mut self, return_source_documents: bool) -> Self {
self.return_source_documents = return_source_documents;
self
}
pub fn build(mut self) -> Result<ConversationalRetrieverChain, ChainError> {
if let Some(llm) = self.llm.as_ref() {
let combine_documents_chain = {
let mut builder = StuffDocumentBuilder::new().llm(llm.clone());
if let Some(prompt) = self.prompt {
builder = builder.prompt(prompt);
}
builder.build()?
};
let condense_question_chain = CondenseQuestionGeneratorChain::new(llm.clone());
self.combine_documents_chain = Some(Box::new(combine_documents_chain));
self.condense_question_chain = Some(Box::new(condense_question_chain));
}
let retriever = self
.retriever
.ok_or_else(|| ChainError::MissingObject("Retriever must be set".into()))?;
let memory = self
.memory
.unwrap_or_else(|| Arc::new(Mutex::new(SimpleMemory::new())));
let combine_documents_chain = self.combine_documents_chain.ok_or_else(|| {
ChainError::MissingObject("Combine documents chain must be set or llm must be set".into())
})?;
let condense_question_chain = self.condense_question_chain.ok_or_else(|| {
ChainError::MissingObject("Condense question chain must be set or llm must be set".into())
})?;
Ok(ConversationalRetrieverChain {
ollama: self.llm.unwrap(),
retriever,
memory,
combine_documents_chain,
condense_question_chain,
rephrase_question: self.rephrase_question,
return_source_documents: self.return_source_documents,
input_key: self.input_key,
output_key: self.output_key,
})
}
}
// Retriever is a retriever for vector stores.
pub type RetrieverOption = VecStoreOptions<Value>;
pub struct AFRetriever {
vector_store: Box<dyn VectorStore<Options = RetrieverOption>>,
num_docs: usize,
options: RetrieverOption,
}
impl AFRetriever {
pub fn new<V: Into<Box<dyn VectorStore<Options = RetrieverOption>>>>(
vector_store: V,
num_docs: usize,
options: RetrieverOption,
) -> Self {
AFRetriever {
vector_store: vector_store.into(),
num_docs,
options,
}
}
pub fn set_rag_ids(&mut self, new_rag_ids: Vec<String>) {
trace!("[VectorStore] retriever {:p}", self);
let filters = self.options.filters.get_or_insert_with(|| json!({}));
filters[RAG_IDS] = json!(new_rag_ids);
}
pub fn get_rag_ids(&self) -> Vec<&str> {
trace!("[VectorStore] retriever {:p}", self);
self
.options
.filters
.as_ref()
.and_then(|filters| filters.get(RAG_IDS).and_then(|rag_ids| rag_ids.as_array()))
.map(|rag_ids| rag_ids.iter().filter_map(|id| id.as_str()).collect())
.unwrap_or_default()
}
}
#[async_trait]
impl Retriever for AFRetriever {
async fn get_relevant_documents(&self, query: &str) -> Result<Vec<Document>, Box<dyn Error>> {
trace!(
"[VectorStore] filters: {:?}, retrieving documents for query: {}",
self.options.filters,
query,
);
self
.vector_store
.similarity_search(query, self.num_docs, &self.options)
.await
}
}
/// Deduplicates metadata from a list of documents by merging metadata entries with the same keys
fn deduplicate_metadata(documents: &[Document]) -> Vec<Value> {
let mut merged_metadata: HashMap<String, Value> = HashMap::new();
for document in documents {
if let Some(object_id) = document.metadata.get(SOURCE_ID).and_then(|s| s.as_str()) {
merged_metadata.insert(object_id.to_string(), json!(document.metadata.clone()));
}
}
merged_metadata.into_values().collect()
}
Loaded 100 of 238 files, more files were not shown because too many files have changed in this diff. Show more