Compare commits
11
Commits
0.9.0
...
select_source
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4354227a05 | ||
|
|
f7b556677f | ||
|
|
19b1bccb03 | ||
|
|
0f5699571c | ||
|
|
53cbd22111 | ||
|
|
926a5b31bb | ||
|
|
bdac73e934 | ||
|
|
20f3116bd9 | ||
|
|
a2b1f8c325 | ||
|
|
612d652320 | ||
|
|
7750f2257f |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+91
-68
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+375
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -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());
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
@@ -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()),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+137
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
+117
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+154
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
-3
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+110
-29
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
-12
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -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: '',
|
||||
|
||||
+4
-9
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
+1
-4
@@ -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,
|
||||
|
||||
+12
-2
@@ -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),
|
||||
|
||||
+11
-2
@@ -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(
|
||||
|
||||
+1
-3
@@ -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)),
|
||||
|
||||
+20
-9
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
+6
-76
@@ -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,
|
||||
),
|
||||
|
||||
+3
-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,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
+8
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1176
-178
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -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 =
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
|
||||
+207
@@ -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"
|
||||
);
|
||||
}
|
||||
-215
@@ -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;
|
||||
+33
-1
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
File renamed without changes.
@@ -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::*;
|
||||
@@ -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 = []
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(¶graph);
|
||||
result.extend(paragraph_chunks.map(String::from));
|
||||
} else {
|
||||
current.push_str(¶graph);
|
||||
}
|
||||
} else {
|
||||
// Add paragraph to current chunk
|
||||
current.push_str(¶graph);
|
||||
}
|
||||
}
|
||||
|
||||
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(¶ms)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user