Compare commits
10
Commits
0.9.1
...
select_source
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4354227a05 | ||
|
|
f7b556677f | ||
|
|
19b1bccb03 | ||
|
|
0f5699571c | ||
|
|
53cbd22111 | ||
|
|
926a5b31bb | ||
|
|
bdac73e934 | ||
|
|
20f3116bd9 | ||
|
|
a2b1f8c325 | ||
|
|
612d652320 |
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,7 +19,7 @@ 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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
+35
-34
@@ -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]
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use crate::local_ai::prompt::format_prompt;
|
||||
use flowy_ai_pub::cloud::ResponseFormat;
|
||||
use flowy_error::{FlowyError, FlowyResult};
|
||||
use langchain_rust::prompt::{
|
||||
FormatPrompter, HumanMessagePromptTemplate, MessageFormatter, PromptArgs, PromptError,
|
||||
};
|
||||
use langchain_rust::schemas::{Message, PromptValue};
|
||||
use langchain_rust::template_jinja2;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
const DEFAULT_QA_TEMPLATE: &str = r#"
|
||||
Only Use the context provided below to formulate your answer. Do not use any other information. If the context doesn't contain sufficient information to answer the question, respond with "I don't know".
|
||||
Do not reference external knowledge or information outside the context.
|
||||
|
||||
##Context##
|
||||
{{context}}
|
||||
|
||||
Question:{{question}}
|
||||
Answer:
|
||||
"#;
|
||||
|
||||
struct FormatState {
|
||||
format_msg: Arc<Message>,
|
||||
format: ResponseFormat,
|
||||
}
|
||||
|
||||
pub struct AFMessageFormatter {
|
||||
system_msg: Arc<Message>,
|
||||
state: Arc<RwLock<FormatState>>,
|
||||
user_tmpl: Arc<HumanMessagePromptTemplate>,
|
||||
}
|
||||
|
||||
impl AFMessageFormatter {
|
||||
pub fn new(system_msg: Message, fmt: &ResponseFormat) -> Self {
|
||||
// Compile the Jinja template exactly once
|
||||
let user_tmpl =
|
||||
HumanMessagePromptTemplate::new(template_jinja2!(DEFAULT_QA_TEMPLATE, "context", "question"));
|
||||
|
||||
let state = FormatState {
|
||||
format_msg: Arc::new(format_prompt(fmt)),
|
||||
format: fmt.clone(),
|
||||
};
|
||||
|
||||
Self {
|
||||
system_msg: Arc::new(system_msg),
|
||||
state: Arc::new(RwLock::new(state)),
|
||||
user_tmpl: Arc::new(user_tmpl),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if we actually swapped in a new instruction
|
||||
pub fn update_format(&self, new_fmt: &ResponseFormat) -> FlowyResult<()> {
|
||||
let mut st = self
|
||||
.state
|
||||
.write()
|
||||
.map_err(|err| FlowyError::internal().with_context(err))?;
|
||||
|
||||
if st.format.output_layout != new_fmt.output_layout {
|
||||
st.format = new_fmt.clone();
|
||||
st.format_msg = Arc::new(format_prompt(new_fmt));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for AFMessageFormatter {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
system_msg: Arc::clone(&self.system_msg),
|
||||
state: Arc::clone(&self.state),
|
||||
user_tmpl: Arc::clone(&self.user_tmpl),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageFormatter for AFMessageFormatter {
|
||||
fn format_messages(&self, args: PromptArgs) -> Result<Vec<Message>, PromptError> {
|
||||
let mut out = Vec::with_capacity(3);
|
||||
out.push((*self.system_msg).clone());
|
||||
|
||||
if let Ok(st) = self.state.try_read() {
|
||||
out.push((*st.format_msg).clone());
|
||||
}
|
||||
|
||||
out.extend(self.user_tmpl.format_messages(args)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn input_variables(&self) -> Vec<String> {
|
||||
vec!["context".into(), "question".into()]
|
||||
}
|
||||
}
|
||||
|
||||
impl FormatPrompter for AFMessageFormatter {
|
||||
fn format_prompt(&self, input_variables: PromptArgs) -> Result<PromptValue, PromptError> {
|
||||
let messages = self.format_messages(input_variables)?;
|
||||
Ok(PromptValue::from_messages(messages))
|
||||
}
|
||||
|
||||
fn get_input_variables(&self) -> Vec<String> {
|
||||
self.input_variables()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use langchain_rust::language_models::llm::LLM;
|
||||
use langchain_rust::language_models::{GenerateResult, LLMError, TokenUsage};
|
||||
use langchain_rust::schemas::{Message, StreamData};
|
||||
use ollama_rs::error::OllamaError;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use ollama_rs::generation::chat::request::ChatMessageRequest;
|
||||
use ollama_rs::generation::parameters::FormatType;
|
||||
use ollama_rs::models::ModelOptions;
|
||||
use ollama_rs::Ollama;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LLMOllama {
|
||||
pub model_name: String,
|
||||
ollama: Arc<Ollama>,
|
||||
format: Option<FormatType>,
|
||||
options: Option<ModelOptions>,
|
||||
}
|
||||
|
||||
impl Default for LLMOllama {
|
||||
fn default() -> Self {
|
||||
LLMOllama {
|
||||
model_name: "llama3.1".to_string(),
|
||||
ollama: Arc::new(Ollama::default()),
|
||||
format: None,
|
||||
options: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LLMOllama {
|
||||
pub fn new(
|
||||
model: &str,
|
||||
ollama: Arc<Ollama>,
|
||||
format: Option<FormatType>,
|
||||
options: Option<ModelOptions>,
|
||||
) -> Self {
|
||||
LLMOllama {
|
||||
model_name: model.to_string(),
|
||||
ollama,
|
||||
format,
|
||||
options,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_options(mut self, options: ModelOptions) -> Self {
|
||||
self.options = Some(options);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_format(mut self, format: FormatType) -> Self {
|
||||
self.format = Some(format);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_model(mut self, model: &str) -> Self {
|
||||
self.model_name = model.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_model(&mut self, model: &str) {
|
||||
self.model_name = model.to_string();
|
||||
}
|
||||
|
||||
fn generate_request(&self, messages: &[Message]) -> ChatMessageRequest {
|
||||
let mapped_messages = messages.iter().map(|message| message.into()).collect();
|
||||
let mut request = ChatMessageRequest::new(self.model_name.clone(), mapped_messages);
|
||||
if let Some(option) = &self.options {
|
||||
request = request.options(option.clone())
|
||||
}
|
||||
if let Some(format) = &self.format {
|
||||
request = request.format(format.clone());
|
||||
}
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LLM for LLMOllama {
|
||||
async fn generate(&self, messages: &[Message]) -> Result<GenerateResult, LLMError> {
|
||||
let request = self.generate_request(messages);
|
||||
let result = self.ollama.send_chat_messages(request).await?;
|
||||
let generation = result.message.content;
|
||||
let tokens = result.final_data.map(|final_data| {
|
||||
let prompt_tokens = final_data.prompt_eval_count as u32;
|
||||
let completion_tokens = final_data.eval_count as u32;
|
||||
TokenUsage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens: prompt_tokens + completion_tokens,
|
||||
}
|
||||
});
|
||||
|
||||
Ok(GenerateResult { tokens, generation })
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamData, LLMError>> + Send>>, LLMError> {
|
||||
let request = self.generate_request(messages);
|
||||
let result = self.ollama.send_chat_messages_stream(request).await?;
|
||||
|
||||
let stream = result.map(|data| match data {
|
||||
Ok(data) => Ok(StreamData::new(
|
||||
serde_json::to_value(&data).unwrap_or_default(),
|
||||
None,
|
||||
data.message.content,
|
||||
)),
|
||||
Err(_) => Err(OllamaError::Other("Stream error".to_string()).into()),
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
use crate::local_ai::chat::conversation_chain::{
|
||||
AFRetriever, ConversationalRetrieverChain, ConversationalRetrieverChainBuilder, RetrieverOption,
|
||||
};
|
||||
use crate::local_ai::chat::format_prompt::AFMessageFormatter;
|
||||
use crate::local_ai::chat::llm::LLMOllama;
|
||||
use crate::local_ai::chat::summary_memory::SummaryMemory;
|
||||
use crate::local_ai::chat::{LLMChatInfo, OllamaClientRef};
|
||||
use crate::SqliteVectorStore;
|
||||
use flowy_ai_pub::cloud::{QuestionStreamValue, ResponseFormat, StreamAnswer};
|
||||
use flowy_ai_pub::entities::{RAG_IDS, SOURCE_ID};
|
||||
use flowy_ai_pub::user_service::AIUserService;
|
||||
use flowy_error::{FlowyError, FlowyResult};
|
||||
use futures::StreamExt;
|
||||
use langchain_rust::chain::{Chain, ChainError};
|
||||
use langchain_rust::memory::SimpleMemory;
|
||||
use langchain_rust::prompt_args;
|
||||
use langchain_rust::schemas::{Document, Message};
|
||||
use langchain_rust::vectorstore::{VecStoreOptions, VectorStore};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Weak;
|
||||
use tracing::{info, trace};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct LLMChat {
|
||||
store: Option<SqliteVectorStore>,
|
||||
chain: ConversationalRetrieverChain,
|
||||
#[allow(dead_code)]
|
||||
client: OllamaClientRef,
|
||||
formatter: AFMessageFormatter,
|
||||
info: LLMChatInfo,
|
||||
}
|
||||
|
||||
impl LLMChat {
|
||||
pub async fn new(
|
||||
info: LLMChatInfo,
|
||||
client: OllamaClientRef,
|
||||
store: Option<SqliteVectorStore>,
|
||||
user_service: Option<Weak<dyn AIUserService>>,
|
||||
) -> FlowyResult<Self> {
|
||||
let response_format = ResponseFormat::default();
|
||||
let formatter = create_formatter_prompt_with_format(&response_format);
|
||||
let llm = create_llm(&client, &info.model).await?;
|
||||
let summary_llm = create_llm(&client, &info.model).await?;
|
||||
let memory = SummaryMemory::new(summary_llm, info.summary.clone(), user_service)
|
||||
.await
|
||||
.map(|v| v.into())
|
||||
.unwrap_or(SimpleMemory::new().into());
|
||||
|
||||
let mut builder = ConversationalRetrieverChainBuilder::new()
|
||||
.llm(llm)
|
||||
.rephrase_question(false)
|
||||
.memory(memory);
|
||||
|
||||
if let Some(store) = store.clone() {
|
||||
let retriever = create_retriever(&info.workspace_id, info.rag_ids.clone(), store);
|
||||
builder = builder.retriever(retriever);
|
||||
}
|
||||
|
||||
let chain = builder
|
||||
.prompt(formatter.clone())
|
||||
.build()
|
||||
.map_err(|err| FlowyError::local_ai().with_context(err))?;
|
||||
|
||||
Ok(Self {
|
||||
store,
|
||||
chain,
|
||||
client,
|
||||
formatter,
|
||||
info,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_chat_model(&mut self, model: &str) {
|
||||
self.chain.ollama.set_model(model);
|
||||
}
|
||||
|
||||
pub async fn set_rag_ids(&mut self, rag_ids: Vec<String>) {
|
||||
info!(
|
||||
"[VectorStore]: {} set rag ids: {:?}",
|
||||
self.info.chat_id, rag_ids
|
||||
);
|
||||
self.chain.retriever.set_rag_ids(rag_ids);
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
ids: Vec<String>,
|
||||
) -> FlowyResult<Vec<Document>> {
|
||||
let store = self
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or_else(|| FlowyError::local_ai().with_context("VectorStore is not initialized"))?;
|
||||
|
||||
let options = RetrieverOption::new()
|
||||
.with_filters(json!({RAG_IDS: ids, "workspace_id": self.info.workspace_id}));
|
||||
let result = store
|
||||
.similarity_search(query, limit, &options)
|
||||
.await
|
||||
.map_err(|err| FlowyError::local_ai().with_context(err))?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
|
||||
pub async fn get_all_embedded_documents(
|
||||
&self,
|
||||
) -> FlowyResult<Vec<flowy_sqlite_vec::entities::SqliteEmbeddedDocument>> {
|
||||
let store = self
|
||||
.store
|
||||
.as_ref()
|
||||
.ok_or_else(|| FlowyError::local_ai().with_context("VectorStore is not initialized"))?;
|
||||
|
||||
store
|
||||
.select_all_embedded_documents(&self.info.workspace_id.to_string(), &self.info.rag_ids)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
FlowyError::local_ai().with_context(format!("Failed to select embedded documents: {}", err))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn embed_paragraphs(
|
||||
&self,
|
||||
object_id: &str,
|
||||
paragraphs: Vec<String>,
|
||||
) -> FlowyResult<()> {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("workspace_id".to_string(), json!(self.info.workspace_id));
|
||||
metadata.insert(SOURCE_ID.to_string(), json!(object_id));
|
||||
let document = Document::new(paragraphs.join("\n\n")).with_metadata(metadata);
|
||||
if let Some(store) = &self.store {
|
||||
store
|
||||
.add_documents(&[document], &VecStoreOptions::default())
|
||||
.await
|
||||
.map_err(|err| FlowyError::local_ai().with_context(err))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ask_question(&self, question: &str) -> FlowyResult<String> {
|
||||
let input_variables = prompt_args! {
|
||||
"question" => question,
|
||||
};
|
||||
|
||||
let result = self
|
||||
.chain
|
||||
.invoke(input_variables)
|
||||
.await
|
||||
.map_err(map_chain_error)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Send a message to the chat and get a response
|
||||
pub async fn stream_question(
|
||||
&mut self,
|
||||
message: &str,
|
||||
format: ResponseFormat,
|
||||
) -> Result<StreamAnswer, FlowyError> {
|
||||
self.formatter.update_format(&format)?;
|
||||
let input_variables = prompt_args! {
|
||||
"question" => message,
|
||||
};
|
||||
|
||||
let stream_result = self.chain.stream(input_variables).await;
|
||||
let stream = stream_result.map_err(map_chain_error)?;
|
||||
let transformed_stream = stream.map(|result| {
|
||||
result
|
||||
.map(|stream_data| {
|
||||
if let Some(source) = stream_data.value.as_object().and_then(|v| v.get("source")) {
|
||||
trace!("[VectorStore]: reference sources: {:?}", source);
|
||||
QuestionStreamValue::Metadata {
|
||||
value: source.clone(),
|
||||
}
|
||||
} else {
|
||||
QuestionStreamValue::Answer {
|
||||
value: stream_data.content,
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(map_chain_error)
|
||||
});
|
||||
Ok(Box::pin(transformed_stream))
|
||||
}
|
||||
}
|
||||
|
||||
fn create_formatter_prompt_with_format(format: &ResponseFormat) -> AFMessageFormatter {
|
||||
let system_message =
|
||||
Message::new_system_message("You are an assistant for question-answering tasks");
|
||||
|
||||
AFMessageFormatter::new(system_message, format)
|
||||
}
|
||||
|
||||
fn create_retriever(
|
||||
workspace_id: &Uuid,
|
||||
rag_ids: Vec<String>,
|
||||
store: SqliteVectorStore,
|
||||
) -> AFRetriever {
|
||||
trace!(
|
||||
"[VectorStore]: {} create retriever with rag_ids: {:?}",
|
||||
workspace_id,
|
||||
rag_ids,
|
||||
);
|
||||
let options = VecStoreOptions::default()
|
||||
.with_score_threshold(0.2)
|
||||
.with_filters(json!({RAG_IDS: rag_ids, "workspace_id": workspace_id}));
|
||||
|
||||
AFRetriever::new(store, 5, options)
|
||||
}
|
||||
|
||||
async fn create_llm(client: &OllamaClientRef, model: &str) -> FlowyResult<LLMOllama> {
|
||||
let read_guard = client.read().await;
|
||||
let client = read_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| FlowyError::local_ai().with_context("Ollama client not initialized"))?
|
||||
.upgrade()
|
||||
.ok_or_else(|| FlowyError::local_ai().with_context("Ollama client has been dropped"))?
|
||||
.clone();
|
||||
let ollama = LLMOllama::new(model, client, None, None);
|
||||
Ok(ollama)
|
||||
}
|
||||
|
||||
fn map_chain_error(err: ChainError) -> FlowyError {
|
||||
match err {
|
||||
ChainError::MissingInputVariable(var) => {
|
||||
FlowyError::local_ai().with_context(format!("Missing input variable: {}", var))
|
||||
},
|
||||
ChainError::MissingObject(obj) => {
|
||||
FlowyError::local_ai().with_context(format!("Missing object: {}", obj))
|
||||
},
|
||||
ChainError::RetrieverError(err) => {
|
||||
FlowyError::local_ai().with_context(format!("Retriever error: {}", err))
|
||||
},
|
||||
_ => FlowyError::local_ai().with_context(format!("Chain error: {:?}", err)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
mod conversation_chain;
|
||||
mod format_prompt;
|
||||
pub mod llm;
|
||||
pub mod llm_chat;
|
||||
pub mod related_question_chain;
|
||||
mod summary_memory;
|
||||
|
||||
use crate::local_ai::chat::llm::LLMOllama;
|
||||
use crate::local_ai::chat::llm_chat::LLMChat;
|
||||
use crate::local_ai::chat::related_question_chain::RelatedQuestionChain;
|
||||
use crate::local_ai::completion::chain::CompletionChain;
|
||||
use crate::local_ai::database::summary::DatabaseSummaryChain;
|
||||
use crate::local_ai::database::translate::DatabaseTranslateChain;
|
||||
use crate::SqliteVectorStore;
|
||||
use dashmap::DashMap;
|
||||
use flowy_ai_pub::cloud::ai_dto::{TranslateRowData, TranslateRowResponse};
|
||||
use flowy_ai_pub::cloud::chat_dto::ChatAuthorType;
|
||||
use flowy_ai_pub::cloud::{
|
||||
CompleteTextParams, CompletionType, ResponseFormat, StreamAnswer, StreamComplete,
|
||||
};
|
||||
use flowy_ai_pub::persistence::select_latest_user_message;
|
||||
use flowy_ai_pub::user_service::AIUserService;
|
||||
use flowy_database_pub::cloud::{SummaryRowContent, TranslateRowContent};
|
||||
use flowy_error::{FlowyError, FlowyResult};
|
||||
use futures_util::StreamExt;
|
||||
use ollama_rs::Ollama;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Weak};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::trace;
|
||||
use uuid::Uuid;
|
||||
|
||||
type OllamaClientRef = Arc<RwLock<Option<Weak<Ollama>>>>;
|
||||
|
||||
pub struct LLMChatInfo {
|
||||
pub chat_id: Uuid,
|
||||
pub workspace_id: Uuid,
|
||||
pub model: String,
|
||||
pub rag_ids: Vec<String>,
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
pub struct LLMChatController {
|
||||
chat_by_id: DashMap<Uuid, LLMChat>,
|
||||
store: RwLock<Option<SqliteVectorStore>>,
|
||||
client: OllamaClientRef,
|
||||
user_service: Weak<dyn AIUserService>,
|
||||
}
|
||||
impl LLMChatController {
|
||||
pub fn new(user_service: Weak<dyn AIUserService>) -> Self {
|
||||
Self {
|
||||
store: RwLock::new(None),
|
||||
chat_by_id: DashMap::new(),
|
||||
client: Default::default(),
|
||||
user_service,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_ready(&self) -> bool {
|
||||
self.client.read().await.is_some()
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
|
||||
pub async fn initialize(
|
||||
&self,
|
||||
ollama: Weak<Ollama>,
|
||||
vector_db: Weak<flowy_sqlite_vec::db::VectorSqliteDB>,
|
||||
) {
|
||||
let store = SqliteVectorStore::new(ollama.clone(), vector_db);
|
||||
*self.client.write().await = Some(ollama);
|
||||
*self.store.write().await = Some(store);
|
||||
}
|
||||
|
||||
pub async fn set_rag_ids(&self, chat_id: &Uuid, rag_ids: &[String]) {
|
||||
if let Some(mut chat) = self.chat_by_id.get_mut(chat_id) {
|
||||
chat.set_rag_ids(rag_ids.to_vec()).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_chat(&self, info: LLMChatInfo) -> FlowyResult<()> {
|
||||
let store = self.store.read().await.clone();
|
||||
let chat_id = info.chat_id;
|
||||
let chat = LLMChat::new(
|
||||
info,
|
||||
self.client.clone(),
|
||||
store,
|
||||
Some(self.user_service.clone()),
|
||||
)
|
||||
.await?;
|
||||
self.chat_by_id.insert(chat_id, chat);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn close_chat(&self, chat_id: &Uuid) {
|
||||
self.chat_by_id.remove(chat_id);
|
||||
}
|
||||
|
||||
pub async fn summarize_database_row(
|
||||
&self,
|
||||
model_name: &str,
|
||||
data: SummaryRowContent,
|
||||
) -> FlowyResult<String> {
|
||||
let client = self
|
||||
.client
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or(FlowyError::local_ai())?
|
||||
.upgrade()
|
||||
.ok_or(FlowyError::local_ai())?;
|
||||
|
||||
let chain = DatabaseSummaryChain::new(LLMOllama::new(model_name, client, None, None));
|
||||
let response = chain.summarize(data).await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn translate_database_row(
|
||||
&self,
|
||||
model_name: &str,
|
||||
cells: TranslateRowContent,
|
||||
language: &str,
|
||||
) -> FlowyResult<TranslateRowResponse> {
|
||||
let client = self
|
||||
.client
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or(FlowyError::local_ai())?
|
||||
.upgrade()
|
||||
.ok_or(FlowyError::local_ai())?;
|
||||
|
||||
let chain = DatabaseTranslateChain::new(LLMOllama::new(model_name, client, None, None));
|
||||
let data = TranslateRowData {
|
||||
cells,
|
||||
language: language.to_string(),
|
||||
include_header: false,
|
||||
};
|
||||
let resp = chain.translate(data).await?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn complete_text(
|
||||
&self,
|
||||
model_name: &str,
|
||||
params: CompleteTextParams,
|
||||
) -> Result<StreamComplete, FlowyError> {
|
||||
let client = self
|
||||
.client
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or(FlowyError::local_ai())?
|
||||
.upgrade()
|
||||
.ok_or(FlowyError::local_ai())?;
|
||||
|
||||
let chain = CompletionChain::new(LLMOllama::new(model_name, client, None, None));
|
||||
let ty = params.completion_type.unwrap_or(CompletionType::AskAI);
|
||||
let stream = chain
|
||||
.complete(¶ms.text, ty, params.format, params.metadata)
|
||||
.await?
|
||||
.boxed();
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub async fn embed_file(
|
||||
&self,
|
||||
_chat_id: &Uuid,
|
||||
_file_path: PathBuf,
|
||||
_metadata: Option<HashMap<String, Value>>,
|
||||
) -> FlowyResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_related_question(
|
||||
&self,
|
||||
model_name: &str,
|
||||
chat_id: &Uuid,
|
||||
_message_id: i64,
|
||||
) -> FlowyResult<Vec<String>> {
|
||||
let client = self
|
||||
.client
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or(FlowyError::local_ai())?
|
||||
.upgrade()
|
||||
.ok_or(FlowyError::local_ai())?;
|
||||
|
||||
let user_service = self.user_service.upgrade().ok_or(FlowyError::local_ai())?;
|
||||
let uid = user_service.user_id()?;
|
||||
let conn = user_service.sqlite_connection(uid)?;
|
||||
let message = select_latest_user_message(conn, &chat_id.to_string(), ChatAuthorType::Human)?;
|
||||
|
||||
let chain = RelatedQuestionChain::new(LLMOllama::new(model_name, client, None, None));
|
||||
let questions = chain.related_question(&message.content).await?;
|
||||
trace!(
|
||||
"related questions: {:?} for message: {}",
|
||||
questions,
|
||||
message.content
|
||||
);
|
||||
Ok(questions)
|
||||
}
|
||||
|
||||
pub async fn ask_question(&self, chat_id: &Uuid, question: &str) -> FlowyResult<String> {
|
||||
if let Some(chat) = self.chat_by_id.get(chat_id) {
|
||||
let chat = chat.value();
|
||||
let response = chat.ask_question(question).await;
|
||||
return response;
|
||||
}
|
||||
|
||||
Err(FlowyError::local_ai().with_context(format!("Chat with id {} not found", chat_id)))
|
||||
}
|
||||
|
||||
pub async fn stream_question(
|
||||
&self,
|
||||
chat_id: &Uuid,
|
||||
question: &str,
|
||||
format: ResponseFormat,
|
||||
model_name: &str,
|
||||
) -> FlowyResult<StreamAnswer> {
|
||||
if let Some(mut chat) = self.chat_by_id.get_mut(chat_id) {
|
||||
chat.set_chat_model(model_name);
|
||||
|
||||
let response = chat.stream_question(question, format).await;
|
||||
return response;
|
||||
}
|
||||
|
||||
Err(FlowyError::local_ai().with_context(format!("Chat with id {} not found", chat_id)))
|
||||
}
|
||||
}
|
||||
Loaded 100 of 231 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user