Compare commits

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

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

* chore: temp

* chore: clippy

* chore: fix ui bugs

* chore: fix ui bugs

* chore: fix view title

* chore: cloud id

* chore: bump server

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

* feat: add non-reversed animated chat list

* chore: add scroll animation

* feat: add fade animation

* fix: refresh issue

* feat: add flag to control the ai chat animation

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

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

* refactor: add test

* fix: add source

* fix: add source

* chore: notify all unset source

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

* chore: remove sentry

* fix: allow the member to get the workspace usage

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

* chore: save embedding

* chore: vec sqlite

* chore: clippy

* chore: init vector

* chore: create vector db

* chore: periodically write embedding

* chore: fix compile

* chore: skip write

* chore: impl search

* fix: test

* fix: stop scheduler

* fix: search

* chore: add test

* chore: update schema

* chore: index all

* chore: index

* chore: search document content

* chore: index document content and title

* chore: index all view

* chore: rename trait

* refactor: remove folder index manager

* chore: index folder changes

* chore: delete folder search

* chore: update logs

* chore: update logs

* chore: search

* chore: add search test

* chore: fmt

* chore: fix test

* chore: fix ios build
2025-05-02 08:21:42 +08:00
219 changed files with 12362 additions and 6121 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
+2 -2
View File
@@ -26,9 +26,9 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.9.37-amd64
CLOUD_VERSION: 0.9.49-amd64
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+1 -1
View File
@@ -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 }}
+11 -11
View File
@@ -7,7 +7,7 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
jobs:
create-release:
@@ -232,10 +232,10 @@ jobs:
matrix:
job:
- {
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -336,12 +336,12 @@ jobs:
matrix:
job:
- {
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
+3 -3
View File
@@ -18,8 +18,8 @@ on:
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.8.3-amd64
RUST_TOOLCHAIN: "1.81.0"
CLOUD_VERSION: 0.9.49-amd64
RUST_TOOLCHAIN: "1.84.0"
jobs:
ubuntu-job:
@@ -106,7 +106,7 @@ jobs:
af_cloud_test_ws_url: ws://localhost/ws/v1
af_cloud_test_gotrue_url: http://localhost/gotrue
run: |
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart"
DISABLE_CI_TEST_LOG="true" cargo test --no-default-features --features="dart" -- --skip local_ollama_test
- name: rustfmt rust-lib
run: cargo fmt --all -- --check
+1 -1
View File
@@ -11,7 +11,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
RUST_TOOLCHAIN: "1.84.0"
jobs:
tests:
+35 -34
View File
@@ -1,16 +1,16 @@
#https://github.com/sagiegurari/cargo-make
extend = [
{ path = "scripts/makefile/desktop.toml" },
{ path = "scripts/makefile/mobile.toml" },
{ path = "scripts/makefile/protobuf.toml" },
{ path = "scripts/makefile/tests.toml" },
{ path = "scripts/makefile/docker.toml" },
{ path = "scripts/makefile/env.toml" },
{ path = "scripts/makefile/flutter.toml" },
{ path = "scripts/makefile/tool.toml" },
{ path = "scripts/makefile/tauri.toml" },
{ path = "scripts/makefile/web.toml" },
{ path = "scripts/makefile/desktop.toml" },
{ path = "scripts/makefile/mobile.toml" },
{ path = "scripts/makefile/protobuf.toml" },
{ path = "scripts/makefile/tests.toml" },
{ path = "scripts/makefile/docker.toml" },
{ path = "scripts/makefile/env.toml" },
{ path = "scripts/makefile/flutter.toml" },
{ path = "scripts/makefile/tool.toml" },
{ path = "scripts/makefile/tauri.toml" },
{ path = "scripts/makefile/web.toml" },
]
[config]
@@ -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),
@@ -0,0 +1,15 @@
export 'ai_model_switch_listener.dart';
export 'chat_ai_message_bloc.dart';
export 'chat_bloc.dart';
export 'chat_edit_document_service.dart';
export 'chat_entity.dart';
export 'chat_input_control_cubit.dart';
export 'chat_input_file_bloc.dart';
export 'chat_member_bloc.dart';
export 'chat_message_listener.dart';
export 'chat_message_service.dart';
export 'chat_message_stream.dart';
export 'chat_notification.dart';
export 'chat_select_message_bloc.dart';
export 'chat_select_sources_cubit.dart';
export 'chat_user_message_bloc.dart';
@@ -0,0 +1,78 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// ChatUserCubit is responsible for fetching and storing the user profile
class ChatUserCubit extends Cubit<ChatUserState> {
ChatUserCubit() : super(ChatUserLoadingState()) {
fetchUserProfile();
}
/// Fetches the user profile from the AuthService
Future<void> fetchUserProfile() async {
emit(ChatUserLoadingState());
final userOrFailure = await getIt<AuthService>().getUser();
userOrFailure.fold(
(userProfile) => emit(ChatUserSuccessState(userProfile)),
(error) => emit(ChatUserFailureState(error)),
);
}
bool supportSelectSource() {
if (state is ChatUserSuccessState) {
final userProfile = (state as ChatUserSuccessState).userProfile;
if (userProfile.userAuthType == AuthTypePB.Server) {
return true;
}
}
return false;
}
bool isValueWorkspace() {
if (state is ChatUserSuccessState) {
final userProfile = (state as ChatUserSuccessState).userProfile;
return userProfile.workspaceType == WorkspaceTypePB.LocalW &&
userProfile.userAuthType != AuthTypePB.Local;
}
return false;
}
/// Refreshes the user profile data
Future<void> refresh() async {
await fetchUserProfile();
}
}
/// Base state class for ChatUserCubit
abstract class ChatUserState extends Equatable {
const ChatUserState();
@override
List<Object?> get props => [];
}
/// Loading state when fetching user profile
class ChatUserLoadingState extends ChatUserState {}
/// Success state when user profile is fetched successfully
class ChatUserSuccessState extends ChatUserState {
const ChatUserSuccessState(this.userProfile);
final UserProfilePB userProfile;
@override
List<Object?> get props => [userProfile];
}
/// Failure state when fetching user profile fails
class ChatUserFailureState extends ChatUserState {
const ChatUserFailureState(this.error);
final FlowyError error;
@override
List<Object?> get props => [error];
}
@@ -1,39 +1,15 @@
import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart'
hide ChatAnimatedListReversed;
import 'package:string_validator/string_validator.dart';
import 'package:universal_platform/universal_platform.dart';
import 'package:url_launcher/url_launcher.dart';
import 'application/chat_bloc.dart';
import 'application/chat_entity.dart';
import 'application/chat_member_bloc.dart';
import 'application/chat_select_message_bloc.dart';
import 'application/chat_message_stream.dart';
import 'presentation/animated_chat_list.dart';
import 'presentation/chat_input/mobile_chat_input.dart';
import 'presentation/chat_related_question.dart';
import 'presentation/chat_welcome_page.dart';
import 'presentation/layout_define.dart';
import 'presentation/message/ai_text_message.dart';
import 'presentation/message/error_text_message.dart';
import 'presentation/message/message_util.dart';
import 'presentation/message/user_text_message.dart';
import 'presentation/scroll_to_bottom.dart';
class AIChatPage extends StatelessWidget {
const AIChatPage({
@@ -49,15 +25,6 @@ class AIChatPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
// if (userProfile.authenticator != AuthTypePB.Server) {
// return Center(
// child: FlowyText(
// LocaleKeys.chat_unsupportedCloudPrompt.tr(),
// fontSize: 20,
// ),
// );
// }
return MultiBlocProvider(
providers: [
/// [ChatBloc] is used to handle chat messages including send/receive message
@@ -111,7 +78,7 @@ class AIChatPage extends StatelessWidget {
return KeyEventResult.ignored;
},
child: _ChatContentPage(
child: ChatContentPage(
view: view,
userProfile: userProfile,
),
@@ -122,371 +89,3 @@ class AIChatPage extends StatelessWidget {
);
}
}
class _ChatContentPage extends StatelessWidget {
const _ChatContentPage({
required this.view,
required this.userProfile,
});
final UserProfilePB userProfile;
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return switch (state.loadingState) {
LoadChatMessageStatus.ready => Column(
children: [
ChatMessageSelectorBanner(
view: view,
allMessages: context.read<ChatBloc>().chatController.messages,
),
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: _wrapConstraints(
SelectionArea(
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(scrollbars: false),
child: Chat(
chatController:
context.read<ChatBloc>().chatController,
user: User(id: userProfile.id.toString()),
darkTheme:
ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: _buildTextMessage,
chatMessageBuilder: _buildChatMessage,
scrollToBottomBuilder: _buildScrollToBottom,
chatAnimatedListBuilder: _buildChatAnimatedList,
),
),
),
),
),
),
),
_wrapConstraints(
_Input(view: view),
),
],
),
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
},
);
}
Widget _wrapConstraints(Widget child) {
return Container(
constraints: const BoxConstraints(maxWidth: 784),
margin: UniversalPlatform.isDesktop
? const EdgeInsets.symmetric(horizontal: 60.0)
: null,
child: child,
);
}
Widget _buildTextMessage(
BuildContext context,
TextMessage message,
) {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.error) {
return ChatErrorMessageWidget(
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) {
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
);
}
if (message.author.id == userProfile.id.toString() ||
isOtherUserMessage(message)) {
return ChatUserMessageWidget(
user: message.author,
message: message,
);
}
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: message,
stream: stream is AnswerStream ? stream : null,
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming:
state.promptResponseState != PromptResponseState.ready,
isLastMessage: isLastMessage,
isSelectingMessages: isSelectingMessages,
onSelectedMetadata: (metadata) =>
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
onChangeModel: (model) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
onStopStream: () => context.read<ChatBloc>().add(
const ChatEvent.stopStream(),
),
);
},
);
},
);
}
Widget _buildChatMessage(
BuildContext context,
Message message,
Animation<double> animation,
Widget child,
) {
return ChatMessage(
message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 18.0),
receivedMessageScaleAnimationAlignment: Alignment.center,
child: child,
);
}
Widget _buildScrollToBottom(
BuildContext context,
Animation<double> animation,
VoidCallback onPressed,
) {
return CustomScrollToBottom(
animation: animation,
onPressed: onPressed,
);
}
Widget _buildChatAnimatedList(
BuildContext context,
ScrollController scrollController,
ChatItem itemBuilder,
) {
final bloc = context.read<ChatBloc>();
if (bloc.chatController.messages.isEmpty) {
return ChatWelcomePage(
userProfile: userProfile,
onSelectedQuestion: (question) {
final aiPromptInputBloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats =
aiPromptInputBloc.state.showPredefinedFormats;
final predefinedFormat = aiPromptInputBloc.state.predefinedFormat;
bloc.add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
);
}
context
.read<ChatSelectMessageBloc>()
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return ChatAnimatedListReversed(
scrollController: scrollController,
itemBuilder: itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
},
);
}
void _onSelectMetadata(
BuildContext context,
ChatMessageRefSource metadata,
) async {
// When the source of metatdata is appflowy, which means it is a appflowy page
if (metadata.source == "appflowy") {
final sidebarView =
await ViewBackendService.getView(metadata.id).toNullable();
if (context.mounted) {
openPageFromMessage(context, sidebarView);
}
return;
}
if (metadata.source == "web") {
if (isURL(metadata.name)) {
late Uri uri;
try {
uri = Uri.parse(metadata.name);
// `Uri` identifies `localhost` as a scheme
if (!uri.hasScheme || uri.scheme == 'localhost') {
uri = Uri.parse("http://${metadata.name}");
await InternetAddress.lookup(uri.host);
}
await launchUrl(uri);
} catch (err) {
Log.error("failed to open url $err");
}
}
return;
}
}
}
class _Input extends StatefulWidget {
const _Input({
required this.view,
});
final ViewPB view;
@override
State<_Input> createState() => _InputState();
}
class _InputState extends State<_Input> {
final textController = AiPromptInputTextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) {
return NonClippingSizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
);
},
child: isSelectingMessages
? const SizedBox.shrink()
: Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return state.promptResponseState ==
PromptResponseState.ready;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
return UniversalPlatform.isDesktop
? DesktopPromptInput(
isStreaming: !canSendMessage,
textController: textController,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier:
chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
)
: MobileChatInput(
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier:
chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
},
),
),
);
},
);
}
}
@@ -11,8 +11,8 @@ import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
import 'package:provider/provider.dart';
class ChatAnimatedListReversed extends StatefulWidget {
const ChatAnimatedListReversed({
class ChatAnimatedList extends StatefulWidget {
const ChatAnimatedList({
super.key,
required this.scrollController,
required this.itemBuilder,
@@ -22,6 +22,7 @@ class ChatAnimatedListReversed extends StatefulWidget {
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
this.bottomPadding = 8,
this.onLoadPreviousMessages,
this.scrollBottomPadding = 440,
});
final ScrollController scrollController;
@@ -32,16 +33,19 @@ class ChatAnimatedListReversed extends StatefulWidget {
final Duration scrollToBottomAppearanceDelay;
final double? bottomPadding;
final VoidCallback? onLoadPreviousMessages;
final double scrollBottomPadding;
@override
ChatAnimatedListReversedState createState() =>
ChatAnimatedListReversedState();
ChatAnimatedListState createState() => ChatAnimatedListState();
}
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
class ChatAnimatedListState extends State<ChatAnimatedList>
with SingleTickerProviderStateMixin {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
late ChatController _chatController;
late final ChatController _chatController = Provider.of<ChatController>(
context,
listen: false,
);
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
@@ -51,12 +55,12 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
bool _userHasScrolled = false;
bool _isScrollingToBottom = false;
String _lastInsertedMessageId = '';
// String _lastInsertedMessageId = '';
@override
void initState() {
super.initState();
_chatController = Provider.of<ChatController>(context, listen: false);
// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
@@ -109,12 +113,14 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
);
widget.scrollController.addListener(_handleLoadPreviousMessages);
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleLoadPreviousMessages();
});
@@ -132,6 +138,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
final height = MediaQuery.of(context).size.height;
return NotificationListener<Notification>(
onNotification: (notification) {
@@ -142,7 +149,7 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.minScrollExtent) {
notification.metrics.maxScrollExtent) {
_userHasScrolled = false;
}
}
@@ -158,8 +165,8 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
child: Stack(
children: [
CustomScrollView(
reverse: true,
controller: widget.scrollController,
physics: ClampingScrollPhysics(),
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.only(
@@ -174,15 +181,22 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
int index,
Animation<double> animation,
) {
final message = _chatController.messages[
max(_chatController.messages.length - 1 - index, 0)];
return widget.itemBuilder(
final message = _chatController.messages[index];
final child = widget.itemBuilder(
context,
animation,
message,
);
return RepaintBoundary(
child: child,
);
},
),
SliverPadding(
padding: EdgeInsets.only(
bottom: max(0, height - widget.scrollBottomPadding),
),
),
],
),
builders.scrollToBottomBuilder?.call(
@@ -199,53 +213,62 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
);
}
void _subsequentScrollToEnd(Message data) async {
final user = Provider.of<User>(context, listen: false);
// void _subsequentScrollToEnd(Message data) async {
// final user = Provider.of<User>(context, listen: false);
// We only want to scroll to the bottom if user has not scrolled up
// or if the message is sent by the current user.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
// // We only want to scroll to the bottom if user has not scrolled up
// // or if the message is sent by the current user.
// if (data.id == _lastInsertedMessageId &&
// widget.scrollController.offset <
// widget.scrollController.position.maxScrollExtent &&
// (user.id != data.author.id && !_userHasScrolled)) {
// if (widget.scrollToEndAnimationDuration == Duration.zero) {
// widget.scrollController
// .jumpTo(widget.scrollController.position.maxScrollExtent);
// } else {
// await widget.scrollController.animateTo(
// widget.scrollController.position.maxScrollExtent,
// duration: widget.scrollToEndAnimationDuration,
// curve: Curves.linearToEaseOut,
// );
// }
if (!widget.scrollController.hasClients || !mounted) return;
// if (!widget.scrollController.hasClients || !mounted) return;
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
// we need an additional jump to the end. Sometimes Flutter
// will not scroll to the very end. Sometimes it will not scroll to the
// very end even with this, so this is something that needs to be
// addressed by the Flutter team.
//
// Additionally here we have a check for the message id, because
// if new message arrives in the meantime it will trigger another
// scroll to the end animation, making this logic redundant.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
}
}
}
// // Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
// // we need an additional jump to the end. Sometimes Flutter
// // will not scroll to the very end. Sometimes it will not scroll to the
// // very end even with this, so this is something that needs to be
// // addressed by the Flutter team.
// //
// // Additionally here we have a check for the message id, because
// // if new message arrives in the meantime it will trigger another
// // scroll to the end animation, making this logic redundant.
// if (data.id == _lastInsertedMessageId &&
// widget.scrollController.offset <
// widget.scrollController.position.maxScrollExtent &&
// (user.id == data.author.id && _userHasScrolled)) {
// widget.scrollController
// .jumpTo(widget.scrollController.position.maxScrollExtent);
// }
// }
// }
void _scrollToEnd(Message data) {
void _scrollToTop(Message data) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
_subsequentScrollToEnd(data);
final user = Provider.of<User>(context, listen: false);
final lastMessage = _chatController.messages.last;
if (lastMessage.author.id == user.id) {
// scroll the current message to the top
widget.scrollController.animateTo(
widget.scrollController.position.maxScrollExtent,
duration: Durations.medium4,
curve: Curves.linearToEaseOut,
);
}
},
);
}
@@ -257,12 +280,16 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!widget.scrollController.hasClients || !mounted) return;
final height = MediaQuery.of(context).size.height;
final paddingHeight = height - widget.scrollBottomPadding;
final offset =
widget.scrollController.position.maxScrollExtent - paddingHeight;
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
widget.scrollController.jumpTo(offset);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
offset,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
@@ -270,13 +297,6 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollController.offset <
widget.scrollController.position.minScrollExtent) {
widget.scrollController.jumpTo(
widget.scrollController.position.minScrollExtent,
);
}
_isScrollingToBottom = false;
});
}
@@ -286,9 +306,11 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
return;
}
final paddingHeight =
MediaQuery.of(context).size.height - widget.scrollBottomPadding;
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
if (widget.scrollController.offset <
widget.scrollController.position.maxScrollExtent - paddingHeight) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
@@ -312,8 +334,8 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
// animation is triggered.
if (position == 0 &&
_userHasScrolled &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
widget.scrollController.offset <
widget.scrollController.position.maxScrollExtent) {
_userHasScrolled = false;
}
@@ -323,10 +345,11 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
);
// Used later to trigger scroll to end only for the last inserted message.
_lastInsertedMessageId = data.id;
// _lastInsertedMessageId = data.id;
if (position == _oldList.length) {
_scrollToEnd(data);
_scrollToTop(data);
// _scrollToEnd(data);
}
}
@@ -0,0 +1,375 @@
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:math';
import 'package:diffutil_dart/diffutil.dart' as diffutil;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
import 'package:provider/provider.dart';
class ChatAnimatedListReversed extends StatefulWidget {
const ChatAnimatedListReversed({
super.key,
required this.scrollController,
required this.itemBuilder,
this.insertAnimationDuration = const Duration(milliseconds: 250),
this.removeAnimationDuration = const Duration(milliseconds: 250),
this.scrollToEndAnimationDuration = const Duration(milliseconds: 250),
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
this.bottomPadding = 8,
this.onLoadPreviousMessages,
});
final ScrollController scrollController;
final ChatItem itemBuilder;
final Duration insertAnimationDuration;
final Duration removeAnimationDuration;
final Duration scrollToEndAnimationDuration;
final Duration scrollToBottomAppearanceDelay;
final double? bottomPadding;
final VoidCallback? onLoadPreviousMessages;
@override
ChatAnimatedListReversedState createState() =>
ChatAnimatedListReversedState();
}
class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed>
with SingleTickerProviderStateMixin {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
late final ChatController _chatController = Provider.of<ChatController>(
context,
listen: false,
);
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
late final AnimationController _scrollToBottomController;
late final Animation<double> _scrollToBottomAnimation;
Timer? _scrollToBottomShowTimer;
bool _userHasScrolled = false;
bool _isScrollingToBottom = false;
String _lastInsertedMessageId = '';
@override
void initState() {
super.initState();
// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
switch (event.type) {
case ChatOperationType.insert:
assert(
event.index != null,
'Index must be provided when inserting a message.',
);
assert(
event.message != null,
'Message must be provided when inserting a message.',
);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
case ChatOperationType.remove:
assert(
event.index != null,
'Index must be provided when removing a message.',
);
assert(
event.message != null,
'Message must be provided when removing a message.',
);
_onRemoved(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
break;
case ChatOperationType.set:
final newList = _chatController.messages;
final updates = diffutil
.calculateDiff<Message>(
MessageListDiff(_oldList, newList),
)
.getUpdatesWithData();
for (var i = updates.length - 1; i >= 0; i--) {
_onDiffUpdate(updates.elementAt(i));
}
_oldList = List.from(newList);
break;
default:
break;
}
});
_scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
);
widget.scrollController.addListener(_handleLoadPreviousMessages);
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleLoadPreviousMessages();
});
}
@override
void dispose() {
super.dispose();
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.dispose();
_operationsSubscription.cancel();
widget.scrollController.removeListener(_handleLoadPreviousMessages);
}
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
return NotificationListener<Notification>(
onNotification: (notification) {
if (notification is UserScrollNotification) {
// When user scrolls up, save it to `_userHasScrolled`
if (notification.direction == ScrollDirection.reverse) {
_userHasScrolled = true;
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.minScrollExtent) {
_userHasScrolled = false;
}
}
}
if (notification is ScrollUpdateNotification) {
_handleToggleScrollToBottom();
}
// Allow other listeners to get the notification
return false;
},
child: Stack(
children: [
CustomScrollView(
reverse: true,
controller: widget.scrollController,
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.only(
top: widget.bottomPadding ?? 0,
),
),
SliverAnimatedList(
key: _listKey,
initialItemCount: _chatController.messages.length,
itemBuilder: (
BuildContext context,
int index,
Animation<double> animation,
) {
final message = _chatController.messages[
max(_chatController.messages.length - 1 - index, 0)];
return widget.itemBuilder(
context,
animation,
message,
);
},
),
],
),
builders.scrollToBottomBuilder?.call(
context,
_scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: _scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
),
],
),
);
}
void _subsequentScrollToEnd(Message data) async {
final user = Provider.of<User>(context, listen: false);
// We only want to scroll to the bottom if user has not scrolled up
// or if the message is sent by the current user.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
if (!widget.scrollController.hasClients || !mounted) return;
// Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
// we need an additional jump to the end. Sometimes Flutter
// will not scroll to the very end. Sometimes it will not scroll to the
// very end even with this, so this is something that needs to be
// addressed by the Flutter team.
//
// Additionally here we have a check for the message id, because
// if new message arrives in the meantime it will trigger another
// scroll to the end animation, making this logic redundant.
if (data.id == _lastInsertedMessageId &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent &&
(user.id == data.author.id && _userHasScrolled)) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
}
}
}
void _scrollToEnd(Message data) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
_subsequentScrollToEnd(data);
},
);
}
void _handleScrollToBottom() {
_isScrollingToBottom = true;
_scrollToBottomController.reverse();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController
.jumpTo(widget.scrollController.position.minScrollExtent);
} else {
await widget.scrollController.animateTo(
widget.scrollController.position.minScrollExtent,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
if (!widget.scrollController.hasClients || !mounted) return;
if (widget.scrollController.offset <
widget.scrollController.position.minScrollExtent) {
widget.scrollController.jumpTo(
widget.scrollController.position.minScrollExtent,
);
}
_isScrollingToBottom = false;
});
}
void _handleToggleScrollToBottom() {
if (_isScrollingToBottom) {
return;
}
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
}
});
} else {
if (_scrollToBottomController.status != AnimationStatus.completed) {
_scrollToBottomController.stop();
}
_scrollToBottomController.reverse();
}
}
void _onInserted(final int position, final Message data) {
// There is a scroll notification listener the controls the
// `_userHasScrolled` variable.
//
// If for some reason `_userHasScrolled` is true and the user is not at the
// bottom of the list, set `_userHasScrolled` to false so that the scroll
// animation is triggered.
if (position == 0 &&
_userHasScrolled &&
widget.scrollController.offset >
widget.scrollController.position.minScrollExtent) {
_userHasScrolled = false;
}
_listKey.currentState!.insertItem(
0,
duration: widget.insertAnimationDuration,
);
// Used later to trigger scroll to end only for the last inserted message.
_lastInsertedMessageId = data.id;
if (position == _oldList.length) {
_scrollToEnd(data);
}
}
void _onRemoved(final int position, final Message data) {
final visualPosition = max(_oldList.length - position - 1, 0);
_listKey.currentState!.removeItem(
visualPosition,
(context, animation) => widget.itemBuilder(
context,
animation,
data,
isRemoved: true,
),
duration: widget.removeAnimationDuration,
);
}
void _onChanged(int position, Message oldData, Message newData) {
_onRemoved(position, oldData);
_listKey.currentState!.insertItem(
max(_oldList.length - position - 1, 0),
duration: widget.insertAnimationDuration,
);
}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
update.when<void>(
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
remove: (pos, data) => _onRemoved(pos, data),
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
move: (_, __, ___) => throw UnimplementedError('unused'),
);
}
void _handleLoadPreviousMessages() {
if (widget.scrollController.offset >=
widget.scrollController.position.maxScrollExtent) {
widget.onLoadPreviousMessages?.call();
}
}
}
@@ -0,0 +1,89 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/animated_chat_list.dart';
import 'package:appflowy/plugins/ai_chat/presentation/animated_chat_list_reversed.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_welcome_page.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
class ChatAnimationListWidget extends StatefulWidget {
const ChatAnimationListWidget({
super.key,
required this.userProfile,
required this.scrollController,
required this.itemBuilder,
this.enableReversedList = false,
});
final UserProfilePB userProfile;
final ScrollController scrollController;
final ChatItem itemBuilder;
final bool enableReversedList;
@override
State<ChatAnimationListWidget> createState() =>
_ChatAnimationListWidgetState();
}
class _ChatAnimationListWidgetState extends State<ChatAnimationListWidget> {
@override
Widget build(BuildContext context) {
final bloc = context.read<ChatBloc>();
// this logic is quite weird, why don't we just get the message from the state?
if (bloc.chatController.messages.isEmpty) {
return ChatWelcomePage(
userProfile: widget.userProfile,
onSelectedQuestion: (question) {
final aiPromptInputBloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats =
aiPromptInputBloc.state.showPredefinedFormats;
final predefinedFormat = aiPromptInputBloc.state.predefinedFormat;
bloc.add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
);
}
// don't call this in the build method
context
.read<ChatSelectMessageBloc>()
.add(ChatSelectMessageEvent.enableStartSelectingMessages());
// final bool reversed = false;
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return widget.enableReversedList
? ChatAnimatedListReversed(
scrollController: widget.scrollController,
itemBuilder: widget.itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
)
: ChatAnimatedList(
scrollController: widget.scrollController,
itemBuilder: widget.itemBuilder,
bottomPadding: isSelectingMessages
? 48.0 + DesktopAIChatSizes.messageActionBarIconSize
: 8.0,
onLoadPreviousMessages: () {
bloc.add(const ChatEvent.loadPreviousMessages());
},
);
},
);
}
}
@@ -0,0 +1,33 @@
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/load_chat_message_status_ready.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ChatContentPage extends StatelessWidget {
const ChatContentPage({
super.key,
required this.view,
required this.userProfile,
});
final UserProfilePB userProfile;
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
return switch (state.loadingState) {
LoadChatMessageStatus.ready => LoadChatMessageStatusReady(
view: view,
userProfile: userProfile,
chatController: context.read<ChatBloc>().chatController,
),
_ => const Center(child: CircularProgressIndicator.adaptive()),
};
},
);
}
}
@@ -0,0 +1,137 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input/mobile_chat_input.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
class ChatFooter extends StatefulWidget {
const ChatFooter({
super.key,
required this.view,
});
final ViewPB view;
@override
State<ChatFooter> createState() => _ChatFooterState();
}
class _ChatFooterState extends State<ChatFooter> {
final textController = AiPromptInputTextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
transitionBuilder: (child, animation) {
return NonClippingSizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: child,
);
},
child: isSelectingMessages
? const SizedBox.shrink()
: Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return state.promptResponseState ==
PromptResponseState.ready;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
return UniversalPlatform.isDesktop
? _buildDesktopInput(
context,
chatBloc,
canSendMessage,
)
: _buildMobileInput(
context,
chatBloc,
canSendMessage,
);
},
),
),
);
},
);
}
Widget _buildDesktopInput(
BuildContext context,
ChatBloc chatBloc,
bool canSendMessage,
) {
return DesktopPromptInput(
isStreaming: !canSendMessage,
textController: textController,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier: chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
}
Widget _buildMobileInput(
BuildContext context,
ChatBloc chatBloc,
bool canSendMessage,
) {
return MobileChatInput(
isStreaming: !canSendMessage,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
),
);
},
selectedSourcesNotifier: chatBloc.selectedSourcesNotifier,
onUpdateSelectedSources: (ids) {
chatBloc.add(
ChatEvent.updateSelectedSources(
selectedSourcesIds: ids,
),
);
},
);
}
}
@@ -0,0 +1,117 @@
import 'package:appflowy/plugins/ai_chat/presentation/chat_message_selector_banner.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_footer.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/text_message_widget.dart';
import 'package:appflowy/plugins/ai_chat/presentation/scroll_to_bottom.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/flutter_chat_ui.dart';
import 'package:universal_platform/universal_platform.dart';
class LoadChatMessageStatusReady extends StatelessWidget {
const LoadChatMessageStatusReady({
super.key,
required this.view,
required this.userProfile,
required this.chatController,
});
final ViewPB view;
final UserProfilePB userProfile;
final ChatController chatController;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Chat header, banner
_buildHeader(context),
// Chat body, a list of messages
_buildBody(context),
// Chat footer, a text input field with toolbar, send button, etc.
_buildFooter(context),
],
);
}
Widget _buildHeader(BuildContext context) {
return ChatMessageSelectorBanner(
view: view,
allMessages: chatController.messages,
);
}
Widget _buildBody(BuildContext context) {
return Expanded(
child: Align(
alignment: Alignment.topCenter,
child: _wrapConstraints(
SelectionArea(
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
scrollbars: false,
),
child: Chat(
chatController: chatController,
user: User(id: userProfile.id.toString()),
darkTheme: ChatTheme.fromThemeData(Theme.of(context)),
theme: ChatTheme.fromThemeData(Theme.of(context)),
builders: Builders(
// we have a custom input builder, so we don't need the default one
inputBuilder: (_) => const SizedBox.shrink(),
textMessageBuilder: (context, message) => TextMessageWidget(
message: message,
userProfile: userProfile,
view: view,
),
chatMessageBuilder: (context, message, animation, child) =>
ChatMessage(
message: message,
animation: animation,
padding: const EdgeInsets.symmetric(vertical: 18.0),
receivedMessageScaleAnimationAlignment: Alignment.center,
child: child,
),
scrollToBottomBuilder: (context, animation, onPressed) =>
CustomScrollToBottom(
animation: animation,
onPressed: onPressed,
),
chatAnimatedListBuilder: (
context,
scrollController,
itemBuilder,
) =>
ChatAnimationListWidget(
userProfile: userProfile,
scrollController: scrollController,
itemBuilder: itemBuilder,
enableReversedList: true,
),
),
),
),
),
),
),
);
}
Widget _buildFooter(BuildContext context) {
return _wrapConstraints(
ChatFooter(view: view),
);
}
Widget _wrapConstraints(Widget child) {
return Container(
constraints: const BoxConstraints(maxWidth: 784),
margin: UniversalPlatform.isDesktop
? const EdgeInsets.symmetric(horizontal: 60.0)
: null,
child: child,
);
}
}
@@ -0,0 +1,154 @@
import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_text_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/error_text_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/message_util.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/user_text_message.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:string_validator/string_validator.dart';
import 'package:url_launcher/url_launcher.dart';
class TextMessageWidget extends StatelessWidget {
const TextMessageWidget({
super.key,
required this.message,
required this.userProfile,
required this.view,
this.enableAnimation = true,
});
final TextMessage message;
final UserProfilePB userProfile;
final ViewPB view;
final bool enableAnimation;
@override
Widget build(BuildContext context) {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.error) {
return ChatErrorMessageWidget(
errorMessage: message.metadata?[errorMessageTextKey] ?? "",
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) {
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
);
}
if (message.author.id == userProfile.id.toString() ||
isOtherUserMessage(message)) {
return ChatUserMessageWidget(
user: message.author,
message: message,
);
}
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?[messageQuestionIdKey];
final refSourceJsonString =
message.metadata?[messageRefSourceJsonStringKey] as String?;
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
selector: (state) => state.isSelectingMessages,
builder: (context, isSelectingMessages) {
return BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
final chatController = context.read<ChatBloc>().chatController;
final messages = chatController.messages
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
message: message,
stream: stream is AnswerStream ? stream : null,
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming:
state.promptResponseState != PromptResponseState.ready,
isLastMessage: isLastMessage,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
onSelectedMetadata: (metadata) =>
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
onChangeModel: (model) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
onStopStream: () => context.read<ChatBloc>().add(
const ChatEvent.stopStream(),
),
);
},
);
},
);
}
void _onSelectMetadata(
BuildContext context,
ChatMessageRefSource metadata,
) async {
// When the source of metatdata is appflowy, which means it is a appflowy page
if (metadata.source == "appflowy") {
final sidebarView =
await ViewBackendService.getView(metadata.id).toNullable();
if (context.mounted) {
openPageFromMessage(context, sidebarView);
}
return;
}
if (metadata.source == "web") {
if (isURL(metadata.name)) {
late Uri uri;
try {
uri = Uri.parse(metadata.name);
// `Uri` identifies `localhost` as a scheme
if (!uri.hasScheme || uri.scheme == 'localhost') {
uri = Uri.parse("http://${metadata.name}");
await InternetAddress.lookup(uri.host);
}
await launchUrl(uri);
} catch (err) {
Log.error("failed to open url $err");
}
}
return;
}
}
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
@@ -17,16 +19,21 @@ class AIMarkdownText extends StatelessWidget {
const AIMarkdownText({
super.key,
required this.markdown,
this.withAnimation = false,
});
final String markdown;
final bool withAnimation;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => DocumentPageStyleBloc(view: ViewPB())
..add(const DocumentPageStyleEvent.initial()),
child: _AppFlowyEditorMarkdown(markdown: markdown),
child: _AppFlowyEditorMarkdown(
markdown: markdown,
withAnimation: withAnimation,
),
);
}
}
@@ -34,19 +41,28 @@ class AIMarkdownText extends StatelessWidget {
class _AppFlowyEditorMarkdown extends StatefulWidget {
const _AppFlowyEditorMarkdown({
required this.markdown,
this.withAnimation = false,
});
// the text should be the markdown format
final String markdown;
/// Whether to animate the text.
final bool withAnimation;
@override
State<_AppFlowyEditorMarkdown> createState() =>
_AppFlowyEditorMarkdownState();
}
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown>
with TickerProviderStateMixin {
late EditorState editorState;
late EditorScrollController scrollController;
late Timer markdownOutputTimer;
int offset = 0;
final Map<String, (AnimationController, Animation<double>)> _animations = {};
@override
void initState() {
@@ -57,13 +73,46 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
editorState: editorState,
shrinkWrap: true,
);
if (widget.withAnimation) {
markdownOutputTimer =
Timer.periodic(const Duration(milliseconds: 60), (timer) {
if (offset >= widget.markdown.length || !widget.withAnimation) {
return;
}
final markdown = widget.markdown.substring(0, offset);
offset += 30;
final editorState = _parseMarkdown(
markdown,
previousDocument: this.editorState.document,
);
final lastCurrentNode = editorState.document.last;
final lastPreviousNode = this.editorState.document.last;
if (lastCurrentNode?.id != lastPreviousNode?.id ||
lastCurrentNode?.type != lastPreviousNode?.type ||
lastCurrentNode?.delta?.toPlainText() !=
lastPreviousNode?.delta?.toPlainText()) {
setState(() {
this.editorState.dispose();
this.editorState = editorState;
scrollController.dispose();
scrollController = EditorScrollController(
editorState: editorState,
shrinkWrap: true,
);
});
}
});
}
}
@override
void didUpdateWidget(covariant _AppFlowyEditorMarkdown oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.markdown != widget.markdown) {
if (oldWidget.markdown != widget.markdown && !widget.withAnimation) {
final editorState = _parseMarkdown(
widget.markdown.trim(),
previousDocument: this.editorState.document,
@@ -83,6 +132,13 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
scrollController.dispose();
editorState.dispose();
if (widget.withAnimation) {
markdownOutputTimer.cancel();
for (final controller in _animations.values.map((e) => e.$1)) {
controller.dispose();
}
}
super.dispose();
}
@@ -120,6 +176,33 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
commandShortcutEvents: [customCopyCommand],
disableAutoScroll: true,
editorState: editorState,
blockWrapper: (
context, {
required Node node,
required Widget child,
}) {
if (!widget.withAnimation) {
return child;
}
if (!_animations.containsKey(node.id)) {
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
);
final fade = Tween<double>(
begin: 0,
end: 1,
).animate(controller);
_animations[node.id] = (controller, fade);
controller.forward();
}
final (controller, fade) = _animations[node.id]!;
return _AnimatedWrapper(
fade: fade,
child: child,
);
},
contextMenuItems: [
[
ContextMenuItem(
@@ -161,3 +244,39 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown> {
return editorState;
}
}
class _AnimatedWrapper extends StatelessWidget {
const _AnimatedWrapper({
required this.fade,
required this.child,
});
final Animation<double> fade;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: fade,
builder: (context, childWidget) {
return ShaderMask(
shaderCallback: (Rect bounds) {
return LinearGradient(
stops: [fade.value, fade.value],
colors: const [
Colors.white,
Colors.transparent,
],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: Opacity(
opacity: fade.value,
child: childWidget,
),
);
},
child: child,
);
}
}
@@ -23,9 +23,9 @@ import 'package:go_router/go_router.dart';
import 'package:universal_platform/universal_platform.dart';
import '../layout_define.dart';
import 'ai_change_format_bottom_sheet.dart';
import 'ai_change_model_bottom_sheet.dart';
import 'ai_message_action_bar.dart';
import 'ai_change_format_bottom_sheet.dart';
import 'message_util.dart';
/// Wraps an AI response message with the avatar and actions. On desktop,
@@ -132,7 +132,7 @@ class ChatAIBottomInlineActions extends StatelessWidget {
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
),
const VSpace(32.0),
const VSpace(16.0),
],
);
}
@@ -41,6 +41,7 @@ class ChatAIMessageWidget extends StatelessWidget {
this.isLastMessage = false,
this.isStreaming = false,
this.isSelectingMessages = false,
this.enableAnimation = true,
});
final User user;
@@ -59,6 +60,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final bool isStreaming;
final bool isLastMessage;
final bool isSelectingMessages;
final bool enableAnimation;
@override
Widget build(BuildContext context) {
@@ -96,41 +98,27 @@ class ChatAIMessageWidget extends StatelessWidget {
),
ready: () {
return state.text.isEmpty
? ChatAIMessageBubble(
? _LoadingMessage(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
loadingText: loadingText,
)
: ChatAIMessageBubble(
: _NonEmptyMessage(
user: user,
messageUserId: messageUserId,
message: message,
isLastMessage: isLastMessage,
showActions: stream == null &&
state.text.isNotEmpty &&
!isStreaming,
isSelectingMessages: isSelectingMessages,
stream: stream,
questionId: questionId,
chatId: chatId,
refSourceJsonString: refSourceJsonString,
onStopStream: onStopStream,
onSelectedMetadata: onSelectedMetadata,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AIMarkdownText(
markdown: state.text,
),
if (state.sources.isNotEmpty)
SelectionContainer.disabled(
child: AIMessageMetadata(
sources: state.sources,
onSelectedMetadata: onSelectedMetadata,
),
),
if (state.sources.isNotEmpty && !isLastMessage)
const VSpace(8.0),
],
),
isLastMessage: isLastMessage,
isStreaming: isStreaming,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
);
},
onError: (error) {
@@ -171,3 +159,96 @@ class ChatAIMessageWidget extends StatelessWidget {
);
}
}
class _LoadingMessage extends StatelessWidget {
const _LoadingMessage({
required this.message,
required this.loadingText,
});
final Message message;
final String loadingText;
@override
Widget build(BuildContext context) {
return ChatAIMessageBubble(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
);
}
}
class _NonEmptyMessage extends StatelessWidget {
const _NonEmptyMessage({
required this.user,
required this.messageUserId,
required this.message,
required this.stream,
required this.questionId,
required this.chatId,
required this.refSourceJsonString,
required this.onStopStream,
this.onSelectedMetadata,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
this.isLastMessage = false,
this.isStreaming = false,
this.isSelectingMessages = false,
this.enableAnimation = true,
});
final User user;
final String messageUserId;
final Message message;
final AnswerStream? stream;
final Int64? questionId;
final String chatId;
final String? refSourceJsonString;
final ValueChanged<ChatMessageRefSource>? onSelectedMetadata;
final VoidCallback? onRegenerate;
final VoidCallback onStopStream;
final ValueChanged<PredefinedFormat>? onChangeFormat;
final ValueChanged<AIModelPB>? onChangeModel;
final bool isStreaming;
final bool isLastMessage;
final bool isSelectingMessages;
final bool enableAnimation;
@override
Widget build(BuildContext context) {
final state = context.read<ChatAIMessageBloc>().state;
final showActions = stream == null && state.text.isNotEmpty && !isStreaming;
return ChatAIMessageBubble(
message: message,
isLastMessage: isLastMessage,
showActions: showActions,
isSelectingMessages: isSelectingMessages,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AIMarkdownText(
markdown: state.text,
withAnimation: enableAnimation && stream != null,
),
if (state.sources.isNotEmpty)
SelectionContainer.disabled(
child: AIMessageMetadata(
sources: state.sources,
onSelectedMetadata: onSelectedMetadata,
),
),
if (state.sources.isNotEmpty && !isLastMessage) const VSpace(8.0),
],
),
);
}
}
@@ -15,6 +15,7 @@ class CustomPageBlockComponentBuilder extends BlockComponentBuilder {
node: blockComponentContext.node,
header: blockComponentContext.header,
footer: blockComponentContext.footer,
wrapper: blockComponentContext.wrapper,
);
}
}
@@ -29,10 +30,12 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
super.configuration = const BlockComponentConfiguration(),
this.header,
this.footer,
this.wrapper,
});
final Widget? header;
final Widget? footer;
final BlockComponentWrapper? wrapper;
@override
Widget build(BuildContext context) {
@@ -52,14 +55,20 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
children: [
if (header != null) header!,
...items.map(
(e) => Container(
constraints: BoxConstraints(
maxWidth:
editorState.editorStyle.maxWidth ?? double.infinity,
),
padding: editorState.editorStyle.padding,
child: editorState.renderer.build(context, e),
),
(e) {
Widget child = editorState.renderer.build(context, e);
if (wrapper != null) {
child = wrapper!(context, node: e, child: child);
}
return Container(
constraints: BoxConstraints(
maxWidth:
editorState.editorStyle.maxWidth ?? double.infinity,
),
padding: editorState.editorStyle.padding,
child: child,
);
},
),
if (footer != null) footer!,
],
@@ -92,6 +101,11 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
final childNode = items[index - (header != null ? 1 : 0)];
final isOverflowType = overflowTypes.contains(childNode.type);
Widget child = editorState.renderer.build(context, childNode);
if (wrapper != null) {
child = wrapper!(context, node: childNode, child: child);
}
final item = Container(
constraints: BoxConstraints(
maxWidth: editorState.editorStyle.maxWidth ?? double.infinity,
@@ -99,10 +113,7 @@ class CustomPageBlockComponent extends BlockComponentStatelessWidget {
padding: isOverflowType
? EdgeInsets.zero
: editorState.editorStyle.padding,
child: editorState.renderer.build(
context,
childNode,
),
child: child,
);
return isOverflowType ? item : Center(child: item);
@@ -49,7 +49,6 @@ class CommandPaletteBloc
final TrashService _trashService = TrashService();
final TrashListener _trashListener = TrashListener();
String? _activeQuery;
String? _workspaceId;
@override
Future<void> close() {
@@ -115,7 +114,6 @@ class CommandPaletteBloc
unawaited(
SearchBackendService.performSearch(
event.search,
workspaceId: _workspaceId,
).then(
(result) => result.fold(
(stream) {
@@ -161,6 +159,7 @@ class CommandPaletteBloc
onServerItems: (items, searchId, searching, generatingAIOverview) =>
_handleResultsUpdate(
searchId: searchId,
summaries: [], // when got server search result, summaries should be empty
serverItems: items,
searching: searching,
generatingAIOverview: generatingAIOverview,
@@ -263,7 +262,6 @@ class CommandPaletteBloc
_WorkspaceChanged event,
Emitter<CommandPaletteState> emit,
) {
_workspaceId = event.workspaceId;
emit(
state.copyWith(
query: '',
@@ -8,28 +8,23 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/query.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/search_filter.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:nanoid/nanoid.dart';
import 'package:fixnum/fixnum.dart';
class SearchBackendService {
static Future<FlowyResult<SearchResponseStream, FlowyError>> performSearch(
String keyword, {
String? workspaceId,
}) async {
final searchId = nanoid(6);
String keyword,
) async {
final searchId = DateTime.now().millisecondsSinceEpoch.toString();
final stream = SearchResponseStream(searchId: searchId);
final filter = SearchFilterPB(workspaceId: workspaceId);
final request = SearchQueryPB(
search: keyword,
filter: filter,
searchId: searchId,
streamPort: Int64(stream.nativePort),
);
unawaited(SearchEventSearch(request).send());
unawaited(SearchEventStreamSearch(request).send());
return FlowyResult.success(stream);
}
}
@@ -39,8 +39,7 @@ class LocalAiPluginBloc extends Bloc<LocalAiPluginEvent, LocalAiPluginState> {
emit(
LocalAiPluginState.ready(
isEnabled: aiState.enabled,
version: aiState.pluginVersion,
runningState: aiState.state,
isReady: aiState.isReady,
lackOfResource:
aiState.hasLackOfResource() ? aiState.lackOfResource : null,
),
@@ -116,8 +115,7 @@ class LocalAiPluginState with _$LocalAiPluginState {
const factory LocalAiPluginState.ready({
required bool isEnabled,
required String version,
required RunningStatePB runningState,
required bool isReady,
required LackOfAIResourcePB? lackOfResource,
}) = ReadyLocalAiPluginState;
@@ -125,15 +123,15 @@ class LocalAiPluginState with _$LocalAiPluginState {
bool get isEnabled {
return maybeWhen(
ready: (isEnabled, _, __, ___) => isEnabled,
ready: (isEnabled, _, ___) => isEnabled,
orElse: () => false,
);
}
bool get showIndicator {
return maybeWhen(
ready: (isEnabled, _, runningState, lackOfResource) =>
runningState != RunningStatePB.Running || lackOfResource != null,
ready: (isEnabled, isReady, lackOfResource) =>
isReady || lackOfResource != null,
orElse: () => false,
);
}
@@ -150,14 +150,16 @@ class SettingItem extends Equatable {
required this.content,
required this.hintText,
required this.settingType,
this.editable = true,
});
final String content;
final String hintText;
final SettingType settingType;
final bool editable;
@override
List<Object?> get props => [content, settingType];
List<Object?> get props => [content, settingType, editable];
}
/// Items pending submission
@@ -232,6 +234,7 @@ extension on LocalAISettingPB {
content: embeddingModelName,
hintText: kDefaultEmbeddingModel,
settingType: SettingType.embeddingModel,
editable: false, // embedding model is not editable
),
];
@@ -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();
}
@@ -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(
@@ -70,15 +70,26 @@ class _SettingItemWidget extends StatelessWidget {
const VSpace(4),
SizedBox(
height: 32,
child: FlowyTextField(
autoFocus: false,
hintText: item.hintText,
text: item.content,
onChanged: (content) {
context.read<OllamaSettingBloc>().add(
OllamaSettingEvent.onEdit(content, item.settingType),
);
},
child: FlowyTooltip(
message: item.editable
? null
: LocaleKeys.settings_aiPage_keys_readOnlyField.tr(),
child: FlowyTextField(
autoFocus: false,
hintText: item.hintText,
text: item.content,
readOnly: !item.editable,
onChanged: item.editable
? (content) {
context.read<OllamaSettingBloc>().add(
OllamaSettingEvent.onEdit(
content,
item.settingType,
),
);
}
: null,
),
),
),
],
@@ -19,19 +19,14 @@ class LocalAIStatusIndicator extends StatelessWidget {
return BlocBuilder<LocalAiPluginBloc, LocalAiPluginState>(
builder: (context, state) {
return state.maybeWhen(
ready: (_, version, runningState, lackOfResource) {
ready: (_, isReady, lackOfResource) {
if (lackOfResource != null) {
return _LackOfResource(resource: lackOfResource);
}
return switch (runningState) {
RunningStatePB.ReadyToRun => const _ReadyToRun(),
RunningStatePB.Connecting ||
RunningStatePB.Connected =>
_Initializing(),
RunningStatePB.Running => _LocalAIRunning(version: version),
RunningStatePB.Stopped => const _RestartPluginButton(),
_ => const SizedBox.shrink(),
return switch (isReady) {
true => const _LocalAIRunning(),
false => const _RestartPluginButton(),
};
},
orElse: () => const SizedBox.shrink(),
@@ -41,66 +36,6 @@ class LocalAIStatusIndicator extends StatelessWidget {
}
}
class _ReadyToRun extends StatelessWidget {
const _ReadyToRun();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
const SizedBox.square(
dimension: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
strokeAlign: BorderSide.strokeAlignInside,
),
),
const HSpace(8.0),
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIStart.tr(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _Initializing extends StatelessWidget {
const _Initializing();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
const SizedBox.square(
dimension: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
strokeAlign: BorderSide.strokeAlignInside,
),
),
HSpace(8),
Expanded(
child: FlowyText(
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _RestartPluginButton extends StatelessWidget {
const _RestartPluginButton();
@@ -163,16 +98,11 @@ class _RestartPluginButton extends StatelessWidget {
}
class _LocalAIRunning extends StatelessWidget {
const _LocalAIRunning({
required this.version,
});
final String version;
const _LocalAIRunning();
@override
Widget build(BuildContext context) {
final runningText = LocaleKeys.settings_aiPage_keys_localAIRunning.tr();
final text = version.isEmpty ? runningText : "$runningText ($version)";
return Container(
decoration: const BoxDecoration(
@@ -189,7 +119,7 @@ class _LocalAIRunning extends StatelessWidget {
const HSpace(6),
Expanded(
child: FlowyText(
text,
runningText,
color: const Color(0xFF1E4620),
maxLines: 3,
),
@@ -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,
);
}
},
+22 -33
View File
@@ -36,11 +36,6 @@ PODS:
- ReachabilitySwift (5.2.4)
- screen_retriever_macos (0.0.1):
- FlutterMacOS
- Sentry/HybridSDK (8.46.0)
- sentry_flutter (8.14.2):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.46.0)
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
@@ -77,7 +72,6 @@ DEPENDENCIES:
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
@@ -90,7 +84,6 @@ SPEC REPOS:
trunk:
- HotKey
- ReachabilitySwift
- Sentry
- Sparkle
EXTERNAL SOURCES:
@@ -126,8 +119,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
screen_retriever_macos:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos
sentry_flutter:
:path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
@@ -144,34 +135,32 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: 9028728e32c83a0831d9db8cf91c526d16cc5468
appflowy_backend: 464aeb3e5c6966a41641a2111e5ead72ce2695f7
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9
connectivity_plus: e74b9f74717d2d99d45751750e266e55912baeb5
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31
flowy_infra_ui: 8760ff42a789de40bf5007a5f176b454722a341e
app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
appflowy_backend: 865496343de667fc8c600e04b9fd05234e130cf9
auto_updater_macos: 3e3462c418fe4e731917eacd8d28eef7af84086d
bitsdojo_window_macos: 44e3b8fe3dd463820e0321f6256c5b1c16bb6a00
connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flowy_infra_ui: 03301a39ad118771adbf051a664265c61c507f38
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
hotkey_manager: b443f35f4d772162937aa73fd8995e579f8ac4e2
irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba
local_notifier: ebf072651e35ae5e47280ad52e2707375cb2ae4e
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
hotkey_manager: c32bf0bfe8f934b7bc17ab4ad5c4c142960b023c
irondash_engine_context: da62996ee25616d2f01bbeb85dc115d813359478
local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
screen_retriever_macos: 776e0fa5d42c6163d2bf772d22478df4b302b161
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
Sparkle: 9c328bdcfbcaf8f030c4b678eadfd0fcb03822d8
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189
url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
window_manager: e8d0b1431ab6c454f2b5c9ae26004bbfa43469aa
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
super_native_extensions: 85efee3a7495b46b04befcfc86ed12069264ebf3
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
window_manager: 990c8e348c4da2a93b81da638245d40554ec9436
PODFILE CHECKSUM: 0532f3f001ca3110b8be345d6491fff690e95823
+2 -2
View File
@@ -98,8 +98,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: e56746a
resolved-ref: e56746ac65681fb002ff169be3de15ae4f5aad60
ref: "470c4e7"
resolved-ref: "470c4e77c71b63f693ce0923a927afcd667d6f3b"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "5.2.0"
+1 -1
View File
@@ -185,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:
+1
View File
@@ -892,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?",
+1176 -178
View File
File diff suppressed because it is too large. Load diff
+18 -14
View File
@@ -29,7 +29,7 @@ members = [
"flowy-search-pub",
"flowy-ai",
"flowy-ai-pub",
"flowy-storage-pub",
"flowy-storage-pub", "flowy-sqlite-vec",
]
resolver = "2"
@@ -82,7 +82,8 @@ uuid = { version = "1.5.0", features = ["serde", "v4", "v5"] }
serde_repr = "0.1"
futures = "0.3.31"
tokio = "1.38.0"
tokio-stream = "0.1.14"
tokio-stream = "0.1.17"
ollama-rs = { version = "0.3.0" }
async-trait = "0.1.81"
chrono = { version = "0.4.31", default-features = false, features = ["clock"] }
collab = { version = "0.2" }
@@ -99,19 +100,20 @@ tokio-util = "0.7.11"
zip = "2.2.0"
dashmap = "6.0.1"
derive_builder = "0.20.2"
tantivy = { version = "0.24.0" }
tantivy = { version = "0.24.1" }
af-plugin = { version = "0.1" }
af-local-ai = { version = "0.1" }
num_enum = "0.7.3"
flowy-sqlite-vec = { path = "flowy-sqlite-vec" }
# Please using the following command to update the revision id
# Current directory: frontend
# Run the script.add_workspace_members:
# scripts/tool/update_client_api_rev.sh new_rev_id
# ⚠️⚠️⚠️️
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6ed0f4e" }
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6ed0f4e" }
workspace-template = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "6ed0f4e" }
client-api = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9e16ff714903823ed68a001e78607da5c7f5c93a" }
client-api-entity = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9e16ff714903823ed68a001e78607da5c7f5c93a" }
workspace-template = { git = "https://github.com/AppFlowy-IO/AppFlowy-Cloud", rev = "9e16ff714903823ed68a001e78607da5c7f5c93a" }
[profile.dev]
opt-level = 0
@@ -146,14 +148,14 @@ rocksdb = { git = "https://github.com/rust-rocksdb/rust-rocksdb", rev = "1710120
# To switch to the local path, run:
# scripts/tool/update_collab_source.sh
# ⚠️⚠️⚠️️
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "f029a79e6112c296286cd7bb4c6dcaa4cf0d33f3" }
collab = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-entity = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-folder = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-document = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-database = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-plugins = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev = "b9bd12e83d3d9acacfacecf4ec12c7bae1df99fe" }
# Working directory: frontend
# To update the commit ID, run:
@@ -162,3 +164,5 @@ collab-importer = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFl
af-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" }
af-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" }
af-mcp = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "093d3f4916a7b924ac66b4f0a9d81cc5fcc72eaa" }
langchain-rust = { version = "4.6.0", git = "https://github.com/appflowy/langchain-rust", branch = "af" }
@@ -53,7 +53,7 @@ impl PartialEq<Symbol> for Ident {
}
}
impl<'a> PartialEq<Symbol> for &'a Ident {
impl PartialEq<Symbol> for &Ident {
fn eq(&self, word: &Symbol) -> bool {
*self == word.0
}
@@ -65,7 +65,7 @@ impl PartialEq<Symbol> for Path {
}
}
impl<'a> PartialEq<Symbol> for &'a Path {
impl PartialEq<Symbol> for &Path {
fn eq(&self, word: &Symbol) -> bool {
self.is_ident(word.0)
}
@@ -22,10 +22,12 @@ tracing.workspace = true
tokio = { workspace = true, features = ["sync"] }
lib-infra = { workspace = true }
arc-swap = "1.7"
flowy-sqlite = { workspace = true }
diesel.workspace = true
flowy-error.workspace = true
uuid.workspace = true
flowy-ai-pub.workspace = true
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
twox-hash = { version = "2.1.0", features = ["xxhash64"] }
[features]
default = []
@@ -33,7 +33,9 @@ use collab_plugins::local_storage::kv::KVTransactionDB;
use collab_plugins::local_storage::CollabPersistenceConfig;
use collab_user::core::{UserAwareness, UserAwarenessNotifier};
use crate::instant_indexed_data_provider::InstantIndexedDataWriter;
use flowy_error::FlowyError;
use lib_infra::util::get_operating_system;
use lib_infra::{if_native, if_wasm};
use tracing::{error, instrument, trace, warn};
use uuid::Uuid;
@@ -79,14 +81,17 @@ pub struct AppFlowyCollabBuilder {
#[cfg(not(target_arch = "wasm32"))]
rocksdb_backup: ArcSwapOption<Arc<dyn RocksdbBackup>>,
workspace_integrate: Arc<dyn WorkspaceCollabIntegrate>,
embeddings_writer: Option<Weak<InstantIndexedDataWriter>>,
}
impl AppFlowyCollabBuilder {
pub fn new(
storage_provider: impl CollabCloudPluginProvider + 'static,
workspace_integrate: impl WorkspaceCollabIntegrate + 'static,
embeddings_writer: Option<Weak<InstantIndexedDataWriter>>,
) -> Self {
Self {
embeddings_writer,
network_reachability: CollabConnectReachability::new(),
plugin_provider: ArcSwap::new(Arc::new(Arc::new(storage_provider))),
snapshot_persistence: Default::default(),
@@ -306,6 +311,19 @@ impl AppFlowyCollabBuilder {
where
T: BorrowMut<Collab> + Send + Sync + 'static,
{
if get_operating_system().is_desktop() {
let cloned_object = object.clone();
let weak_collab = Arc::downgrade(&collab);
let weak_embedding_writer = self.embeddings_writer.clone();
tokio::spawn(async move {
if let Some(embedding_writer) = weak_embedding_writer.and_then(|w| w.upgrade()) {
embedding_writer
.queue_collab_embed(cloned_object, weak_collab)
.await;
}
});
}
let mut write_collab = collab.try_write()?;
let has_cloud_plugin = write_collab.borrow().has_cloud_plugin();
if has_cloud_plugin {
@@ -0,0 +1,335 @@
use collab::core::collab::DataSource;
use collab::core::origin::CollabOrigin;
use collab::entity::EncodedCollab;
use collab::lock::RwLock;
use collab::preclude::{Collab, Transact};
use collab_document::document::DocumentBody;
use collab_entity::{CollabObject, CollabType};
use flowy_ai_pub::entities::{UnindexedCollab, UnindexedCollabMetadata, UnindexedData};
use flowy_error::{FlowyError, FlowyResult};
use lib_infra::async_trait::async_trait;
use lib_infra::util::get_operating_system;
use std::borrow::BorrowMut;
use std::collections::HashMap;
use std::sync::{Arc, Weak};
use std::time::Duration;
use tokio::runtime::Runtime;
use tokio::time::interval;
use tracing::{error, info, trace};
use uuid::Uuid;
pub struct WriteObject {
pub collab_object: CollabObject,
pub collab: Weak<dyn CollabIndexedData>,
}
pub struct InstantIndexedDataWriter {
collab_by_object: Arc<RwLock<HashMap<String, WriteObject>>>,
consumers: Arc<RwLock<Vec<Box<dyn InstantIndexedDataConsumer>>>>,
}
impl Default for InstantIndexedDataWriter {
fn default() -> Self {
Self::new()
}
}
impl InstantIndexedDataWriter {
pub fn new() -> InstantIndexedDataWriter {
let collab_by_object = Arc::new(RwLock::new(HashMap::<String, WriteObject>::new()));
let consumers = Arc::new(RwLock::new(
Vec::<Box<dyn InstantIndexedDataConsumer>>::new(),
));
InstantIndexedDataWriter {
collab_by_object,
consumers,
}
}
pub async fn num_consumers(&self) -> usize {
let consumers = self.consumers.read().await;
consumers.len()
}
pub async fn clear_consumers(&self) {
let mut consumers = self.consumers.write().await;
consumers.clear();
info!("[Indexing] Cleared all instant index consumers");
}
pub async fn register_consumer(&self, consumer: Box<dyn InstantIndexedDataConsumer>) {
info!(
"[Indexing] Registering instant index consumer: {}",
consumer.consumer_id()
);
let mut guard = self.consumers.write().await;
guard.push(consumer);
}
pub async fn spawn_instant_indexed_provider(&self, runtime: &Runtime) -> FlowyResult<()> {
if !get_operating_system().is_desktop() {
return Ok(());
}
let weak_collab_by_object = Arc::downgrade(&self.collab_by_object);
let consumers_weak = Arc::downgrade(&self.consumers);
let interval_dur = Duration::from_secs(30);
runtime.spawn(async move {
let mut ticker = interval(interval_dur);
ticker.tick().await;
loop {
ticker.tick().await;
// Upgrade our state holders
let collab_by_object = match weak_collab_by_object.upgrade() {
Some(m) => m,
None => break, // provider dropped
};
let consumers = match consumers_weak.upgrade() {
Some(c) => c,
None => break,
};
// Snapshot keys and consumers under read locks
let (object_ids, mut to_remove) = {
let guard = collab_by_object.read().await;
let keys: Vec<_> = guard.keys().cloned().collect();
(keys, Vec::new())
};
let guard = collab_by_object.read().await;
for id in object_ids {
// Check if the collab is still alive
match guard.get(&id) {
Some(wo) => {
if let Some(collab_rc) = wo.collab.upgrade() {
if let Some(data) = collab_rc
.get_unindexed_data(&wo.collab_object.collab_type)
.await
{
// Snapshot consumers
let consumers_guard = consumers.read().await;
for consumer in consumers_guard.iter() {
let workspace_id = match Uuid::parse_str(&wo.collab_object.workspace_id) {
Ok(id) => id,
Err(err) => {
error!(
"Invalid workspace_id {}: {}",
wo.collab_object.workspace_id, err
);
continue;
},
};
let object_id = match Uuid::parse_str(&wo.collab_object.object_id) {
Ok(id) => id,
Err(err) => {
error!("Invalid object_id {}: {}", wo.collab_object.object_id, err);
continue;
},
};
match consumer
.consume_collab(
&workspace_id,
data.clone(),
&object_id,
wo.collab_object.collab_type,
)
.await
{
Ok(is_indexed) => {
if is_indexed {
trace!("[Indexing] {} consumed {}", consumer.consumer_id(), id);
}
},
Err(err) => {
error!(
"Consumer {} failed on {}: {}",
consumer.consumer_id(),
id,
err
);
},
}
}
}
} else {
// Mark for removal if collab was dropped
to_remove.push(id);
}
},
None => continue,
}
}
if !to_remove.is_empty() {
let mut guard = collab_by_object.write().await;
guard.retain(|k, _| !to_remove.contains(k));
trace!("[Indexing] Removed {} stale entries", to_remove.len());
}
}
info!("[Indexing] Instant indexed data provider stopped");
});
Ok(())
}
pub fn support_collab_type(&self, t: &CollabType) -> bool {
matches!(t, CollabType::Document)
}
pub async fn index_encoded_collab(
&self,
workspace_id: Uuid,
object_id: Uuid,
data: EncodedCollab,
collab_type: CollabType,
) -> FlowyResult<()> {
match unindexed_collab_from_encoded_collab(workspace_id, object_id, data, collab_type) {
None => Err(FlowyError::internal().with_context("Failed to create unindexed collab")),
Some(data) => {
self.index_unindexed_collab(data).await?;
Ok(())
},
}
}
pub async fn index_unindexed_collab(&self, data: UnindexedCollab) -> FlowyResult<()> {
let consumers_guard = self.consumers.read().await;
for consumer in consumers_guard.iter() {
match consumer
.consume_collab(
&data.workspace_id,
data.data.clone(),
&data.object_id,
data.collab_type,
)
.await
{
Ok(is_indexed) => {
if is_indexed {
trace!(
"[Indexing] {} consumed {}",
consumer.consumer_id(),
data.object_id
);
}
},
Err(err) => {
error!(
"Consumer {} failed on {}: {}",
consumer.consumer_id(),
data.object_id,
err
);
},
}
}
Ok(())
}
pub async fn queue_collab_embed(
&self,
collab_object: CollabObject,
collab: Weak<dyn CollabIndexedData>,
) {
if !get_operating_system().is_desktop() {
return;
}
if !self.support_collab_type(&collab_object.collab_type) {
return;
}
let mut map = self.collab_by_object.write().await;
map.insert(
collab_object.object_id.clone(),
WriteObject {
collab_object,
collab,
},
);
}
}
pub fn unindexed_data_form_collab(
collab: &Collab,
collab_type: &CollabType,
) -> Option<UnindexedData> {
match collab_type {
CollabType::Document => {
let txn = collab.doc().try_transact().ok()?;
let doc = DocumentBody::from_collab(collab)?;
let paras = doc.paragraphs(txn);
Some(UnindexedData::Paragraphs(paras))
},
_ => None,
}
}
pub fn unindexed_collab_from_encoded_collab(
workspace_id: Uuid,
object_id: Uuid,
encoded_collab: EncodedCollab,
collab_type: CollabType,
) -> Option<UnindexedCollab> {
match collab_type {
CollabType::Document => {
let collab = Collab::new_with_source(
CollabOrigin::Empty,
&object_id.to_string(),
DataSource::DocStateV1(encoded_collab.doc_state.to_vec()),
vec![],
false,
)
.ok()?;
let data = unindexed_data_form_collab(&collab, &collab_type)?;
Some(UnindexedCollab {
workspace_id,
object_id,
collab_type,
data,
metadata: UnindexedCollabMetadata::default(), // default means do not update metadata
})
},
_ => None,
}
}
#[async_trait]
pub trait CollabIndexedData: Send + Sync + 'static {
async fn get_unindexed_data(&self, collab_type: &CollabType) -> Option<UnindexedData>;
}
#[async_trait]
impl<T> CollabIndexedData for RwLock<T>
where
T: BorrowMut<Collab> + Send + Sync + 'static,
{
async fn get_unindexed_data(&self, collab_type: &CollabType) -> Option<UnindexedData> {
let collab = self.try_read().ok()?;
unindexed_data_form_collab(collab.borrow(), collab_type)
}
}
/// writer interface
#[async_trait]
pub trait InstantIndexedDataConsumer: Send + Sync + 'static {
fn consumer_id(&self) -> String;
async fn consume_collab(
&self,
workspace_id: &Uuid,
data: UnindexedData,
object_id: &Uuid,
collab_type: CollabType,
) -> Result<bool, FlowyError>;
async fn did_delete_collab(
&self,
workspace_id: &Uuid,
object_id: &Uuid,
) -> Result<(), FlowyError>;
}
@@ -4,7 +4,7 @@ pub use collab_plugins::CollabKVDB;
pub mod collab_builder;
pub mod config;
pub mod persistence;
pub mod instant_indexed_data_provider;
mod plugin_provider;
pub use collab_plugins::local_storage::kv::doc::CollabKVAction;
@@ -1 +0,0 @@
pub mod collab_metadata_sql;
+19 -7
View File
@@ -84,13 +84,25 @@ impl DartAppFlowyCore {
ret: Option<mpsc::Sender<AFPluginEventResponse>>,
) {
if let Ok(sender_guard) = self.sender.read() {
if let Err(e) = sender_guard.as_ref().unwrap().send(Task {
dispatcher: self.dispatcher().unwrap(),
request,
port,
ret,
}) {
error!("Failed to send task: {}", e);
let dispatcher = match self.dispatcher() {
Some(dispatcher) => dispatcher,
None => {
error!("Failed to get dispatcher: dispatcher is None");
return;
},
};
if let Some(sender) = sender_guard.as_ref() {
if let Err(e) = sender.send(Task {
dispatcher,
request,
port,
ret,
}) {
error!("Failed to send task: {}", e);
}
} else {
error!("Failed to send task: sender is None");
}
} else {
warn!("Failed to acquire read lock for sender");
@@ -48,9 +48,8 @@ zip.workspace = true
walkdir = "2.5.0"
futures = "0.3.31"
flowy-ai-pub = { workspace = true }
serial_test = "2.0.0"
[features]
default = ["supabase_cloud_test"]
dart = ["flowy-core/dart"]
supabase_cloud_test = []
single_thread = []
single_thread = []
@@ -87,4 +87,11 @@ impl EventIntegrationTest {
.await
.parse::<ChatMessageListPB>()
}
pub async fn toggle_local_ai(&self) {
EventBuilder::new(self.clone())
.event(AIEvent::ToggleLocalAI)
.async_send()
.await;
}
}
@@ -112,6 +112,15 @@ impl EventIntegrationTest {
.parse::<WorkspacePB>()
}
pub async fn get_workspace_id(&self) -> Uuid {
let a = EventBuilder::new(self.clone())
.event(FolderEvent::ReadCurrentWorkspace)
.async_send()
.await
.parse::<WorkspacePB>();
Uuid::from_str(&a.id).unwrap()
}
pub async fn get_user_workspace(&self, workspace_id: &str) -> UserWorkspacePB {
let payload = UserWorkspaceIdPB {
workspace_id: workspace_id.to_string(),
@@ -124,7 +133,7 @@ impl EventIntegrationTest {
.parse::<UserWorkspacePB>()
}
pub fn get_folder_search_handler(&self) -> &Arc<dyn SearchHandler> {
pub fn get_folder_search_handler(&self) -> Arc<dyn SearchHandler> {
self
.appflowy_core
.search_manager
@@ -10,6 +10,8 @@ use tokio::sync::broadcast::{channel, Sender};
use tracing::error;
use uuid::Uuid;
use crate::event_builder::EventBuilder;
use crate::EventIntegrationTest;
use flowy_folder::event_map::FolderEvent;
use flowy_notification::entities::SubscribeObject;
use flowy_notification::NotificationSender;
@@ -27,9 +29,6 @@ use flowy_user::event_map::UserEvent;
use flowy_user_pub::entities::WorkspaceType;
use lib_dispatch::prelude::{AFPluginDispatcher, AFPluginRequest, ToBytes};
use crate::event_builder::EventBuilder;
use crate::EventIntegrationTest;
impl EventIntegrationTest {
pub async fn enable_encryption(&self) -> String {
let config = EventBuilder::new(self.clone())
@@ -1,8 +1,6 @@
mod local_test;
mod af_cloud_test;
// #[cfg(feature = "supabase_cloud_test")]
// mod supabase_test;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
@@ -1,3 +1 @@
mod local_test;
// #[cfg(feature = "supabase_cloud_test")]
// mod supabase_test;
@@ -2,8 +2,7 @@ mod database;
mod document;
mod folder;
// TODO(Mathias): Enable tests for search
// mod search;
mod search;
mod sql_test;
mod user;
@@ -0,0 +1,207 @@
use crate::util::unzip;
use event_integration_test::EventIntegrationTest;
use flowy_core::DEFAULT_NAME;
use flowy_search::entities::SearchResponsePB;
use flowy_search::services::manager::SearchType;
use flowy_user::errors::FlowyResult;
use flowy_user_pub::entities::WorkspaceType;
use futures::StreamExt;
use std::time::{Duration, Instant};
use uuid::Uuid;
// Helper function to wait for search indexing to complete
async fn wait_for_indexing(test: &EventIntegrationTest) {
let mut rx = test
.user_manager
.app_life_cycle
.read()
.await
.subscribe_full_indexed_finish()
.unwrap();
let _ = rx.changed().await;
}
// Helper function to perform search and collect results
async fn perform_search(
test: &EventIntegrationTest,
query: &str,
workspace_id: &Uuid,
) -> Vec<FlowyResult<SearchResponsePB>> {
let search_handler = test
.search_manager
.get_handler(SearchType::DocumentLocal)
.unwrap();
let stream = search_handler
.perform_search(query.to_string(), workspace_id)
.await;
stream.collect().await
}
// Helper function to wait for document to be indexed with a specified query
async fn wait_for_document_indexing(
test: &EventIntegrationTest,
query: &str,
workspace_id: &Uuid,
document_name: &str,
timeout_secs: u64,
) -> Vec<FlowyResult<SearchResponsePB>> {
let start_time = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut result = Vec::new();
while start_time.elapsed() < timeout {
result = perform_search(test, query, workspace_id).await;
if let Some(Ok(search_result)) = result.first() {
if let Some(local) = &search_result.local_search_result {
if local
.items
.iter()
.any(|item| item.display_name.contains(document_name))
{
break;
}
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
result
}
#[tokio::test]
async fn anon_user_multiple_workspace_search_test() {
// SETUP: Initialize test environment with test data
let user_db_path = unzip("./tests/asset", "090_anon_search").unwrap();
let test =
EventIntegrationTest::new_with_user_data_path(user_db_path, DEFAULT_NAME.to_string()).await;
let first_workspace_id = test.get_workspace_id().await;
// Wait for initial indexing to complete
wait_for_indexing(&test).await;
// TEST CASE 1: Search by page title
let result = perform_search(&test, "japan", &first_workspace_id).await;
let local = result[0]
.as_ref()
.unwrap()
.local_search_result
.as_ref()
.expect("expected a local_search_result");
assert_eq!(
local.items.len(),
2,
"Should find 2 pages with 'japan' in the title"
);
assert_eq!(
local.items[0].display_name, "Japan Skiing",
"First result should be 'Japan Skiing'"
);
assert_eq!(
local.items[1].display_name, "Japan Food",
"Second result should be 'Japan Food'"
);
// TEST CASE 2: Search by page content
let result = perform_search(&test, "Niseko", &first_workspace_id).await;
let local = result[0]
.as_ref()
.unwrap()
.local_search_result
.as_ref()
.expect("expected a local_search_result");
assert_eq!(
local.items.len(),
1,
"Should find 1 page with 'Niseko' in the content"
);
assert_eq!(
local.items[0].display_name, "Japan Skiing",
"The page should be 'Japan Skiing'"
);
// TEST CASE 3: Create a new document and verify it becomes searchable
// Create and add content to new document
let document_title = "My dog";
let view = test
.create_and_open_document(
&first_workspace_id.to_string(),
document_title.to_string(),
vec![],
)
.await;
test
.insert_document_text(
&view.id,
"I have maltese dog, he love eating food all the time",
0,
)
.await;
// Wait for document to be indexed and searchable
let result = wait_for_document_indexing(
&test,
"maltese dog",
&first_workspace_id,
document_title,
30,
)
.await;
let local = result[0]
.as_ref()
.unwrap()
.local_search_result
.as_ref()
.expect("expected a local_search_result");
assert!(
local
.items
.iter()
.any(|item| item.display_name.contains(document_title)),
"New document should be found when searching for its content"
);
// TEST CASE 4: Create and search in a second workspace
// Create and open a new workspace
let second_workspace_id = Uuid::parse_str(
&test
.create_workspace("my second workspace", WorkspaceType::Local)
.await
.workspace_id,
)
.unwrap();
test
.open_workspace(
&second_workspace_id.to_string(),
WorkspaceType::Local.into(),
)
.await;
// Wait for indexing in the new workspace
wait_for_indexing(&test).await;
// Search in second workspace
let result = perform_search(&test, "japan", &second_workspace_id).await;
assert!(
result[0].as_ref().unwrap().local_search_result.is_none(),
"Empty workspace should not have results for 'japan'"
);
// TEST CASE 5: Return to first workspace and verify search still works
test
.open_workspace(&first_workspace_id.to_string(), WorkspaceType::Local.into())
.await;
wait_for_indexing(&test).await;
let result = perform_search(&test, "japan", &first_workspace_id).await;
assert!(
!result.is_empty(),
"First workspace should still have search results after switching workspaces"
);
}
@@ -1,215 +0,0 @@
use crate::util::{unzip_test_asset, zip};
use collab_folder::View;
use event_integration_test::EventIntegrationTest;
use flowy_core::DEFAULT_NAME;
use flowy_folder::entities::UpdateViewPayloadPB;
use flowy_folder_pub::folder_builder::{FlattedViews, NestedViewBuilder};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::test]
async fn test_folder_index_all_startup() {
let folder_name = "folder_1000_view";
// comment out the following line to create a test asset if you modify the test data
// don't forget to delete unnecessary test assets
// create_folder_test_data(folder_name).await;
let (cleaner, user_db_path) = unzip_test_asset(folder_name).unwrap();
let test =
EventIntegrationTest::new_with_user_data_path(user_db_path.clone(), DEFAULT_NAME.to_string())
.await;
let first_level_views = test.get_all_workspace_views().await;
assert_eq!(first_level_views.len(), 3);
assert_eq!(first_level_views[1].name, "1");
assert_eq!(first_level_views[2].name, "2");
let view_1 = test.get_view(&first_level_views[1].id).await;
assert_eq!(view_1.child_views.len(), 500);
let folder_data = test.get_folder_data();
// Get started + 1002 Views
assert_eq!(folder_data.views.len(), 1003);
// Wait for the index to be created/updated
sleep(Duration::from_secs(1)).await;
let folder_search_manager = test.get_folder_search_handler();
let num_docs = folder_search_manager.index_count();
assert_eq!(num_docs, 1004);
drop(cleaner);
}
#[tokio::test]
async fn test_folder_index_create_20_views() {
let test = EventIntegrationTest::new_anon().await;
let folder_search_manager = test.get_folder_search_handler();
// Wait for the index to be created/updated
sleep(Duration::from_secs(1)).await;
let workspace_id = test.get_current_workspace().await.id;
for i in 0..20 {
let view = test.create_view(&workspace_id, format!("View {}", i)).await;
sleep(Duration::from_millis(500)).await;
assert_eq!(view.name, format!("View {}", i));
}
// Wait for the index update to finish
sleep(Duration::from_secs(2)).await;
let num_docs = folder_search_manager.index_count();
// Workspace + Get started + 20 Views
assert_eq!(num_docs, 22);
}
#[tokio::test]
async fn test_folder_index_create_view() {
let test = EventIntegrationTest::new_anon().await;
let folder_search_manager = test.get_folder_search_handler();
// Wait for the index to be created/updated
sleep(Duration::from_secs(1)).await;
let workspace_id = test.get_current_workspace().await.id;
let view = test.create_view(&workspace_id, "Flowers".to_owned()).await;
// Wait for the index to be updated
sleep(Duration::from_millis(500)).await;
let results = folder_search_manager.perform_search(view.name.clone(), None);
if let Err(e) = results {
panic!("Error performing search: {:?}", e);
}
let results = results.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].data, view.name);
}
#[tokio::test]
async fn test_folder_index_rename_view() {
let test = EventIntegrationTest::new_anon().await;
let folder_search_manager = test.get_folder_search_handler();
// Wait for the index to be created/updated
sleep(Duration::from_secs(1)).await;
let workspace_id = test.get_current_workspace().await.id;
let view = test.create_view(&workspace_id, "Flowers".to_owned()).await;
// Wait for the index to be updated
sleep(Duration::from_millis(500)).await;
let new_view_name = "Bouquets".to_string();
let update_payload = UpdateViewPayloadPB {
view_id: view.id,
name: Some(new_view_name.clone()),
..Default::default()
};
test.update_view(update_payload).await;
// Wait for the index to be updated
sleep(Duration::from_millis(500)).await;
let first = folder_search_manager.perform_search(view.name, None);
if let Err(e) = first {
panic!("Error performing search: {:?}", e);
}
let second = folder_search_manager.perform_search(new_view_name.clone(), None);
if let Err(e) = second {
panic!("Error performing search: {:?}", e);
}
let first = first.unwrap();
assert_eq!(first.len(), 0);
let second = second.unwrap();
assert_eq!(second.len(), 1);
assert_eq!(second[0].data, new_view_name);
}
/// Using this method to create a folder test asset. Only use when you want to create a new asset.
/// The file will be created at tests/asset/{file_name}.zip and it will be committed to the repo.
///
#[allow(dead_code)]
async fn create_folder_test_data(file_name: &str) {
let test = EventIntegrationTest::new_with_name(DEFAULT_NAME).await;
test.sign_up_as_anon().await;
let uid = test.get_user_profile().await.unwrap().id;
let workspace_id = test.get_current_workspace().await.id;
let views = create_1002_views(uid, workspace_id.clone()).await;
test.create_views(views).await;
let first_level_views = test.get_all_workspace_views().await;
assert_eq!(first_level_views.len(), 3);
assert_eq!(first_level_views[1].name, "1");
assert_eq!(first_level_views[2].name, "2");
let view_1 = test.get_view(&first_level_views[1].id).await;
assert_eq!(view_1.child_views.len(), 500);
let folder_data = test.get_folder_data();
// Get started + 1002 Views
assert_eq!(folder_data.views.len(), 1003);
let data_path = test.config.application_path.clone();
zip(
data_path.into(),
format!("tests/asset/{}.zip", file_name).into(),
)
.unwrap();
sleep(Duration::from_secs(2)).await;
}
/// Create view without create the view's content(document/database).
/// workspace
/// - get_started
/// - view_1
/// - view_1_1
/// - view_1_2
/// - view_2
/// - view_2_1
/// - view_2_2
async fn create_1002_views(uid: i64, workspace_id: String) -> Vec<View> {
let mut builder = NestedViewBuilder::new(workspace_id.clone(), uid);
builder
.with_view_builder(|view_builder| async {
let mut builder = view_builder.with_name("1");
for i in 0..500 {
builder = builder
.with_child_view_builder(|child_view_builder| async {
child_view_builder.with_name(format!("1_{}", i)).build()
})
.await;
}
builder.build()
})
.await;
builder
.with_view_builder(|view_builder| async {
let mut builder = view_builder.with_name("2");
for i in 0..500 {
builder = builder
.with_child_view_builder(|child_view_builder| async {
child_view_builder.with_name(format!("2_{}", i)).build()
})
.await;
}
builder.build()
})
.await;
// The output views should be:
// view_1
// view_1_1
// view_1_x
// view_2
// view_2_1
// view_2_x
let views = builder.build();
FlattedViews::flatten_views(views)
}
@@ -1 +0,0 @@
mod folder_search_test;
@@ -1 +1 @@
mod local_test;
mod document_title_content_search;
+4 -1
View File
@@ -13,4 +13,7 @@ futures.workspace = true
serde_json.workspace = true
serde.workspace = true
uuid.workspace = true
flowy-sqlite = { workspace = true }
flowy-sqlite = { workspace = true }
chrono.workspace = true
twox-hash = { version = "2.1.0", features = ["xxhash64"] }
tracing.workspace = true
+3 -3
View File
@@ -123,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(
@@ -153,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(
@@ -0,0 +1,84 @@
use crate::cloud::workspace_dto::ViewIcon;
use crate::cloud::CollabType;
use twox_hash::xxhash64::Hasher;
use uuid::Uuid;
pub const RAG_IDS: &str = "rag_ids";
pub const SOURCE_ID: &str = "id";
pub const SOURCE: &str = "source";
pub const SOURCE_NAME: &str = "name";
pub struct EmbeddingRecord {
pub workspace_id: Uuid,
pub object_id: Uuid,
pub chunks: Vec<EmbeddedChunk>,
}
#[derive(Debug, Clone, Default)]
pub struct UnindexedCollabMetadata {
pub name: Option<String>,
pub icon: Option<ViewIcon>,
}
#[derive(Debug, Clone)]
pub struct UnindexedCollab {
pub workspace_id: Uuid,
pub object_id: Uuid,
pub collab_type: CollabType,
pub data: UnindexedData,
pub metadata: UnindexedCollabMetadata,
}
#[derive(Debug, Clone)]
pub enum UnindexedData {
Text(String),
Paragraphs(Vec<String>),
}
impl UnindexedData {
pub fn is_empty(&self) -> bool {
match self {
UnindexedData::Text(text) => text.is_empty(),
UnindexedData::Paragraphs(paragraphs) => paragraphs.is_empty(),
}
}
pub fn into_string(&self) -> String {
match self {
UnindexedData::Text(text) => text.clone(),
UnindexedData::Paragraphs(paragraphs) => paragraphs.join("\n"),
}
}
pub fn content_hash(&self) -> String {
match self {
UnindexedData::Text(text) => {
let h = Hasher::oneshot(0, text.as_bytes());
format!("{:016x}", h)
},
UnindexedData::Paragraphs(paragraphs) => {
let combined = paragraphs.join("");
let h = Hasher::oneshot(0, combined.as_bytes());
format!("{:016x}", h)
},
}
}
}
#[derive(Debug, Clone)]
pub struct EmbeddedChunk {
pub fragment_id: String,
pub object_id: String,
pub content_type: i32,
pub content: Option<String>,
pub metadata: Option<String>,
pub fragment_index: i32,
pub embedder_type: i32,
pub embeddings: Option<Vec<f32>>,
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub oid: Uuid,
pub content: String,
pub metadata: Option<serde_json::Value>,
pub score: f32,
}
@@ -1,3 +1,4 @@
pub mod cloud;
pub mod entities;
pub mod persistence;
pub mod user_service;
@@ -1,3 +1,4 @@
use crate::cloud::chat_dto::ChatAuthorType;
use crate::cloud::MessageCursor;
use client_api::entity::chat_dto::ChatMessage;
use flowy_error::{FlowyError, FlowyResult};
@@ -10,7 +11,7 @@ use flowy_sqlite::{
Queryable,
};
#[derive(Queryable, Insertable, Identifiable)]
#[derive(Queryable, Insertable, Identifiable, Debug)]
#[diesel(table_name = chat_message_table)]
#[diesel(primary_key(message_id))]
pub struct ChatMessageTable {
@@ -40,6 +41,19 @@ impl ChatMessageTable {
}
}
pub fn select_latest_user_message(
mut conn: DBConnection,
chat_id_val: &str,
auth_type: ChatAuthorType,
) -> FlowyResult<ChatMessageTable> {
dsl::chat_message_table
.filter(chat_message_table::chat_id.eq(chat_id_val))
.filter(chat_message_table::author_type.eq(auth_type as i64))
.order(chat_message_table::created_at.desc())
.first::<ChatMessageTable>(&mut *conn)
.map_err(FlowyError::from)
}
pub fn update_chat_message_is_sync(
mut conn: DBConnection,
chat_id_val: &str,
@@ -59,6 +73,7 @@ pub fn upsert_chat_messages(
mut conn: DBConnection,
new_messages: &[ChatMessageTable],
) -> FlowyResult<()> {
//trace!("Upserting chat messages: {:?}", new_messages);
conn.immediate_transaction(|conn| {
for message in new_messages {
let _ = insert_into(chat_message_table::table)
@@ -18,10 +18,10 @@ use uuid::Uuid;
pub struct ChatTable {
pub chat_id: String,
pub created_at: i64,
pub name: String,
pub metadata: String,
pub rag_ids: Option<String>,
pub is_sync: bool,
pub summary: String,
}
impl ChatTable {
@@ -32,10 +32,10 @@ impl ChatTable {
Self {
chat_id,
created_at: timestamp(),
name: "".to_string(),
metadata,
rag_ids,
is_sync,
summary: "".to_string(),
}
}
}
@@ -66,10 +66,33 @@ pub struct ChatTableFile {
#[diesel(primary_key(chat_id))]
pub struct ChatTableChangeset {
pub chat_id: String,
pub name: Option<String>,
pub metadata: Option<String>,
pub rag_ids: Option<String>,
pub is_sync: Option<bool>,
pub summary: Option<String>,
}
impl ChatTableChangeset {
pub fn summary(chat_id: String, summary: String) -> Self {
Self {
chat_id,
metadata: None,
rag_ids: None,
is_sync: None,
summary: Some(summary),
}
}
pub fn rag_ids(chat_id: String, rag_ids: Vec<String>) -> Self {
let rag_ids = Some(serialize_rag_ids(&rag_ids));
Self {
chat_id,
metadata: None,
rag_ids,
is_sync: None,
summary: None,
}
}
}
pub fn serialize_rag_ids(rag_ids: &[String]) -> String {
@@ -104,7 +127,6 @@ pub fn upsert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult<
.do_update()
.set((
chat_table::created_at.eq(excluded(chat_table::created_at)),
chat_table::name.eq(excluded(chat_table::name)),
chat_table::metadata.eq(excluded(chat_table::metadata)),
chat_table::rag_ids.eq(excluded(chat_table::rag_ids)),
chat_table::is_sync.eq(excluded(chat_table::is_sync)),
@@ -112,13 +134,33 @@ pub fn upsert_chat(mut conn: DBConnection, new_chat: &ChatTable) -> QueryResult<
.execute(&mut *conn)
}
pub fn update_chat(
conn: &mut SqliteConnection,
changeset: ChatTableChangeset,
) -> QueryResult<usize> {
let filter = dsl::chat_table.filter(chat_table::chat_id.eq(changeset.chat_id.clone()));
let affected_row = diesel::update(filter).set(changeset).execute(conn)?;
Ok(affected_row)
pub fn update_chat(mut conn: DBConnection, changeset: ChatTableChangeset) -> QueryResult<usize> {
// Check if the chat exists
let chat_exists = dsl::chat_table
.filter(chat_table::chat_id.eq(&changeset.chat_id))
.first::<ChatTable>(&mut *conn)
.is_ok();
if chat_exists {
// Update existing chat
let filter = dsl::chat_table.filter(chat_table::chat_id.eq(changeset.chat_id.clone()));
diesel::update(filter).set(changeset).execute(&mut *conn)
} else {
// Create a new chat row with default values
let chat = ChatTable {
chat_id: changeset.chat_id.clone(),
created_at: timestamp(),
metadata: changeset.metadata.unwrap_or_else(|| "{}".to_string()),
rag_ids: changeset.rag_ids,
is_sync: changeset.is_sync.unwrap_or(false),
summary: changeset.summary.unwrap_or_default(),
};
// Insert the new row
diesel::insert_into(chat_table::table)
.values(&chat)
.execute(&mut *conn)
}
}
pub fn update_chat_is_sync(
@@ -131,14 +173,22 @@ pub fn update_chat_is_sync(
.execute(&mut *conn)
}
pub fn read_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult<ChatTable> {
pub fn select_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult<ChatTable> {
let row = dsl::chat_table
.filter(chat_table::chat_id.eq(chat_id_val))
.first::<ChatTable>(&mut *conn)?;
Ok(row)
}
pub fn read_chat_rag_ids(
pub fn select_chat_summary(conn: &mut DBConnection, chat_id_val: &Uuid) -> QueryResult<String> {
let summary = dsl::chat_table
.select(chat_table::summary)
.filter(chat_table::chat_id.eq(chat_id_val.to_string()))
.first::<String>(conn)?;
Ok(summary)
}
pub fn select_chat_rag_ids(
conn: &mut SqliteConnection,
chat_id_val: &str,
) -> FlowyResult<Vec<String>> {
@@ -149,7 +199,7 @@ pub fn read_chat_rag_ids(
Ok(deserialize_rag_ids(&chat.rag_ids))
}
pub fn read_chat_metadata(
pub fn select_chat_metadata(
conn: &mut SqliteConnection,
chat_id_val: &str,
) -> FlowyResult<ChatTableMetadata> {
@@ -160,17 +210,6 @@ pub fn read_chat_metadata(
Ok(deserialize_chat_metadata(&metadata_str))
}
#[allow(dead_code)]
pub fn update_chat_name(
mut conn: DBConnection,
chat_id_val: &str,
new_name: &str,
) -> QueryResult<usize> {
diesel::update(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val)))
.set(chat_table::name.eq(new_name))
.execute(&mut *conn)
}
#[allow(dead_code)]
pub fn delete_chat(mut conn: DBConnection, chat_id_val: &str) -> QueryResult<usize> {
diesel::delete(dsl::chat_table.filter(chat_table::chat_id.eq(chat_id_val))).execute(&mut *conn)
@@ -0,0 +1,97 @@
use diesel::sqlite::SqliteConnection;
use flowy_error::FlowyResult;
use flowy_sqlite::upsert::excluded;
use flowy_sqlite::{
diesel,
query_dsl::*,
schema::{index_collab_record_table, index_collab_record_table::dsl},
AsChangeset, ExpressionMethods, Identifiable, Insertable, Queryable,
};
#[derive(Clone, Debug, Queryable, Insertable, AsChangeset, Identifiable)]
#[diesel(table_name = index_collab_record_table)]
#[diesel(primary_key(oid))]
pub struct IndexCollabRecordTable {
pub oid: String,
pub workspace_id: String,
pub content_hash: String,
}
impl IndexCollabRecordTable {
pub fn new(workspace_id: String, oid: String, content_hash: String) -> Self {
Self {
workspace_id,
oid,
content_hash,
}
}
}
pub fn batch_upsert_index_collab(
conn: &mut SqliteConnection,
rows: Vec<IndexCollabRecordTable>,
) -> FlowyResult<()> {
if rows.is_empty() {
return Ok(());
}
conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| {
for row in rows {
diesel::insert_into(index_collab_record_table::table)
.values(&row)
.on_conflict(index_collab_record_table::oid)
.do_update()
.set(
index_collab_record_table::content_hash
.eq(excluded(index_collab_record_table::content_hash)),
)
.execute(conn)?;
}
Ok(())
})?;
Ok(())
}
pub fn upsert_index_collab(
conn: &mut SqliteConnection,
row: IndexCollabRecordTable,
) -> FlowyResult<()> {
diesel::insert_into(index_collab_record_table::table)
.values(row)
.on_conflict(index_collab_record_table::oid)
.do_update()
.set((
index_collab_record_table::content_hash.eq(excluded(index_collab_record_table::content_hash)),
))
.execute(conn)?;
Ok(())
}
pub fn select_indexed_collab_ids(
conn: &mut SqliteConnection,
workspace_id: String,
) -> FlowyResult<Vec<String>> {
let result = index_collab_record_table::table
.filter(index_collab_record_table::workspace_id.eq(workspace_id))
.select(dsl::oid)
.load::<String>(conn)?;
Ok(result)
}
pub fn select_indexed_collab(
conn: &mut SqliteConnection,
workspace_id: String,
limit: i64,
offset: i64,
) -> FlowyResult<Vec<IndexCollabRecordTable>> {
let result = index_collab_record_table::table
.filter(index_collab_record_table::workspace_id.eq(workspace_id))
.limit(limit)
.offset(offset)
.load::<IndexCollabRecordTable>(conn)?;
Ok(result)
}
@@ -1,7 +1,11 @@
mod chat_message_sql;
mod chat_sql;
mod collab_metadata_sql;
mod collab_sql;
mod local_model_sql;
pub use chat_message_sql::*;
pub use chat_sql::*;
pub use collab_metadata_sql::*;
pub use collab_sql::*;
pub use local_model_sql::*;
+22 -6
View File
@@ -33,7 +33,7 @@ log = "0.4.21"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
anyhow = "1.0.86"
tokio-stream = "0.1.15"
tokio-stream.workspace = true
tokio-util = { workspace = true, features = ["full"] }
af-local-ai = { workspace = true }
af-plugin = { workspace = true }
@@ -43,21 +43,37 @@ base64 = "0.21.5"
futures-util = "0.3.30"
pin-project = "1.1.5"
flowy-storage-pub = { workspace = true }
collab-integrate.workspace = true
ollama-rs = "0.3.0"
ollama-rs.workspace = true
schemars = "0.8.22"
twox-hash = { version = "2.1.0", features = ["xxhash64"] }
async-trait.workspace = true
async-stream = "0.3.6"
flowy-database-pub = { workspace = true }
langchain-rust = { version = "4.6.0", features = ["ollama"] }
[target.'cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))'.dependencies]
notify = "6.1.1"
#faiss = { version = "0.12.1" }
af-mcp = { version = "0.1.0" }
text-splitter = { version = "0.25.1" }
flowy-sqlite-vec.workspace = true
lopdf = { version = "0.36.0", optional = true }
pulldown-cmark = { version = "0.13.0", optional = true }
[dev-dependencies]
uuid.workspace = true
tracing-subscriber = { version = "0.3.3", features = ["env-filter"] }
tempfile = "3.8.1"
[build-dependencies]
flowy-codegen.workspace = true
tempdir = "0.3.7"
[features]
default = []
dart = ["flowy-codegen/dart", "flowy-notification/dart"]
local_ai = []
local_ai = []
file_reader = ["lopdf", "pulldown-cmark"]
ai-tool = []
+97 -62
View File
@@ -5,7 +5,9 @@ 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;
@@ -18,10 +20,11 @@ use crate::model_select::{
ServerModelStorageImpl, SourceKey, GLOBAL_ACTIVE_MODEL_KEY,
};
use crate::notification::{chat_notification_builder, ChatNotification};
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::user_service::AIUserService;
use flowy_sqlite::DBConnection;
use flowy_storage_pub::storage::StorageService;
use lib_infra::async_trait::async_trait;
use serde_json::json;
@@ -29,7 +32,7 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{Arc, Weak};
use tokio::sync::Mutex;
use tracing::{error, info, instrument, trace};
use tracing::{error, info, instrument, trace, warn};
use uuid::Uuid;
/// AIExternalService is an interface for external services that AI plugin can interact with.
@@ -76,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(),
@@ -118,14 +116,14 @@ 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 {
@@ -137,7 +135,7 @@ 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 {
@@ -149,7 +147,7 @@ impl AIManager {
}
// Log status for other cases
if is_running {
if is_ready {
info!("[AI Manager] Local AI is already running");
}
}
@@ -157,7 +155,8 @@ impl AIManager {
async fn prepare_local_ai(&self, workspace_id: &Uuid) {
self
.local_ai
.reload_ollama_client(&workspace_id.to_string());
.reload_ollama_client(&workspace_id.to_string())
.await;
self
.model_control
.lock()
@@ -210,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(
@@ -230,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()
@@ -255,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(())
}
@@ -267,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()
@@ -318,7 +326,7 @@ impl AIManager {
) -> Result<ChatMessagePB, FlowyError> {
let chat = self.get_or_create_chat_instance(&params.chat_id).await?;
let ai_model = self.get_active_model(&params.chat_id.to_string()).await;
let question = chat.stream_chat_message(&params, Some(ai_model)).await?;
let question = chat.stream_chat_message(&params, ai_model).await?;
let _ = self
.external_service
.notify_did_send_message(&params.chat_id, &params.message)
@@ -344,7 +352,7 @@ impl AIManager {
Some(model) => model.into(),
};
chat
.stream_regenerate_response(question_message_id, answer_stream_port, format, Some(model))
.stream_regenerate_response(question_message_id, answer_stream_port, format, model)
.await?;
Ok(())
}
@@ -353,8 +361,7 @@ impl AIManager {
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
@@ -375,7 +382,8 @@ impl AIManager {
if need_restart {
self
.local_ai
.reload_ollama_client(&workspace_id.to_string());
.reload_ollama_client(&workspace_id.to_string())
.await;
self.local_ai.restart_plugin().await;
}
@@ -428,7 +436,7 @@ impl AIManager {
if enabled {
self.prepare_local_ai(&workspace_id).await;
if let Some(name) = self.local_ai.get_plugin_chat_model() {
if let Some(name) = self.local_ai.get_local_chat_model() {
let model = AIModel::local(name, "".to_string());
info!(
"[Model Selection] Set global active model to local ai: {}",
@@ -483,8 +491,10 @@ impl AIManager {
}
}
pub async fn get_local_available_models(&self) -> FlowyResult<ModelSelectionPB> {
let setting = self.local_ai.get_local_ai_setting();
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
@@ -493,10 +503,25 @@ impl AIManager {
.get_local_models(&workspace_id)
.await;
let selected_model = AIModel::local(setting.chat_model_name, "".to_string());
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(),
@@ -511,7 +536,7 @@ 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;
}
let workspace_id = self.user_service.workspace_id()?;
@@ -526,6 +551,14 @@ impl AIManager {
let active_model = model_control
.get_active_model(&workspace_id, &source_key)
.await;
trace!(
"[Model Selection] {} active model: {:?}, global model:{:?}",
source_key.storage_id(),
active_model,
local_model_name
);
let all_models = model_control
.get_models_with_specific_local_model(&workspace_id, local_model_name)
.await;
@@ -568,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,
@@ -602,9 +634,7 @@ impl AIManager {
) -> Result<RepeatedRelatedQuestionPB, FlowyError> {
let chat = self.get_or_create_chat_instance(chat_id).await?;
let ai_model = self.get_active_model(&chat_id.to_string()).await;
let resp = chat
.get_related_question(message_id, Some(ai_model))
.await?;
let resp = chat.get_related_question(message_id, ai_model).await?;
Ok(resp)
}
@@ -630,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(
@@ -661,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())
@@ -710,7 +746,6 @@ async fn sync_chat_documents(
{
if let Ok(uid) = user_service.user_id() {
if let Ok(conn) = user_service.sqlite_connection(uid) {
info!("sync rag documents success: {}", metadatas.len());
batch_insert_collab_metadata(conn, &metadatas).unwrap();
}
}
@@ -0,0 +1,32 @@
use pulldown_cmark::{html, Options, Parser};
use std::fs;
pub struct MdRead {
path: String,
}
impl MdRead {
/// Creates a new MdRead for the given file path.
pub fn new<P: Into<String>>(path: P) -> Self {
MdRead { path: path.into() }
}
pub fn read_markdown(&self) -> std::io::Result<String> {
fs::read_to_string(&self.path)
}
/// Renders the markdown to HTML, with CommonMark extensions enabled.
pub fn render_html(&self) -> Result<String, Box<dyn std::error::Error>> {
let markdown_input = self.read_markdown()?;
// Set up parser options (tables, footnotes, strikethrough)
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
// Create parser and render HTML
let parser = Parser::new_ext(&markdown_input, options);
let mut html_output = String::with_capacity(markdown_input.len() * 3 / 2);
html::push_html(&mut html_output, parser);
Ok(html_output)
}
}
@@ -0,0 +1,2 @@
pub mod markdown;
pub mod pdf;
@@ -0,0 +1,31 @@
use lopdf::Document;
pub struct PdfRead {
path: String,
}
impl PdfRead {
pub fn new<P: Into<String>>(path: P) -> Self {
PdfRead { path: path.into() }
}
/// Loads the PDF and extracts text from all pages, concatenated with double newlines.
pub fn read_all(&self) -> lopdf::Result<String> {
let doc = Document::load(&self.path)?;
// Get a map of page numbers to object IDs
let pages = doc.get_pages();
let mut texts = Vec::with_capacity(pages.len());
// Iterate pages in ascending order
let mut page_numbers: Vec<u32> = pages.keys().cloned().collect();
page_numbers.sort_unstable();
for page_num in page_numbers {
// Extract text for this page
let page_text = doc.extract_text(&[page_num])?;
texts.push(page_text);
}
// Join all pages with blank line separators
Ok(texts.join("\n\n"))
}
}
+10 -5
View File
@@ -73,7 +73,7 @@ impl Chat {
pub async fn stream_chat_message(
&self,
params: &StreamMessageParams,
preferred_ai_model: Option<AIModel>,
preferred_ai_model: AIModel,
) -> Result<ChatMessagePB, FlowyError> {
trace!(
"[Chat] stream chat message: chat_id={}, message={}, message_type={:?}, format={:?}",
@@ -138,7 +138,7 @@ impl Chat {
question_id: i64,
answer_stream_port: i64,
format: Option<PredefinedFormatPB>,
ai_model: Option<AIModel>,
ai_model: AIModel,
) -> FlowyResult<()> {
trace!(
"[Chat] regenerate and stream chat message: chat_id={}",
@@ -152,7 +152,6 @@ impl Chat {
self.stream_buffer.lock().await.clear();
let format = format.map(Into::into).unwrap_or_default();
let answer_stream_buffer = self.stream_buffer.clone();
let uid = self.user_service.user_id()?;
let workspace_id = self.user_service.workspace_id()?;
@@ -179,7 +178,7 @@ impl Chat {
workspace_id: Uuid,
question_id: i64,
format: ResponseFormat,
ai_model: Option<AIModel>,
ai_model: AIModel,
) {
let stop_stream = self.stop_stream.clone();
let chat_id = self.chat_id;
@@ -503,7 +502,7 @@ impl Chat {
pub async fn get_related_question(
&self,
message_id: i64,
ai_model: Option<AIModel>,
ai_model: AIModel,
) -> Result<RepeatedRelatedQuestionPB, FlowyError> {
let workspace_id = self.user_service.workspace_id()?;
let resp = self
@@ -543,6 +542,12 @@ impl Chat {
limit: u64,
offset: MessageCursor,
) -> Result<Vec<ChatMessagePB>, FlowyError> {
trace!(
"[Chat] Loading messages from disk: chat_id={}, limit={}, offset={:?}",
self.chat_id,
limit,
offset
);
let conn = self.user_service.sqlite_connection(self.uid)?;
let rows = select_chat_messages(conn, &self.chat_id.to_string(), limit, offset)?.messages;
let messages = rows
+4 -4
View File
@@ -40,7 +40,7 @@ impl AICompletion {
pub async fn create_complete_task(
&self,
complete: CompleteTextPB,
preferred_model: Option<AIModel>,
preferred_model: AIModel,
) -> FlowyResult<CompleteTextTaskPB> {
if matches!(complete.completion_type, CompletionTypePB::CustomPrompt)
&& complete.custom_prompt.is_none()
@@ -84,14 +84,14 @@ pub struct CompletionTask {
stop_rx: tokio::sync::mpsc::Receiver<()>,
context: CompleteTextPB,
cloud_service: Weak<dyn ChatCloudService>,
preferred_model: Option<AIModel>,
preferred_model: AIModel,
}
impl CompletionTask {
pub fn new(
workspace_id: Uuid,
context: CompleteTextPB,
preferred_model: Option<AIModel>,
preferred_model: AIModel,
cloud_service: Weak<dyn ChatCloudService>,
stop_rx: tokio::sync::mpsc::Receiver<()>,
) -> Self {
@@ -117,7 +117,7 @@ impl CompletionTask {
CompletionTypePB::MakeLonger => CompletionType::MakeLonger,
CompletionTypePB::ContinueWriting => CompletionType::ContinueWriting,
CompletionTypePB::ExplainSelected => CompletionType::Explain,
CompletionTypePB::UserQuestion => CompletionType::UserQuestion,
CompletionTypePB::UserQuestion => CompletionType::AskAI,
CompletionTypePB::CustomPrompt => CompletionType::CustomPrompt,
};
@@ -0,0 +1,87 @@
use crate::embeddings::scheduler::EmbeddingScheduler;
use arc_swap::ArcSwapOption;
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_sqlite_vec::db::VectorSqliteDB;
use lib_infra::util::get_operating_system;
use ollama_rs::Ollama;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use tracing::{error, info, warn};
pub struct EmbedContext {
ollama: ArcSwapOption<Ollama>,
vector_db: ArcSwapOption<VectorSqliteDB>,
scheduler: ArcSwapOption<EmbeddingScheduler>,
}
impl EmbedContext {
pub fn shared() -> &'static Arc<EmbedContext> {
static INSTANCE: OnceLock<Arc<EmbedContext>> = OnceLock::new();
INSTANCE.get_or_init(|| {
Arc::new(EmbedContext {
ollama: ArcSwapOption::empty(),
vector_db: ArcSwapOption::empty(),
scheduler: Default::default(),
})
})
}
pub fn get_vector_db(&self) -> Option<Arc<VectorSqliteDB>> {
self.vector_db.load_full()
}
pub fn init_vector_db(&self, db_path: PathBuf) {
let sys = get_operating_system();
if !sys.is_desktop() {
warn!("[Embedding] Vector db is not supported on {:?}", sys);
return;
}
info!("Initializing vector db");
match VectorSqliteDB::new(db_path.clone()) {
Ok(db) => {
info!("[Embedding] Vector db created at: {:?}", db_path);
self.vector_db.store(Some(Arc::new(db)));
self.try_create_scheduler();
},
Err(err) => {
error!("[Embedding] Failed to create vector db: {}", err);
},
}
}
pub fn set_ollama(&self, ollama: Option<Arc<Ollama>>) {
if let Some(ollama) = ollama {
self.ollama.store(Some(ollama));
self.try_create_scheduler();
} else {
self.ollama.store(None);
if let Some(s) = self.scheduler.swap(None) {
info!("[Embedding] Stopping scheduler");
let _ = s.stop_tx.send(());
}
}
}
pub fn get_scheduler(&self) -> FlowyResult<Arc<EmbeddingScheduler>> {
self.scheduler.load_full().ok_or_else(|| {
FlowyError::new(
ErrorCode::LocalEmbeddingNotReady,
"Local embedding is not ready. Please check if the Ollama and vector db are initialized.",
)
})
}
fn try_create_scheduler(&self) {
if let (Some(ollama), Some(vector_db)) = (self.ollama.load_full(), self.vector_db.load_full()) {
info!("[Embedding] Creating scheduler");
match EmbeddingScheduler::new(ollama, vector_db) {
Ok(s) => {
info!("[Embedding] create scheduler successfully");
self.scheduler.store(Some(s));
},
Err(err) => error!("[Embedding] Failed to create scheduler: {}", err),
}
}
}
}
@@ -0,0 +1,192 @@
use crate::embeddings::embedder::Embedder;
use crate::embeddings::indexer::{EmbeddingModel, Indexer};
use flowy_ai_pub::entities::{EmbeddedChunk, SOURCE, SOURCE_ID, SOURCE_NAME};
use flowy_error::FlowyError;
use lib_infra::async_trait::async_trait;
use ollama_rs::generation::embeddings::request::{EmbeddingsInput, GenerateEmbeddingsRequest};
use serde_json::json;
use text_splitter::{ChunkConfig, TextSplitter};
use tracing::{debug, error, trace, warn};
use twox_hash::xxhash64::Hasher;
use uuid::Uuid;
pub struct DocumentIndexer;
#[async_trait]
impl Indexer for DocumentIndexer {
fn create_embedded_chunks_from_text(
&self,
object_id: Uuid,
paragraphs: Vec<String>,
model: EmbeddingModel,
) -> Result<Vec<EmbeddedChunk>, FlowyError> {
if paragraphs.is_empty() {
warn!(
"[Embedding] No paragraphs found in document `{}`. Skipping embedding.",
object_id
);
return Ok(vec![]);
}
split_text_into_chunks(&object_id.to_string(), paragraphs, model, 1000, 200)
}
async fn embed(
&self,
embedder: &Embedder,
mut chunks: Vec<EmbeddedChunk>,
) -> Result<Vec<EmbeddedChunk>, FlowyError> {
let mut valid_indices = Vec::new();
for (i, chunk) in chunks.iter().enumerate() {
if let Some(ref content) = chunk.content {
if !content.is_empty() {
valid_indices.push(i);
}
}
}
if valid_indices.is_empty() {
return Ok(vec![]);
}
let mut contents = Vec::with_capacity(valid_indices.len());
for &i in &valid_indices {
contents.push(chunks[i].content.as_ref().unwrap().to_owned());
}
let request = GenerateEmbeddingsRequest::new(
embedder.model().name().to_string(),
EmbeddingsInput::Multiple(contents),
);
let resp = embedder.embed(request).await?;
if resp.embeddings.len() != valid_indices.len() {
error!(
"[Embedding] requested {} embeddings, received {} embeddings",
valid_indices.len(),
resp.embeddings.len()
);
return Err(FlowyError::internal().with_context(format!(
"Mismatch in number of embeddings requested and received: {} vs {}",
valid_indices.len(),
resp.embeddings.len()
)));
}
for (index, embedding) in resp.embeddings.into_iter().enumerate() {
let chunk_idx = valid_indices[index];
chunks[chunk_idx].embeddings = Some(embedding);
}
Ok(chunks)
}
}
/// chunk_size:
/// Small Chunks (50–256 tokens): Best for precision-focused tasks (e.g., Q&A, technical docs) where specific details matter.
/// Medium Chunks (256–1,024 tokens): Ideal for balanced tasks like RAG or contextual search, providing enough context without noise.
/// Large Chunks (1,024–2,048 tokens): Suited for analysis or thematic tasks where broad understanding is key.
///
/// overlap:
/// Add 10–20% overlap for larger chunks (e.g., 50–100 tokens for 512-token chunks) to preserve context across boundaries.
pub fn split_text_into_chunks(
object_id: &str,
paragraphs: Vec<String>,
embedding_model: EmbeddingModel,
chunk_size: usize,
overlap: usize,
) -> Result<Vec<EmbeddedChunk>, FlowyError> {
debug_assert!(matches!(embedding_model, EmbeddingModel::NomicEmbedText));
if paragraphs.is_empty() {
return Ok(vec![]);
}
let split_contents = group_paragraphs_by_max_content_len(paragraphs, chunk_size, overlap);
let metadata = json!({
SOURCE_ID: object_id,
SOURCE: "appflowy",
SOURCE_NAME: "document",
});
let mut seen = std::collections::HashSet::new();
let mut chunks = Vec::new();
for (index, content) in split_contents.into_iter().enumerate() {
let metadata_string = metadata.to_string();
let combined_data = format!("{}{}", content, metadata_string);
let consistent_hash = Hasher::oneshot(0, combined_data.as_bytes());
let fragment_id = format!("{:x}", consistent_hash);
if seen.insert(fragment_id.clone()) {
chunks.push(EmbeddedChunk {
fragment_id,
object_id: object_id.to_string(),
content_type: 0,
content: Some(content),
embeddings: None,
metadata: Some(metadata_string),
fragment_index: index as i32,
embedder_type: 0,
});
} else {
debug!(
"[Embedding] Duplicate fragment_id detected: {}. This fragment will not be added.",
fragment_id
);
}
}
trace!(
"[Embedding] Created {} chunks for object_id `{}`, chunk_size: {}, overlap: {}",
chunks.len(),
object_id,
chunk_size,
overlap
);
Ok(chunks)
}
pub fn group_paragraphs_by_max_content_len(
paragraphs: Vec<String>,
mut context_size: usize,
overlap: usize,
) -> Vec<String> {
if paragraphs.is_empty() {
return vec![];
}
let mut result = Vec::new();
let mut current = String::with_capacity(context_size.min(4096));
if overlap > context_size {
warn!("context_size is smaller than overlap, which may lead to unexpected behavior.");
context_size = 2 * overlap;
}
let chunk_config = ChunkConfig::new(context_size)
.with_overlap(overlap)
.unwrap();
let splitter = TextSplitter::new(chunk_config);
for paragraph in paragraphs {
if current.len() + paragraph.len() > context_size {
if !current.is_empty() {
result.push(std::mem::take(&mut current));
}
if paragraph.len() > context_size {
let paragraph_chunks = splitter.chunks(&paragraph);
result.extend(paragraph_chunks.map(String::from));
} else {
current.push_str(&paragraph);
}
} else {
// Add paragraph to current chunk
current.push_str(&paragraph);
}
}
if !current.is_empty() {
result.push(current);
}
result
}
@@ -0,0 +1,41 @@
use crate::embeddings::indexer::EmbeddingModel;
use flowy_error::FlowyResult;
use ollama_rs::generation::embeddings::request::GenerateEmbeddingsRequest;
use ollama_rs::generation::embeddings::GenerateEmbeddingsResponse;
use ollama_rs::Ollama;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub enum Embedder {
Ollama(OllamaEmbedder),
}
impl Embedder {
pub async fn embed(
&self,
request: GenerateEmbeddingsRequest,
) -> FlowyResult<GenerateEmbeddingsResponse> {
match self {
Embedder::Ollama(ollama) => ollama.embed(request).await,
}
}
pub fn model(&self) -> EmbeddingModel {
EmbeddingModel::NomicEmbedText
}
}
#[derive(Debug, Clone)]
pub struct OllamaEmbedder {
pub ollama: Arc<Ollama>,
}
impl OllamaEmbedder {
pub async fn embed(
&self,
request: GenerateEmbeddingsRequest,
) -> FlowyResult<GenerateEmbeddingsResponse> {
let resp = self.ollama.generate_embeddings(request).await?;
Ok(resp)
}
}
@@ -0,0 +1,70 @@
use crate::embeddings::document_indexer::DocumentIndexer;
use crate::embeddings::embedder::Embedder;
use flowy_ai_pub::cloud::CollabType;
use flowy_ai_pub::entities::EmbeddedChunk;
use flowy_error::FlowyError;
use lib_infra::async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Hash)]
pub enum EmbeddingModel {
NomicEmbedText,
}
impl EmbeddingModel {
pub fn name(&self) -> &'static str {
match self {
EmbeddingModel::NomicEmbedText => "nomic-embed-text",
}
}
#[allow(dead_code)]
pub fn dimension(&self) -> usize {
match self {
// https://ollama.com/library/nomic-embed-text/blobs/970aa74c0a90
EmbeddingModel::NomicEmbedText => 768,
}
}
}
#[async_trait]
pub trait Indexer: Send + Sync {
fn create_embedded_chunks_from_text(
&self,
object_id: Uuid,
paragraphs: Vec<String>,
model: EmbeddingModel,
) -> Result<Vec<EmbeddedChunk>, FlowyError>;
async fn embed(
&self,
embedder: &Embedder,
chunks: Vec<EmbeddedChunk>,
) -> Result<Vec<EmbeddedChunk>, FlowyError>;
}
/// A structure responsible for resolving different [Indexer] types for different [CollabType]s,
/// including access permission checks for the specific workspaces.
pub struct IndexerProvider {
indexer_cache: HashMap<CollabType, Arc<dyn Indexer>>,
}
impl IndexerProvider {
pub fn new() -> Arc<Self> {
let mut cache: HashMap<CollabType, Arc<dyn Indexer>> = HashMap::new();
cache.insert(CollabType::Document, Arc::new(DocumentIndexer));
Arc::new(Self {
indexer_cache: cache,
})
}
/// Returns indexer for a specific type of [Collab] object.
/// If collab of given type is not supported or workspace it belongs to has indexing disabled,
/// returns `None`.
pub fn indexer_for(&self, collab_type: CollabType) -> Option<Arc<dyn Indexer>> {
self.indexer_cache.get(&collab_type).cloned()
}
}
@@ -0,0 +1,6 @@
pub mod context;
mod document_indexer;
mod embedder;
pub(crate) mod indexer;
mod scheduler;
pub mod store;
@@ -0,0 +1,346 @@
use crate::embeddings::embedder::{Embedder, OllamaEmbedder};
use crate::embeddings::indexer::IndexerProvider;
use crate::search::summary::{summarize_documents, LLMDocument};
use flowy_ai_pub::cloud::search_dto::{
SearchContentType, SearchDocumentResponseItem, SearchResult, SearchSummaryResult, Summary,
};
use flowy_ai_pub::entities::{EmbeddingRecord, UnindexedCollab, UnindexedData};
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_sqlite::internal::derives::multiconnection::chrono::Utc;
use flowy_sqlite_vec::db::VectorSqliteDB;
use ollama_rs::generation::embeddings::request::{EmbeddingsInput, GenerateEmbeddingsRequest};
use ollama_rs::Ollama;
use std::sync::{Arc, Weak};
use tokio::select;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use tokio::sync::{broadcast, mpsc};
use tracing::{debug, error, info, trace, warn};
use uuid::Uuid;
type UnindexedCollabContext = UnindexedCollab;
pub struct EmbeddingScheduler {
indexer_provider: Arc<IndexerProvider>,
write_embedding_tx: UnboundedSender<EmbeddingRecord>,
generate_embedding_tx: mpsc::Sender<UnindexedCollab>,
ollama: Arc<Ollama>,
vector_db: Arc<VectorSqliteDB>,
pub(crate) stop_tx: tokio::sync::broadcast::Sender<()>,
}
impl EmbeddingScheduler {
pub fn new(
ollama: Arc<Ollama>,
vector_db: Arc<VectorSqliteDB>,
) -> FlowyResult<Arc<EmbeddingScheduler>> {
let indexer_provider = IndexerProvider::new();
let (write_embedding_tx, write_embedding_rx) = unbounded_channel::<EmbeddingRecord>();
let (generate_embedding_tx, gen_embedding_rx) = mpsc::channel::<UnindexedCollabContext>(100);
let (stop_tx, _) = broadcast::channel::<()>(1);
let this = Arc::new(Self {
indexer_provider,
write_embedding_tx,
generate_embedding_tx,
ollama,
vector_db,
stop_tx,
});
let weak_this = Arc::downgrade(&this);
let stop_rx = this.stop_tx.subscribe();
tokio::spawn(spawn_generate_embeddings(
gen_embedding_rx,
weak_this.clone(),
stop_rx,
));
let weak_this = Arc::downgrade(&this);
let stop_rx = this.stop_tx.subscribe();
tokio::spawn(spawn_write_embeddings(
write_embedding_rx,
weak_this,
stop_rx,
));
Ok(this)
}
pub(crate) fn create_embedder(&self) -> Result<Embedder, FlowyError> {
let embedder = Embedder::Ollama(OllamaEmbedder {
ollama: self.ollama.clone(),
});
Ok(embedder)
}
pub async fn index_collab(&self, data: UnindexedCollab) -> FlowyResult<()> {
trace!("[Embedding] got {} unindexd data", data.object_id);
if let Err(err) = self.generate_embedding_tx.send(data).await {
error!("[Embedding] error generating embedding: {}", err);
}
Ok(())
}
pub async fn delete_collab(&self, workspace_id: &Uuid, object_id: &Uuid) -> FlowyResult<()> {
self
.vector_db
.delete_collab(&workspace_id.to_string(), &object_id.to_string())
.await
.map_err(|err| {
error!("[Embedding] Failed to delete collab: {}", err);
FlowyError::new(ErrorCode::LocalEmbeddingNotReady, "Failed to delete collab")
})?;
Ok(())
}
pub async fn search(
&self,
workspace_id: &Uuid,
query: &str,
) -> FlowyResult<Vec<SearchDocumentResponseItem>> {
let embedder = self.create_embedder()?;
let request = GenerateEmbeddingsRequest::new(
embedder.model().name().to_string(),
EmbeddingsInput::Single(query.to_string()),
);
let resp = embedder.embed(request).await?;
match resp.embeddings.first() {
None => Ok(vec![]),
Some(query_embed) => {
let result = self
.vector_db
.search_with_score(&workspace_id.to_string(), &[], query_embed, 10, 0.4)
.await
.map_err(|err| {
error!("[Embedding] Failed to search: {}", err);
FlowyError::new(ErrorCode::LocalEmbeddingNotReady, "Failed to search")
})?;
let rows = result
.into_iter()
.map(|v| SearchDocumentResponseItem {
object_id: v.oid,
workspace_id: *workspace_id,
score: 1.0,
content_type: Some(SearchContentType::PlainText),
content: v.content,
preview: None,
created_by: "".to_string(),
created_at: Utc::now(),
})
.collect::<Vec<_>>();
Ok(rows)
},
}
}
pub async fn generate_summary(
&self,
question: &str,
model_name: &str,
search_results: Vec<SearchResult>,
) -> FlowyResult<SearchSummaryResult> {
if search_results.is_empty() {
return Ok(SearchSummaryResult { summaries: vec![] });
}
let docs = search_results
.into_iter()
.map(|v| LLMDocument {
content: v.content,
object_id: v.object_id,
})
.collect::<Vec<_>>();
let resp = summarize_documents(&self.ollama, question, model_name, docs)
.await
.map_err(|err| {
error!("[Embedding] Failed to generate summary: {}", err);
FlowyError::new(
ErrorCode::LocalEmbeddingNotReady,
"Failed to generate summary",
)
})?;
let summaries = resp
.summaries
.into_iter()
.flat_map(|s| {
if s.content.is_empty() {
None
} else {
Some(Summary {
content: s.content,
highlights: s.highlights,
sources: s.sources,
})
}
})
.collect::<Vec<_>>();
Ok(SearchSummaryResult { summaries })
}
}
const EMBEDDING_RECORD_BUFFER_SIZE: usize = 10;
pub async fn spawn_write_embeddings(
mut rx: UnboundedReceiver<EmbeddingRecord>,
scheduler: Weak<EmbeddingScheduler>,
mut stop_rx: broadcast::Receiver<()>,
) {
let mut buf = Vec::with_capacity(EMBEDDING_RECORD_BUFFER_SIZE);
loop {
select! {
// Shutdown signal arrives
_ = stop_rx.recv() => {
info!("Received stop signal; shutting down embedding writer");
break;
}
// Next batch from the input channel
n = rx.recv_many(&mut buf, EMBEDDING_RECORD_BUFFER_SIZE) => {
// channel closed
if n == 0 {
info!("Input channel closed; stopping write embeddings");
break;
}
// upgrade scheduler reference
let scheduler = match scheduler.upgrade() {
Some(db) => db,
None => {
error!("EmbeddingScheduler dropped; stopping write embeddings");
break;
}
};
// drain and process exactly `n` records
let records = buf.drain(..n).collect::<Vec<_>>();
for record in records {
debug!("[Embedding] Writing {} chunks for {}", record.chunks.len(), record.object_id);
match scheduler
.vector_db
.upsert_collabs_embeddings(&record.workspace_id.to_string(), &record.object_id.to_string(), record.chunks)
.await
{
Ok(_) => trace!("[Embedding] Successfully wrote embeddings for {}", record.object_id),
Err(err) => error!("[Embedding] Failed to write embeddings for {}: {}", record.object_id, err),
}
}
}
}
}
info!("spawn_write_embeddings exited");
}
async fn spawn_generate_embeddings(
mut rx: mpsc::Receiver<UnindexedCollab>,
scheduler: Weak<EmbeddingScheduler>,
mut stop_rx: broadcast::Receiver<()>,
) {
let mut buf = Vec::with_capacity(EMBEDDING_RECORD_BUFFER_SIZE);
loop {
select! {
_ = stop_rx.recv() => {
info!("Received stop signal; shutting down embedding writer");
break;
}
n = rx.recv_many(&mut buf, EMBEDDING_RECORD_BUFFER_SIZE) => {
let scheduler = match scheduler.upgrade() {
Some(scheduler) => scheduler,
None => {
info!("[Embedding] Failed to upgrade scheduler connection, break loop");
break;
},
};
if n == 0 {
info!("[Embedding] Stop generating embeddings");
break;
}
let records = buf.drain(..n).collect::<Vec<_>>();
let indexer_provider = scheduler.indexer_provider.clone();
let write_embedding_tx = scheduler.write_embedding_tx.clone();
let embedder = scheduler.create_embedder();
match embedder {
Ok(embedder) => {
let params: Vec<_> = records.iter().map(|r| r.object_id.to_string()).collect();
let existing_embeddings = scheduler
.vector_db
.select_collabs_fragment_ids(&params)
.await
.unwrap_or_else(|err| {
error!("[Embedding] failed to get existing embeddings: {}", err);
Default::default()
});
for record in records {
if let Some(indexer) = indexer_provider.indexer_for(record.collab_type) {
let paragraphs = match record.data {
UnindexedData::Paragraphs(paragraphs) => paragraphs,
UnindexedData::Text(text) => text.split('\n').map(|s| s.to_string()).collect(),
};
let embedder = embedder.clone();
match indexer.create_embedded_chunks_from_text(
record.object_id,
paragraphs,
embedder.model(),
) {
Ok(mut chunks) => {
if let Some(fragment_ids) = existing_embeddings.get(&record.object_id) {
for chunk in chunks.iter_mut() {
if fragment_ids.contains(&chunk.fragment_id) {
chunk.content = None;
}
}
}
if chunks.iter().all(|c| c.content.is_none()) {
trace!(
"[Embedding] skip generating embeddings for collab: {}",
record.object_id
);
continue;
}
let result = indexer.embed(&embedder, chunks).await;
match result {
Ok(chunks) => {
let record = EmbeddingRecord {
workspace_id: record.workspace_id,
object_id: record.object_id,
chunks,
};
if let Err(err) = write_embedding_tx.send(record) {
error!("Failed to send embedding record: {}", err);
}
},
Err(err) => {
error!(
"[Embedding] Failed to create embeddings content for collab: {}, error:{}",
record.object_id, err
);
},
}
},
Err(err) => {
warn!(
"Failed to create embedded chunks for collab: {}, error:{}",
record.object_id, err
);
continue;
},
}
}
}
},
Err(err) => error!("[Embedding] Failed to create embedder: {}", err),
}
}
}
}
info!("spawn_generate_embeddings exited");
}
@@ -0,0 +1,269 @@
use crate::embeddings::document_indexer::split_text_into_chunks;
use crate::embeddings::embedder::{Embedder, OllamaEmbedder};
use crate::embeddings::indexer::{EmbeddingModel, IndexerProvider};
use async_trait::async_trait;
use flowy_ai_pub::cloud::CollabType;
use flowy_ai_pub::entities::{RAG_IDS, SOURCE_ID};
use flowy_error::{FlowyError, FlowyResult};
use flowy_sqlite_vec::db::VectorSqliteDB;
use flowy_sqlite_vec::entities::SqliteEmbeddedDocument;
use futures::stream::{self, StreamExt};
use langchain_rust::llm::client::OllamaClient;
use langchain_rust::{
schemas::Document,
vectorstore::{VecStoreOptions, VectorStore},
};
use ollama_rs::generation::embeddings::request::{EmbeddingsInput, GenerateEmbeddingsRequest};
use serde_json::Value;
use std::collections::HashMap;
use std::error::Error;
use std::sync::{Arc, Weak};
use tracing::{error, trace, warn};
use uuid::Uuid;
#[derive(Clone)]
pub struct SqliteVectorStore {
ollama: Weak<OllamaClient>,
vector_db: Weak<VectorSqliteDB>,
indexer_provider: Arc<IndexerProvider>,
}
impl SqliteVectorStore {
pub fn new(ollama: Weak<OllamaClient>, vector_db: Weak<VectorSqliteDB>) -> Self {
Self {
ollama,
vector_db,
indexer_provider: IndexerProvider::new(),
}
}
pub(crate) fn create_embedder(&self) -> Result<Embedder, FlowyError> {
let ollama = self
.ollama
.upgrade()
.ok_or_else(|| FlowyError::internal().with_context("Ollama reference was dropped"))?;
let embedder = Embedder::Ollama(OllamaEmbedder { ollama });
Ok(embedder)
}
pub(crate) async fn select_all_embedded_documents(
&self,
workspace_id: &str,
rag_ids: &[String],
) -> FlowyResult<Vec<SqliteEmbeddedDocument>> {
// Get the vector database
let vector_db = match self.vector_db.upgrade() {
Some(db) => db,
None => return Err(FlowyError::internal().with_context("Vector database not initialized")),
};
vector_db
.select_all_embedded_documents(workspace_id, rag_ids)
.await
.map_err(|err| {
FlowyError::internal().with_context(format!("Failed to select embedded documents: {}", err))
})
}
}
#[async_trait]
impl VectorStore for SqliteVectorStore {
type Options = VecStoreOptions<Value>;
async fn add_documents(
&self,
docs: &[Document],
_opt: &Self::Options,
) -> Result<Vec<String>, Box<dyn Error>> {
let vector_db = match self.vector_db.upgrade() {
Some(db) => db,
None => return Err("Vector database not initialized".into()),
};
let embedder = self.create_embedder()?;
let indexer = self
.indexer_provider
.indexer_for(CollabType::Document)
.ok_or_else(|| Box::<dyn Error>::from("Failed to get indexer for Document"))?;
// Parse documents and filter out invalid ones early
let documents = docs
.iter()
.filter_map(|v| {
let workspace_id = match v.metadata.get("workspace_id") {
Some(value) => value.as_str().and_then(|s| Uuid::parse_str(s).ok()),
None => None,
}?;
let object_id = match v.metadata.get(SOURCE_ID) {
Some(value) => value.as_str().and_then(|s| Uuid::parse_str(s).ok()),
None => None,
}?;
Some((workspace_id, object_id, v.page_content.clone()))
})
.collect::<Vec<(Uuid, Uuid, String)>>();
let concurrency_limit = 4;
let document_ids = stream::iter(documents)
.map(|(workspace_id, object_id, paragraph)| {
// Clone values that need to be moved into the async block
let object_id_str = object_id.to_string();
let workspace_id_str = workspace_id.to_string();
let vector_db_clone = vector_db.clone();
let embedder_clone = embedder.clone();
let indexer_clone = indexer.clone();
async move {
let chunks_result = split_text_into_chunks(
&object_id_str,
vec![paragraph],
EmbeddingModel::NomicEmbedText,
2000,
200,
);
match chunks_result {
Ok(chunks) => match indexer_clone.embed(&embedder_clone, chunks).await {
Ok(chunks) => {
if let Err(e) = vector_db_clone
.upsert_collabs_embeddings(&workspace_id_str, &object_id_str, chunks)
.await
{
error!(
"[Embedding] Failed to upsert document `{}`: {}",
object_id_str, e
);
return None;
}
Some(object_id_str)
},
Err(err) => {
error!(
"[Embedding] Failed to embed document `{}`: {}",
object_id_str, err
);
None
},
},
Err(err) => {
error!(
"[Embedding] Failed to split document `{}`: {}",
object_id_str, err
);
None
},
}
}
})
.buffer_unordered(concurrency_limit)
.filter_map(|result| async move { result })
.collect()
.await;
Ok(document_ids)
}
async fn similarity_search(
&self,
query: &str,
limit: usize,
opt: &Self::Options,
) -> Result<Vec<Document>, Box<dyn Error>> {
// Extract rag_ids from filters
let rag_ids = opt
.filters
.as_ref()
.and_then(|filters| filters.get(RAG_IDS))
.and_then(|value| value.as_array())
.map(|array| {
array
.iter()
.filter_map(|item| item.as_str().map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
// Extract workspace_id from filters
let workspace_id = opt
.filters
.as_ref()
.and_then(|filters| filters.get("workspace_id"))
.and_then(|value| value.as_str())
.and_then(|s| Uuid::parse_str(s).ok());
// Return empty result if workspace_id is missing
let workspace_id = match workspace_id {
Some(id) => id.to_string(),
None => {
warn!("[VectorStore] Missing workspace_id in filters. Returning empty result.");
return Ok(Vec::new());
},
};
// Get the vector database
let vector_db = match self.vector_db.upgrade() {
Some(db) => db,
None => return Err("Vector database not initialized".into()),
};
// Create embedder and generate embedding for query
let embedder = self.create_embedder()?;
let request = GenerateEmbeddingsRequest::new(
embedder.model().name().to_string(),
EmbeddingsInput::Single(query.to_string()),
);
let embedding = match embedder.embed(request).await {
Ok(result) => result.embeddings,
Err(e) => return Err(Box::new(e)),
};
if embedding.is_empty() {
return Ok(Vec::new());
}
let score_threshold = opt.score_threshold.unwrap_or(0.4);
debug_assert!(embedding.len() == 1);
let query_embedding = embedding.first().unwrap();
// Perform similarity search in the database
let results = vector_db
.search_with_score(
&workspace_id,
&rag_ids,
query_embedding,
limit as i32,
score_threshold,
)
.await?;
trace!(
"[VectorStore] Found {} results for query:{}, rag_ids: {:?}, score_threshold: {}",
results.len(),
query,
rag_ids,
score_threshold
);
// Convert results to Documents
let documents = results
.into_iter()
.map(|result| {
let mut metadata = HashMap::new();
if let Some(map) = result.metadata.as_ref().and_then(|v| v.as_object()) {
for (key, value) in map {
metadata.insert(key.clone(), value.clone());
}
}
Document::new(result.content).with_metadata(metadata)
})
.collect();
Ok(documents)
}
}
+1 -7
View File
@@ -590,13 +590,7 @@ pub struct LocalAIPB {
pub lack_of_resource: Option<LackOfAIResourcePB>,
#[pb(index = 3)]
pub state: RunningStatePB,
#[pb(index = 4, one_of)]
pub plugin_version: Option<String>,
#[pb(index = 5)]
pub plugin_downloaded: bool,
pub is_ready: bool,
}
#[derive(Default, ProtoBuf, Validate, Clone, Debug)]
@@ -193,7 +193,7 @@ pub(crate) async fn start_complete_text_handler(
let data = data.into_inner();
let ai_manager = upgrade_ai_manager(ai_manager)?;
let ai_model = ai_manager.get_active_model(&data.object_id).await;
let task = tools.create_complete_task(data, Some(ai_model)).await?;
let task = tools.create_complete_task(data, ai_model).await?;
data_result_ok(task)
}
@@ -313,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)
}
@@ -346,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)
}
+48
View File
@@ -10,6 +10,53 @@ 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)]
@@ -17,4 +64,5 @@ mod model_select_test;
pub mod notification;
pub mod offline;
mod protobuf;
mod search;
mod stream_message;
@@ -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(&params.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)))
}
}
@@ -0,0 +1,52 @@
use crate::local_ai::chat::llm::LLMOllama;
use flowy_error::{FlowyError, FlowyResult};
use langchain_rust::language_models::llm::LLM;
use langchain_rust::schemas::Message;
use ollama_rs::generation::parameters::{FormatType, JsonStructure};
use schemars::JsonSchema;
use serde::Deserialize;
const SUMMARIZE_SYSTEM_PROMPT: &str = r#"
As an AppFlowy AI assistant, your task is to generate three medium-length, relevant, and informative questions based on the provided conversation history.
The output should only return a JSON instance that conforms to the JSON schema below.
{
"questions": [
"What are the key skills needed to tackle a black diamond slope in snowboarding?",
"How does the difficulty of black diamond trails compare across different ski resorts?",
"Can you provide tips for snowboarders preparing to try a black diamond trail for the first time?"
]
}
"#;
#[derive(Debug, Deserialize, JsonSchema)]
struct QuestionsResponse {
questions: Vec<String>,
}
pub struct RelatedQuestionChain {
llm: LLMOllama,
}
impl RelatedQuestionChain {
pub fn new(ollama: LLMOllama) -> Self {
let format = FormatType::StructuredJson(JsonStructure::new::<QuestionsResponse>());
Self {
llm: ollama.with_format(format),
}
}
pub async fn related_question(&self, question: &str) -> FlowyResult<Vec<String>> {
let messages = vec![
Message::new_system_message(SUMMARIZE_SYSTEM_PROMPT),
Message::new_human_message(question),
];
let result = self.llm.generate(&messages).await.map_err(|err| {
FlowyError::internal().with_context(format!("Error generating related questions: {}", err))
})?;
let parsed_result = serde_json::from_str::<QuestionsResponse>(&result.generation)?;
Ok(parsed_result.questions)
}
}
@@ -0,0 +1,124 @@
use crate::local_ai::chat::llm::LLMOllama;
use async_trait::async_trait;
use flowy_ai_pub::user_service::AIUserService;
use flowy_error::{FlowyError, FlowyResult};
use langchain_rust::chain::{Chain, LLMChain, LLMChainBuilder};
use langchain_rust::schemas::{BaseMemory, Message};
use langchain_rust::{fmt_message, fmt_placeholder, message_formatter, prompt_args};
use std::sync::{Arc, Weak};
use tokio::sync::{Mutex, RwLock};
pub struct SummaryMemory {
messages: RwLock<Vec<Message>>,
chain: SummaryMessageChain,
current_summary: String,
#[allow(dead_code)]
user_service: Option<Weak<dyn AIUserService>>,
}
impl SummaryMemory {
pub async fn new(
llm: LLMOllama,
summary: String,
user_service: Option<Weak<dyn AIUserService>>,
) -> FlowyResult<Self> {
let chain = SummaryMessageChain::new(llm)?;
Ok(Self {
chain,
messages: RwLock::new(Vec::new()),
current_summary: summary,
user_service,
})
}
#[allow(dead_code)]
pub async fn generate_summary(&mut self) -> FlowyResult<String> {
let summary = self
.chain
.generate_summary(self.messages.read().await.as_ref(), &self.current_summary)
.await?;
self.current_summary = summary.clone();
Ok(summary)
}
}
impl From<SummaryMemory> for Arc<dyn BaseMemory> {
fn from(memory: SummaryMemory) -> Self {
Arc::new(memory)
}
}
impl From<SummaryMemory> for Arc<Mutex<dyn BaseMemory>> {
fn from(memory: SummaryMemory) -> Self {
Arc::new(Mutex::new(memory))
}
}
#[async_trait]
impl BaseMemory for SummaryMemory {
async fn messages(&self) -> Vec<Message> {
self.messages.read().await.clone()
}
async fn add_message(&mut self, message: Message) {
self.messages.write().await.push(message);
}
async fn clear(&mut self) {
self.messages.write().await.clear();
}
}
fn message_to_string_with_role(messages: &[Message]) -> Vec<String> {
messages
.iter()
.map(|msg| format!("{}: {}", msg.message_type.to_string(), msg.content))
.collect()
}
const SUMMARY_SYSTEM_PROMPT: &str = r#"
You are AppFlowy AI, tasked with progressively summarizing the lines of conversation provided. With each new line, you add to the previous summary and return an updated summary.
Current summary:
{current_summary}
New lines of conversation:
{new_lines}
New summary:
"#;
struct SummaryMessageChain {
chain: LLMChain,
}
impl SummaryMessageChain {
pub fn new(llm: LLMOllama) -> FlowyResult<Self> {
let prompt = message_formatter![
fmt_message!(Message::new_system_message(SUMMARY_SYSTEM_PROMPT)),
fmt_placeholder!("current_summary"),
fmt_placeholder!("new_lines"),
];
let chain = LLMChainBuilder::new()
.prompt(prompt)
.llm(llm)
.build()
.map_err(|err| FlowyError::internal().with_context(err))?;
Ok(Self { chain })
}
pub async fn generate_summary(
&self,
messages: &[Message],
current_summary: &str,
) -> FlowyResult<String> {
let new_lines = message_to_string_with_role(messages);
let resp = self
.chain
.invoke(prompt_args! {
"current_summary" => current_summary,
"new_lines" => new_lines,
})
.await
.map_err(|err| FlowyError::internal().with_context(err))?;
Ok(resp)
}
}
@@ -0,0 +1,143 @@
use crate::local_ai::chat::llm::LLMOllama;
use crate::local_ai::completion::stream_interpreter::stream_interpreter_for_completion;
use crate::local_ai::completion::writer::{
AskAiWriter, CompletionWriterContext, ContinueWriteWriter, CustomWriter, ExplainWriter,
ImproveWritingWriter, MakeLongerWriter, SpellingGrammarWriter, SummaryWriter,
};
use crate::local_ai::prompt::{format_prompt, history_prompt};
use flowy_ai_pub::cloud::{
CompletionMetadata, CompletionStreamValue, CompletionType, CustomPrompt, ResponseFormat,
};
use flowy_error::FlowyError;
use futures_util::StreamExt;
use langchain_rust::language_models::llm::LLM;
use langchain_rust::schemas::Message;
use std::collections::HashMap;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tracing::error;
pub trait CompletionWriter: Send + Sync {
fn system_message(&self) -> String;
fn reformat_human_input(&self, content: &str) -> String {
content.to_string()
}
fn reasoning_system_message(&self) -> String {
self.system_message()
}
fn support_format(&self) -> bool;
fn support_reasoning(&self) -> bool {
false
}
}
#[derive(Default)]
pub struct WriterConfig {
pub custom_reasoning_system_prompt: HashMap<CompletionType, String>,
}
pub struct CompletionChain {
ollama: LLMOllama,
}
impl CompletionChain {
pub fn new(ollama: LLMOllama) -> Self {
Self { ollama }
}
pub async fn complete(
&self,
text: &str,
ty: CompletionType,
format: ResponseFormat,
mut metadata: Option<CompletionMetadata>,
) -> Result<ReceiverStream<anyhow::Result<CompletionStreamValue, FlowyError>>, FlowyError> {
let custom_prompt = metadata.as_ref().and_then(|m| m.custom_prompt.clone());
let writer = writer_from_type(&ty, &WriterConfig::default(), custom_prompt);
// Build messages
let mut messages = vec![];
let system_msg = if writer.support_reasoning() {
writer.reasoning_system_message()
} else {
writer.system_message()
};
messages.push(Message::new_system_message(system_msg));
messages.push(
if writer.support_format() {
format_prompt(&format)
} else {
Message::new_system_message(
"Respond naturally without formal phrases like 'Certainly', 'Absolutely', or 'Sure'. Keep the tone conversational, clear, and professional"
)
}
);
if let Some(history) = metadata.as_mut().and_then(|m| m.completion_history.take()) {
messages.extend(history_prompt(Some(history)));
}
messages.push(Message::new_human_message(text));
let raw_stream = self
.ollama
.stream(&messages)
.await
.map_err(|e| FlowyError::local_ai().with_context(e))?;
let stream = stream_interpreter_for_completion(raw_stream, ty)
.map(|res| res.map_err(|e| FlowyError::local_ai().with_context(e)))
.boxed();
// Spawn the forwarding task
let (tx, rx) = mpsc::channel(32);
spawn_stream(stream, tx);
Ok(ReceiverStream::new(rx))
}
}
fn spawn_stream<S>(
mut stream: S,
tx: mpsc::Sender<anyhow::Result<CompletionStreamValue, FlowyError>>,
) where
S: tokio_stream::Stream<Item = anyhow::Result<CompletionStreamValue, FlowyError>>
+ Unpin
+ Send
+ 'static,
{
tokio::spawn(async move {
while let Some(item) = stream.next().await {
if tx.send(item).await.is_err() {
error!("[AICompletion] channel closed");
break;
}
}
});
}
/// Construct the appropriate writer based on type
pub fn writer_from_type(
complete_type: &CompletionType,
writer_config: &WriterConfig,
custom_prompt: Option<CustomPrompt>,
) -> Box<dyn CompletionWriter> {
let context = CompletionWriterContext::new(
writer_config
.custom_reasoning_system_prompt
.get(complete_type)
.cloned(),
);
match complete_type {
CompletionType::CustomPrompt => match custom_prompt {
Some(p) => Box::new(CustomWriter::new(p, context)),
None => Box::new(AskAiWriter::new(context)),
},
CompletionType::ImproveWriting => Box::new(ImproveWritingWriter::new(context)),
CompletionType::SpellingAndGrammar => Box::new(SpellingGrammarWriter::new(context)),
CompletionType::MakeShorter => Box::new(SummaryWriter::new(context)),
CompletionType::MakeLonger => Box::new(MakeLongerWriter::new(context)),
CompletionType::ContinueWriting => Box::new(ContinueWriteWriter::new(context)),
CompletionType::Explain => Box::new(ExplainWriter::new(context)),
CompletionType::AskAI => Box::new(AskAiWriter::new(context)),
}
}
@@ -0,0 +1 @@
@@ -0,0 +1,4 @@
pub mod chain;
pub mod impls;
pub mod stream_interpreter;
pub mod writer;
@@ -0,0 +1,520 @@
use flowy_ai_pub::cloud::{CompletionStreamValue, CompletionType};
use futures_util::{stream, stream::BoxStream, Stream, StreamExt};
use langchain_rust::language_models::LLMError;
use langchain_rust::schemas::StreamData;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tracing::{debug, warn};
pub fn stream_interpreter_for_completion(
input_stream: BoxStream<'static, Result<StreamData, LLMError>>,
completion_type: CompletionType,
) -> BoxStream<'static, Result<CompletionStreamValue, LLMError>> {
match completion_type {
CompletionType::ImproveWriting | CompletionType::SpellingAndGrammar => {
interpret_completion_stream(input_stream, completion_type)
},
_ => input_stream
.map(|res| res.map(map_response_to_completion_value))
.boxed(),
}
}
fn map_response_to_completion_value(data: StreamData) -> CompletionStreamValue {
if let Some(content) = data.value.get("content") {
return CompletionStreamValue::Answer {
value: content.as_str().unwrap_or_default().to_string(),
};
}
if let Some(content) = data.value.get("comment") {
return CompletionStreamValue::Comment {
value: content.as_str().unwrap_or_default().to_string(),
};
}
warn!("[AICompletion] unexpected stream data: {:?}", data);
CompletionStreamValue::Answer {
value: data.content,
}
}
/// Creates a mapping for the ImproveWriting completion type
fn create_improve_writing_mapping() -> HashMap<String, String> {
let mut mapping = HashMap::new();
// Support multiple variants of the tag names
mapping.insert("Improved".to_string(), "content".to_string());
mapping.insert("improved".to_string(), "content".to_string());
mapping.insert("IMPROVED".to_string(), "content".to_string());
mapping.insert("Explanation".to_string(), "comment".to_string());
mapping.insert("explanation".to_string(), "comment".to_string());
mapping
}
/// Creates a mapping for the SpellingAndGrammar completion type
fn create_spelling_grammar_mapping() -> HashMap<String, String> {
let mut mapping = HashMap::new();
// Support multiple variants of the tag names
mapping.insert("Corrected".to_string(), "content".to_string());
mapping.insert("corrected".to_string(), "content".to_string());
mapping.insert("Correct".to_string(), "content".to_string());
mapping.insert("correct".to_string(), "content".to_string());
mapping.insert("CORRECTED".to_string(), "content".to_string());
mapping.insert("Corrected:".to_string(), "content".to_string());
mapping.insert("Explanation".to_string(), "comment".to_string());
mapping.insert("explanation".to_string(), "comment".to_string());
mapping
}
/// Process a stream of completion data with the interpreter
///
/// This function takes an input stream and a completion type, and returns a new stream
/// where each item has been processed by the StreamInterpreter.
///
/// The interpreter extracts content between specific XML-like tags based on the mapping
/// for the given completion type.
pub fn interpret_completion_stream<S>(
stream: S,
completion_type: CompletionType,
) -> BoxStream<'static, Result<CompletionStreamValue, LLMError>>
where
S: Stream<Item = Result<StreamData, LLMError>> + Send + 'static,
{
let mapping = match completion_type {
CompletionType::ImproveWriting => create_improve_writing_mapping(),
CompletionType::SpellingAndGrammar => create_spelling_grammar_mapping(),
_ => HashMap::new(),
};
if mapping.is_empty() {
// If no mapping exists for this completion type, return the original stream
return stream
.map(|res| res.map(map_response_to_completion_value))
.boxed();
}
// Create a shared interpreter that will be used across all stream items
let interpreter = Arc::new(Mutex::new(StreamInterpreter::new()));
let section_mapping = Arc::new(mapping);
// First, process all stream items
let interpreter_clone = interpreter.clone();
let section_mapping_clone = section_mapping.clone();
let processed_stream = stream.filter_map(move |result| {
let interpreter = interpreter_clone.clone();
let mapping = section_mapping_clone.clone();
async move {
match result {
Ok(data) => {
// Lock the interpreter only when needed
let mut interpreter_guard = interpreter.lock().unwrap();
interpreter_guard
.post_process_output(&data.value, &mapping)
.map(completion_stream_value_from_value)
},
Err(err) => Some(Err(err)),
}
}
});
// Then, handle any remaining data after the stream is done
let final_stream = stream::once(async move {
let mut interpreter_guard = interpreter.lock().unwrap();
interpreter_guard
.consume_post_process_pending(&section_mapping)
.map(completion_stream_value_from_value)
})
.filter_map(|x| async { x });
// Combine the processed stream with the final stream
Box::pin(processed_stream.chain(final_stream))
}
fn completion_stream_value_from_value(value: Value) -> Result<CompletionStreamValue, LLMError> {
// trace!("[AICompletion] processing stream value: {:?}", value);
if let Some(content) = value.get("content") {
return Ok(CompletionStreamValue::Answer {
value: content.as_str().unwrap_or_default().to_string(),
});
}
if let Some(comment) = value.get("comment") {
return Ok(CompletionStreamValue::Comment {
value: comment.as_str().unwrap_or_default().to_string(),
});
}
Err(LLMError::ContentNotFound(format!(
"Unexpected stream data: {:?}",
value
)))
}
struct StreamInterpreter {
section_buffer: String,
section: Option<String>,
section_is_started: HashMap<String, bool>,
start_tag: Option<String>,
end_tag: Option<String>,
end_tag_len: usize,
check_tag_len: usize,
// Track tag style for matching appropriate end tags
tag_style: Option<TagStyle>,
}
// Define supported tag styles
#[derive(Debug, Clone, PartialEq)]
enum TagStyle {
Xml, // <Tag>...</Tag>
Markdown, // **Tag**...**Tag**
#[allow(dead_code)]
MixedFormat, // **Tag**...</Tag> or other mixed styles
}
impl StreamInterpreter {
/// Creates a new TagProcessingMixin.
pub fn new() -> Self {
StreamInterpreter {
section_buffer: String::new(),
section: None,
section_is_started: Default::default(),
start_tag: None,
end_tag: None,
end_tag_len: 0,
check_tag_len: 0,
tag_style: None,
}
}
/// Creates a Value object from content if it's not empty and the section name exists in the mapping
fn create_value_from_content(
&mut self,
mut content: String,
section_name: &str,
section_mapping: &HashMap<String, String>,
) -> Option<Value> {
if self
.section_is_started
.get(section_name)
.cloned()
.unwrap_or(true)
{
content = content.trim_start_matches('\n').to_string();
self
.section_is_started
.insert(section_name.to_string(), true);
}
if content.is_empty() {
return None;
}
let output_key = section_mapping.get(section_name)?;
// trace!(
// "[AICompletion] create value from content: {}, key: {}",
// content,
// output_key
// );
Some(json!({output_key: content}))
}
/// Finds the earliest start tag in the buffer
///
/// Returns a tuple containing the position, name, tag, style, and length of the earliest tag,
/// or None if no tags are found.
fn find_earliest_start_tag(
&self,
buffer: &str,
section_mapping: &HashMap<String, String>,
) -> Option<(usize, String, String, TagStyle, usize)> {
let mut starts: Vec<(usize, String, String, TagStyle, usize)> = Vec::new();
for section_name in section_mapping.keys() {
if let Some((idx, tag, style, tag_len)) = self.detect_tag(buffer, section_name) {
starts.push((idx, section_name.clone(), tag, style, tag_len));
}
}
if starts.is_empty() {
return None;
}
// Pick the earliest start tag
starts.sort_by_key(|&(idx, _, _, _, _)| idx);
Some(starts[0].clone())
}
/// Detects a tag in multiple formats
fn detect_tag(&self, text: &str, tag_name: &str) -> Option<(usize, String, TagStyle, usize)> {
// Check XML style: <Tag>
let xml_tag = format!("<{}>", tag_name);
if let Some(pos) = text.find(&xml_tag) {
let xml_tag_len = xml_tag.len();
return Some((pos, xml_tag, TagStyle::Xml, xml_tag_len));
}
// Check Markdown style: **Tag**
let md_tag = format!("**{}**", tag_name);
if let Some(pos) = text.find(&md_tag) {
let md_tag_len = md_tag.len();
return Some((pos, md_tag, TagStyle::Markdown, md_tag_len));
}
// Check case-insensitive XML style
let lowercase_text = text.to_lowercase();
let lowercase_xml_tag = format!("<{}>", tag_name.to_lowercase());
if let Some(pos) = lowercase_text.find(&lowercase_xml_tag) {
let xml_tag_len = lowercase_xml_tag.len();
return Some((pos, xml_tag, TagStyle::Xml, xml_tag_len));
}
// Check case-insensitive Markdown style
let lowercase_md_tag = format!("**{}**", tag_name.to_lowercase());
if let Some(pos) = lowercase_text.find(&lowercase_md_tag) {
let md_tag_len = lowercase_md_tag.len();
// Use the original case for the returned tag
let original_md_tag = format!("**{}**", tag_name);
return Some((pos, original_md_tag, TagStyle::Markdown, md_tag_len));
}
None
}
/// Generates appropriate end tag based on tag style and name
fn generate_end_tag(&self, tag_name: &str, style: &TagStyle) -> (String, usize) {
match style {
TagStyle::Xml => {
let tag = format!("</{}>", tag_name);
(tag.clone(), tag.len())
},
TagStyle::Markdown => {
let tag = format!("**{}**", tag_name);
(tag.to_string(), tag.len())
},
TagStyle::MixedFormat => {
// For mixed format, check both closing styles
let xml_tag = format!("</{}>", tag_name);
(xml_tag.clone(), xml_tag.len())
},
}
}
/// Find end tag in text considering multiple formats
fn find_end_tag(&self, text: &str, tag_name: &str, style: &TagStyle) -> Option<(usize, usize)> {
match style {
TagStyle::Xml => {
let end_tag = format!("</{}>", tag_name);
if let Some(pos) = text.find(&end_tag) {
return Some((pos, end_tag.len()));
}
// Try case-insensitive match as fallback
let lowercase_text = text.to_lowercase();
let lowercase_end_tag = format!("</{}>", tag_name.to_lowercase());
if let Some(pos) = lowercase_text.find(&lowercase_end_tag) {
return Some((pos, end_tag.len()));
}
},
TagStyle::Markdown => {
let end_tag = format!("**{}**", tag_name);
if let Some(pos) = text.find(&end_tag) {
return Some((pos, end_tag.len()));
}
},
TagStyle::MixedFormat => {
// Check both styles for mixed format
let xml_end = format!("</{}>", tag_name);
let md_end = format!("**{}**", tag_name);
let xml_pos = text.find(&xml_end).map(|p| (p, xml_end.len()));
let md_pos = text.find(&md_end).map(|p| (p, md_end.len()));
// Return the first one found
if let Some(xml_match) = xml_pos {
if let Some(md_match) = md_pos {
if xml_match.0 < md_match.0 {
return Some(xml_match);
} else {
return Some(md_match);
}
} else {
return Some(xml_match);
}
} else {
return md_pos;
}
},
}
None
}
/// Processes an input JSON object, extracting content between tags based on a mapping.
///
/// - `input`: JSON object with a key "content" containing the text chunk.
/// - `section_mapping`: maps section names to output keys.
///
/// Returns an optional JSON object containing the extracted section content.
pub fn post_process_output(
&mut self,
input: &Value,
section_mapping: &HashMap<String, String>,
) -> Option<Value> {
// Only process JSON objects
let obj = input.as_object()?;
// Extract the new text chunk
let new_text = obj
.get("message")
.and_then(|msg| msg.as_object())
.and_then(|msg_obj| msg_obj.get("content"))
.and_then(Value::as_str)?;
// Append to the buffer
debug!("new_text: {}", new_text);
self.section_buffer.push_str(new_text);
// If not currently inside a section, look for a start tag
if self.section.is_none() {
let earliest_tag = self.find_earliest_start_tag(&self.section_buffer, section_mapping);
let (start_pos, selected_name, selected_tag, selected_style, tag_len) = earliest_tag?;
self.section = Some(selected_name.clone());
self.start_tag = Some(selected_tag.clone());
self.tag_style = Some(selected_style.clone());
let (end_tag, end_tag_len) = self.generate_end_tag(&selected_name, &selected_style);
self.end_tag = Some(end_tag);
self.end_tag_len = end_tag_len;
self.check_tag_len = self.end_tag_len * 2;
let start = start_pos + tag_len;
if start < self.section_buffer.len() {
let after_tag = &self.section_buffer[start..];
if after_tag.starts_with('\n') && start + 1 < self.section_buffer.len() {
self.section_buffer = self.section_buffer[(start + 1)..].to_string();
} else {
self.section_buffer = self.section_buffer[start..].to_string();
}
} else {
self.section_buffer = String::new();
}
return None;
}
// We're inside a section: look for the end tag
let (section_name, tag_style) = match (self.section.clone(), &self.tag_style) {
(Some(name), Some(style)) => (name, style),
_ => return None,
};
// If the buffer is too short, return None
if self.section_buffer.len() < self.check_tag_len {
return None;
}
match self.find_end_tag(&self.section_buffer, &section_name, tag_style) {
None => {
let earliest_tag = self.find_earliest_start_tag(&self.section_buffer, section_mapping);
match earliest_tag {
Some((start_pos, selected_name, selected_tag, selected_style, tag_len)) => {
if selected_name != section_name {
// If it's a new section, process the current one
let content = self.section_buffer[..start_pos].to_string();
self.section_buffer = self.section_buffer[start_pos + tag_len..].to_string();
self.section = Some(selected_name.clone());
self.tag_style = Some(selected_style.clone());
self.start_tag = Some(selected_tag.clone());
let (end_tag, end_tag_len) = self.generate_end_tag(&selected_name, &selected_style);
self.end_tag = Some(end_tag);
self.end_tag_len = end_tag_len;
let content = content.trim_end_matches('\n');
self.create_value_from_content(content.to_string(), &section_name, section_mapping)
} else {
let content = std::mem::take(&mut self.section_buffer);
self.create_value_from_content(content, &section_name, section_mapping)
}
},
None => {
// do not take all the section buffer, because the current buffer might contain
// a uncompleted tag
let content = self.section_buffer[..section_name.len()].to_string();
self.section_buffer = self.section_buffer[section_name.len()..].to_string();
self.create_value_from_content(content, &section_name, section_mapping)
},
}
},
Some((end_pos, end_len)) => {
let content = self.section_buffer[..end_pos].to_string();
self.section_buffer = self.section_buffer[end_pos + end_len..].to_string();
self.section = None;
self.start_tag = None;
let content = content.trim_end_matches('\n');
self.create_value_from_content(content.to_string(), &section_name, section_mapping)
},
}
}
/// Consume any remaining pending content after processing is complete.
pub fn consume_post_process_pending(
&mut self,
section_mapping: &HashMap<String, String>,
) -> Option<Value> {
if !self.section_buffer.is_empty() {
let earliest_tag = self.find_earliest_start_tag(&self.section_buffer, section_mapping);
if let Some((start_pos, selected_name, _, selected_style, tag_len)) = earliest_tag {
// Set up the new section
self.section = Some(selected_name.clone());
self.tag_style = Some(selected_style.clone());
// Skip past the start tag
let start = start_pos + tag_len;
if start < self.section_buffer.len() {
self.section_buffer = self.section_buffer[start..].to_string();
return match self.find_end_tag(&self.section_buffer, &selected_name, &selected_style) {
None => {
let trimmed_content = self.section_buffer.to_string();
self.create_value_from_content(trimmed_content, &selected_name, section_mapping)
},
Some((end_pos, end_len)) => {
let content = self.section_buffer[..end_pos].to_string();
self.section_buffer = self.section_buffer[end_pos + end_len..].to_string();
self.section = None;
self.start_tag = None;
self.create_value_from_content(
content.trim_end_matches("\n").to_string(),
&selected_name,
section_mapping,
)
},
};
}
}
}
match self.find_end_tag(
&self.section_buffer,
self.section.as_ref()?,
self.tag_style.as_ref()?,
) {
None => {
let content = std::mem::take(&mut self.section_buffer)
.trim_end_matches('\n')
.to_string();
let section_name = self.section.clone()?;
self.create_value_from_content(content, &section_name, section_mapping)
},
Some((end_pos, _)) => {
let content = self.section_buffer[..end_pos]
.trim_end_matches('\n')
.to_string();
let section_name = self.section.clone()?;
self.create_value_from_content(content, &section_name, section_mapping)
},
}
}
}
@@ -0,0 +1,280 @@
use crate::local_ai::completion::chain::CompletionWriter;
use flowy_ai_pub::cloud::CustomPrompt;
pub struct CompletionWriterContext {
custom_system_reasoning_prompt: Option<String>,
}
impl CompletionWriterContext {
pub fn new(custom_system_reasoning_prompt: Option<String>) -> Self {
CompletionWriterContext {
custom_system_reasoning_prompt,
}
}
}
// ImproveWritingWriter implementation
pub struct ImproveWritingWriter {
context: CompletionWriterContext,
}
impl ImproveWritingWriter {
pub fn new(context: CompletionWriterContext) -> Self {
ImproveWritingWriter { context }
}
}
impl CompletionWriter for ImproveWritingWriter {
fn system_message(&self) -> String {
r#"You are an expert in refining written material, making it engaging, clear, and effective.
Ensure that your final output maintains the same format and language as the human input."#
.to_string()
}
fn reasoning_system_message(&self) -> String {
if let Some(prompt) = &self.context.custom_system_reasoning_prompt {
return prompt.clone();
}
r#"You are an expert writing coach specializing in making text more engaging, clear, and impactful
while preserving the original meaning and intent. Your task is to thoughtfully enhance any text I submit.
1. Provide an improved version of the text
2. Explain what was enhanced:
- Identify specific areas of improvement (clarity, conciseness, word choice, etc.)
- Use markdown formatting (**bold** or `code`) to highlight modified sections
- If the text already appears optimal, acknowledge its strengths and return it unchanged
3.Maintain the original tone, style, and level of formality
4. Preserve the author's unique voice and perspective
5. Keep specialized terminology and technical language intact
6. Focus on enhancing readability and impact without changing the core message
Format your response like this:
<Improved>[The enhanced version of the text]</Improved>
<Explanation>[Detailed explanation of each improvement made, with before/after comparisons highlighted]</Explanation>
Example:
<Improved>Original text with improvements</Improved>
<Explanation>Original text with explanations of improvements</Explanation>
"#.to_string()
}
fn support_format(&self) -> bool {
false
}
fn support_reasoning(&self) -> bool {
true
}
}
// SpellingGrammarWriter implementation
pub struct SpellingGrammarWriter {
context: CompletionWriterContext,
}
impl SpellingGrammarWriter {
pub fn new(context: CompletionWriterContext) -> Self {
SpellingGrammarWriter { context }
}
}
impl CompletionWriter for SpellingGrammarWriter {
fn system_message(&self) -> String {
r#"You excel at identifying and correcting grammatical and spelling errors, ensuring accuracy and professionalism in the text. Provide only the final corrected version without additional commentary, and ensure that your final output maintains the same format and language as the user input."#.to_string()
}
fn reasoning_system_message(&self) -> String {
if let Some(prompt) = &self.context.custom_system_reasoning_prompt {
return prompt.clone();
}
r#"You excel at identifying and correcting grammatical and spelling errors while maintaining the original text's meaning and style. Your task is to provide corrections and explanations for any text I submit.
For each submission, please:
1. Provide the corrected version of the text
2. Explain the corrections made:
- Identify specific grammatical and spelling issues
- Use markdown formatting (**bold** or `code`) to highlight problem areas
- If no errors exist, simply confirm the text is correct
3. Preserve the original format, style, and language
Format your response like this:
<Corrected>[The fully corrected text]</Corrected>
<Explanation>[Clear explanation of each correction made, with examples highlighted]</Explanation>
Example:
<Corrected>Original text with corrections</Corrected>
<Explanation>Original text with explanations of corrections</Explanation>
"#.to_string()
}
fn support_format(&self) -> bool {
false
}
fn support_reasoning(&self) -> bool {
true
}
}
// SummaryWriter implementation
pub struct SummaryWriter {
#[allow(dead_code)]
context: CompletionWriterContext,
}
impl SummaryWriter {
pub fn new(context: CompletionWriterContext) -> Self {
SummaryWriter { context }
}
}
impl CompletionWriter for SummaryWriter {
fn system_message(&self) -> String {
r#"You are skilled at summarizing text, capturing its core message in a concise, clear manner without losing its original intent. Ensure that your final output retains the same format and language as the user input."#.to_string()
}
fn support_format(&self) -> bool {
true
}
}
// MakeLongerWriter implementation
pub struct MakeLongerWriter {
#[allow(dead_code)]
context: CompletionWriterContext,
}
impl MakeLongerWriter {
pub fn new(context: CompletionWriterContext) -> Self {
MakeLongerWriter { context }
}
}
impl CompletionWriter for MakeLongerWriter {
fn system_message(&self) -> String {
r#"You are skilled at expanding and elaborating on existing content, enriching it with additional details while maintaining the original meaning. Ensure that your final output maintains the same format and language as the user input."#.to_string()
}
fn support_format(&self) -> bool {
true
}
}
// ContinueWriteWriter implementation
pub struct ContinueWriteWriter {
#[allow(dead_code)]
context: CompletionWriterContext,
}
impl ContinueWriteWriter {
pub fn new(context: CompletionWriterContext) -> Self {
ContinueWriteWriter { context }
}
}
impl CompletionWriter for ContinueWriteWriter {
fn system_message(&self) -> String {
r#"You are the AppFlowyAI writing assistant. Treat the user's latest input as the foundational context.
Analyze its themes, style, tone, and key ideas, then extend the content with new, relevant details and
logical elaborations, ensuring coherence and consistency. Ensure that your final output maintains the
same format and language as the user input."#
.to_string()
}
fn support_format(&self) -> bool {
true
}
}
// ExplainWriter implementation
pub struct ExplainWriter {
#[allow(dead_code)]
context: CompletionWriterContext,
}
impl ExplainWriter {
pub fn new(context: CompletionWriterContext) -> Self {
ExplainWriter { context }
}
}
impl CompletionWriter for ExplainWriter {
fn system_message(&self) -> String {
r#"You are an expert educator and communicator who excels at making complex ideas accessible. Your task is
to analyze and explain any concept or text I share with you.
When I provide text, please:
1. Identify the core concept, idea, or message
2. Break it down into clear, digestible components
3. Provide a comprehensive yet concise explanation
4. Include relevant examples, analogies, or illustrations that clarify the concept
5. Match your explanation to my level of understanding based on my query
6. Respond in the same language as my input
Your explanation should:
- Be easy to understand without oversimplifying
- Highlight key themes and important details
- Connect the concept to broader knowledge when helpful
- Be organized in a logical progression
- Preserve the nuance and depth of the original idea
Focus on delivering a thoughtful explanation that would help someone truly grasp the concept rather than
just rephrasing the input."#
.to_string()
}
fn support_format(&self) -> bool {
true
}
}
// AskAiWriter implementation
pub struct AskAiWriter {
#[allow(dead_code)]
context: CompletionWriterContext,
}
impl AskAiWriter {
pub fn new(context: CompletionWriterContext) -> Self {
AskAiWriter { context }
}
}
impl CompletionWriter for AskAiWriter {
fn system_message(&self) -> String {
r#"You are AppFlowy AI, adept at understanding and answering user inquiries quickly and accurately.
Provide clear, concise responses that effectively resolve the query, and ensure your final output is in the
same language as the user input."#
.to_string()
}
fn support_format(&self) -> bool {
true
}
}
// CustomWriter implementation for user-provided prompts
pub struct CustomWriter {
#[allow(dead_code)]
context: CompletionWriterContext,
prompt: CustomPrompt,
}
impl CustomWriter {
pub fn new(prompt: CustomPrompt, context: CompletionWriterContext) -> Self {
CustomWriter { context, prompt }
}
}
impl CompletionWriter for CustomWriter {
fn system_message(&self) -> String {
self.prompt.system.clone()
}
fn support_format(&self) -> bool {
true
}
}
@@ -1,9 +1,8 @@
use crate::entities::{LocalAIPB, RunningStatePB};
use crate::entities::LocalAIPB;
use crate::local_ai::resource::{LLMResourceService, LocalAIResourceController};
use crate::notification::{
chat_notification_builder, ChatNotification, APPFLOWY_AI_NOTIFICATION_KEY,
};
use af_plugin::manager::PluginManager;
use anyhow::Error;
use flowy_error::{FlowyError, FlowyResult};
use flowy_sqlite::kv::KVStorePreferences;
@@ -11,10 +10,8 @@ use futures::Sink;
use lib_infra::async_trait::async_trait;
use std::collections::HashMap;
use crate::local_ai::chat::{LLMChatController, LLMChatInfo};
use crate::stream_message::StreamMessage;
use af_local_ai::ollama_plugin::OllamaAIPlugin;
use af_plugin::core::path::is_plugin_ready;
use af_plugin::core::plugin::RunningState;
use arc_swap::ArcSwapOption;
use flowy_ai_pub::cloud::AIModel;
use flowy_ai_pub::persistence::{
@@ -29,9 +26,7 @@ use serde::{Deserialize, Serialize};
use std::ops::Deref;
use std::path::PathBuf;
use std::sync::{Arc, Weak};
use tokio::select;
use tokio_stream::StreamExt;
use tracing::{debug, error, info, instrument, warn};
use tracing::{debug, error, info, instrument};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -54,7 +49,7 @@ impl Default for LocalAISetting {
const LOCAL_AI_SETTING_KEY: &str = "appflowy_local_ai_setting:v1";
pub struct LocalAIController {
ai_plugin: Arc<OllamaAIPlugin>,
llm_controller: LLMChatController,
resource: Arc<LocalAIResourceController>,
current_chat_id: ArcSwapOption<Uuid>,
store_preferences: Weak<KVStorePreferences>,
@@ -63,26 +58,24 @@ pub struct LocalAIController {
}
impl Deref for LocalAIController {
type Target = Arc<OllamaAIPlugin>;
type Target = LLMChatController;
fn deref(&self) -> &Self::Target {
&self.ai_plugin
&self.llm_controller
}
}
impl LocalAIController {
pub fn new(
plugin_manager: Arc<PluginManager>,
store_preferences: Weak<KVStorePreferences>,
user_service: Arc<dyn AIUserService>,
) -> Self {
debug!(
"[AI Plugin] init local ai controller, thread: {:?}",
"[Local AI] init local ai controller, thread: {:?}",
std::thread::current().id()
);
// Create the core plugin and resource controller
let local_ai = Arc::new(OllamaAIPlugin::new(plugin_manager));
let res_impl = LLMResourceServiceImpl {
store_preferences: store_preferences.clone(),
};
@@ -92,81 +85,9 @@ impl LocalAIController {
));
let ollama = ArcSwapOption::default();
let sys = get_operating_system();
if sys.is_desktop() {
let setting = local_ai_resource.get_llm_setting();
ollama.store(
Ollama::try_new(&setting.ollama_server_url)
.map(Arc::new)
.ok(),
);
// Subscribe to state changes
let mut running_state_rx = local_ai.subscribe_running_state();
let cloned_llm_res = Arc::clone(&local_ai_resource);
let cloned_store_preferences = store_preferences.clone();
let cloned_local_ai = Arc::clone(&local_ai);
let cloned_user_service = Arc::clone(&user_service);
// Spawn a background task to listen for plugin state changes
tokio::spawn(async move {
while let Some(state) = running_state_rx.next().await {
// Skip if we can't get workspace_id
let Ok(workspace_id) = cloned_user_service.workspace_id() else {
continue;
};
let key = crate::local_ai::controller::local_ai_enabled_key(&workspace_id.to_string());
info!("[AI Plugin] state: {:?}", state);
// Read whether plugin is enabled from store; default to true
if let Some(store_preferences) = cloned_store_preferences.upgrade() {
let enabled = store_preferences.get_bool(&key).unwrap_or(true);
// Only check resource status if the plugin isn't in "UnexpectedStop" and is enabled
let (plugin_downloaded, lack_of_resource) =
if !matches!(state, RunningState::UnexpectedStop { .. }) && enabled {
// Possibly check plugin readiness and resource concurrency in parallel,
// but here we do it sequentially for clarity.
let downloaded = is_plugin_ready();
let resource_lack = cloned_llm_res.get_lack_of_resource().await;
(downloaded, resource_lack)
} else {
(false, None)
};
// If plugin is running, retrieve version
let plugin_version = if matches!(state, RunningState::Running { .. }) {
match cloned_local_ai.plugin_info().await {
Ok(info) => Some(info.version),
Err(_) => None,
}
} else {
None
};
// Broadcast the new local AI state
let new_state = RunningStatePB::from(state);
chat_notification_builder(
APPFLOWY_AI_NOTIFICATION_KEY,
ChatNotification::UpdateLocalAIState,
)
.payload(LocalAIPB {
enabled,
plugin_downloaded,
lack_of_resource,
state: new_state,
plugin_version,
})
.send();
} else {
warn!("[AI Plugin] store preferences is dropped");
}
}
});
}
let llm_controller = LLMChatController::new(Arc::downgrade(&user_service));
Self {
ai_plugin: local_ai,
llm_controller,
resource: local_ai_resource,
current_chat_id: ArcSwapOption::default(),
store_preferences,
@@ -175,7 +96,7 @@ impl LocalAIController {
}
}
pub fn reload_ollama_client(&self, workspace_id: &str) {
pub async fn reload_ollama_client(&self, workspace_id: &str) {
if !self.is_enabled_on_workspace(workspace_id) {
return;
}
@@ -189,60 +110,32 @@ impl LocalAIController {
}
info!("[Local AI] reloading ollama client");
match Ollama::try_new(setting.ollama_server_url).map(Arc::new) {
match Ollama::try_new(&setting.ollama_server_url).map(Arc::new) {
Ok(new_ollama) => {
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
{
info!("[Local AI] reload ollama client successfully");
let shared = crate::embeddings::context::EmbedContext::shared();
shared.set_ollama(Some(new_ollama.clone()));
if let Some(vc) = shared.get_vector_db() {
self
.llm_controller
.initialize(Arc::downgrade(&new_ollama), Arc::downgrade(&vc))
.await;
} else {
error!("[Local AI] vector db is not initialized");
}
}
self.ollama.store(Some(new_ollama.clone()));
},
Err(err) => error!(
"failed to create ollama client: {:?}, thread: {:?}",
"[Local AI] failed to create ollama client: {:?}, thread: {:?}",
err,
std::thread::current().id()
),
}
}
#[instrument(level = "debug", skip_all)]
pub async fn observe_plugin_resource(&self) {
let sys = get_operating_system();
if !sys.is_desktop() {
return;
}
debug!(
"[AI Plugin] observer plugin state. thread: {:?}",
std::thread::current().id()
);
async fn try_init_plugin(
resource: &Arc<LocalAIResourceController>,
ai_plugin: &Arc<OllamaAIPlugin>,
) {
if let Err(err) = initialize_ai_plugin(ai_plugin, resource, None).await {
error!("[AI Plugin] failed to setup plugin: {:?}", err);
}
}
// Clone what is needed for the background task.
let resource_clone = self.resource.clone();
let ai_plugin_clone = self.ai_plugin.clone();
let mut resource_notify = self.resource.subscribe_resource_notify();
let mut app_state_watcher = self.resource.subscribe_app_state();
tokio::spawn(async move {
loop {
select! {
_ = app_state_watcher.recv() => {
info!("[AI Plugin] app state changed, try to init plugin");
try_init_plugin(&resource_clone, &ai_plugin_clone).await;
},
_ = resource_notify.recv() => {
info!("[AI Plugin] resource changed, try to init plugin");
try_init_plugin(&resource_clone, &ai_plugin_clone).await;
},
else => break,
}
}
});
}
fn upgrade_store_preferences(&self) -> FlowyResult<Arc<KVStorePreferences>> {
self
.store_preferences
@@ -250,11 +143,6 @@ impl LocalAIController {
.ok_or_else(|| FlowyError::internal().with_context("Store preferences is dropped"))
}
/// Indicate whether the local AI plugin is running.
pub fn is_running(&self) -> bool {
self.ai_plugin.get_plugin_running_state().is_running()
}
/// Indicate whether the local AI is enabled.
/// AppFlowy store the value in local storage isolated by workspace id. Each workspace can have
/// different settings.
@@ -282,51 +170,55 @@ impl LocalAIController {
}
}
pub fn get_plugin_chat_model(&self) -> Option<String> {
pub fn get_local_chat_model(&self) -> Option<String> {
if !self.is_enabled() {
return None;
}
Some(self.resource.get_llm_setting().chat_model_name)
}
pub fn open_chat(&self, chat_id: &Uuid) {
pub async fn set_chat_rag_ids(&self, chat_id: &Uuid, rag_ids: &[String]) {
if !self.is_enabled() {
return;
}
self.llm_controller.set_rag_ids(chat_id, rag_ids).await;
}
pub async fn open_chat(
&self,
workspace_id: &Uuid,
chat_id: &Uuid,
model: &str,
rag_ids: Vec<String>,
summary: String,
) -> FlowyResult<()> {
if !self.is_enabled() {
return Ok(());
}
// Only keep one chat open at a time. Since loading multiple models at the same time will cause
// memory issues.
if let Some(current_chat_id) = self.current_chat_id.load().as_ref() {
debug!("[AI Plugin] close previous chat: {}", current_chat_id);
debug!("[Local AI] close previous chat: {}", current_chat_id);
self.close_chat(current_chat_id);
}
let info = LLMChatInfo {
chat_id: *chat_id,
workspace_id: *workspace_id,
model: model.to_string(),
rag_ids,
summary,
};
self.current_chat_id.store(Some(Arc::new(*chat_id)));
let chat_id = chat_id.to_string();
let weak_ctrl = Arc::downgrade(&self.ai_plugin);
tokio::spawn(async move {
if let Some(ctrl) = weak_ctrl.upgrade() {
if let Err(err) = ctrl.create_chat(&chat_id).await {
error!("[AI Plugin] failed to open chat: {:?}", err);
}
}
});
self.llm_controller.open_chat(info).await?;
Ok(())
}
pub fn close_chat(&self, chat_id: &Uuid) {
if !self.is_running() {
return;
}
info!("[AI Plugin] notify close chat: {}", chat_id);
let weak_ctrl = Arc::downgrade(&self.ai_plugin);
let chat_id = chat_id.to_string();
tokio::spawn(async move {
if let Some(ctrl) = weak_ctrl.upgrade() {
if let Err(err) = ctrl.close_chat(&chat_id).await {
error!("[AI Plugin] failed to close chat: {:?}", err);
}
}
});
info!("[Local AI] notify close chat: {}", chat_id);
self.llm_controller.close_chat(chat_id);
}
pub fn get_local_ai_setting(&self) -> LocalAISetting {
@@ -407,7 +299,7 @@ impl LocalAIController {
pub async fn update_local_ai_setting(&self, setting: LocalAISetting) -> FlowyResult<()> {
info!(
"[AI Plugin] update local ai setting: {:?}, thread: {:?}",
"[Local AI] update local ai setting: {:?}, thread: {:?}",
setting,
std::thread::current().id()
);
@@ -417,62 +309,26 @@ impl LocalAIController {
#[instrument(level = "debug", skip_all)]
pub async fn get_local_ai_state(&self) -> LocalAIPB {
let start = std::time::Instant::now();
let enabled = self.is_enabled();
// If not enabled, return immediately.
if !enabled {
debug!(
"[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}",
start.elapsed(),
std::thread::current().id()
);
return LocalAIPB {
enabled,
plugin_downloaded: false,
state: RunningStatePB::from(RunningState::ReadyToConnect),
lack_of_resource: None,
plugin_version: None,
is_ready: self.is_ready().await,
};
}
let plugin_downloaded = is_plugin_ready();
let state = self.ai_plugin.get_plugin_running_state();
// If the plugin is running, run both requests in parallel.
// Otherwise, only fetch the resource info.
let (plugin_version, lack_of_resource) = if matches!(state, RunningState::Running { .. }) {
// Launch both futures at once
let plugin_info_fut = self.ai_plugin.plugin_info();
let resource_fut = self.resource.get_lack_of_resource();
let (plugin_info_res, resource_res) = tokio::join!(plugin_info_fut, resource_fut);
let plugin_version = plugin_info_res.ok().map(|info| info.version);
(plugin_version, resource_res)
} else {
let resource_res = self.resource.get_lack_of_resource().await;
(None, resource_res)
};
let elapsed = start.elapsed();
debug!(
"[AI Plugin] get local ai state, elapsed: {:?}, thread: {:?}",
elapsed,
std::thread::current().id()
);
let lack_of_resource = self.resource.get_lack_of_resource().await;
LocalAIPB {
enabled,
plugin_downloaded,
state: RunningStatePB::from(state),
lack_of_resource,
plugin_version,
is_ready: self.is_ready().await,
}
}
#[instrument(level = "debug", skip_all)]
pub async fn restart_plugin(&self) {
if let Err(err) = initialize_ai_plugin(&self.ai_plugin, &self.resource, None).await {
error!("[AI Plugin] failed to setup plugin: {:?}", err);
if let Err(err) = check_local_ai_resources(&self.resource, &self.llm_controller).await {
error!("[Local AI] failed to setup plugin: {:?}", err);
}
}
@@ -488,7 +344,7 @@ impl LocalAIController {
let key = local_ai_enabled_key(&workspace_id.to_string());
let store_preferences = self.upgrade_store_preferences()?;
let enabled = !store_preferences.get_bool(&key).unwrap_or(false);
tracing::trace!("[AI Plugin] toggle local ai, enabled: {}", enabled,);
tracing::trace!("[Local AI] toggle local ai, enabled: {}", enabled,);
store_preferences.set_bool(&key, enabled)?;
self.toggle_plugin(enabled).await?;
Ok(enabled)
@@ -502,7 +358,7 @@ impl LocalAIController {
// index_process_sink: &mut (impl Sink<String> + Unpin),
// ) -> FlowyResult<()> {
// if !self.is_enabled() {
// info!("[AI Plugin] local ai is disabled, skip indexing");
// info!("[Local AI] local ai is disabled, skip indexing");
// return Ok(());
// }
//
@@ -519,14 +375,14 @@ impl LocalAIController {
// );
// }
// info!(
// "[AI Plugin] embed file: {:?}, with metadata: {:?}",
// "[Local AI] embed file: {:?}, with metadata: {:?}",
// file_path, file_metadata
// );
//
// match &metadata.data.content_type {
// ContextLoader::Unknown => {
// error!(
// "[AI Plugin] unsupported content type: {:?}",
// "[Local AI] unsupported content type: {:?}",
// metadata.data.content_type
// );
// },
@@ -570,12 +426,8 @@ impl LocalAIController {
.await;
let result = self
.ai_plugin
.embed_file(
&chat_id.to_string(),
file_path,
Some(index_metadata.clone()),
)
.llm_controller
.embed_file(chat_id, file_path, Some(index_metadata.clone()))
.await;
match result {
Ok(_) => {
@@ -587,7 +439,7 @@ impl LocalAIController {
let _ = index_process_sink
.send(StreamMessage::IndexFileError { file_name }.to_string())
.await;
error!("[AI Plugin] failed to index file: {:?}", err);
error!("[Local AI] failed to index file: {:?}", err);
},
}
@@ -597,31 +449,23 @@ impl LocalAIController {
#[instrument(level = "debug", skip_all)]
pub(crate) async fn toggle_plugin(&self, enabled: bool) -> FlowyResult<()> {
info!(
"[AI Plugin] enable: {}, thread id: {:?}",
"[Local AI] enable: {}, thread id: {:?}",
enabled,
std::thread::current().id()
);
if enabled {
let (tx, rx) = tokio::sync::oneshot::channel();
if let Err(err) = initialize_ai_plugin(&self.ai_plugin, &self.resource, Some(tx)).await {
error!("[AI Plugin] failed to initialize local ai: {:?}", err);
if let Err(err) = check_local_ai_resources(&self.resource, &self.llm_controller).await {
error!("[Local AI] failed to initialize local ai: {:?}", err);
}
let _ = rx.await;
} else {
if let Err(err) = self.ai_plugin.destroy_plugin().await {
error!("[AI Plugin] failed to destroy plugin: {:?}", err);
}
chat_notification_builder(
APPFLOWY_AI_NOTIFICATION_KEY,
ChatNotification::UpdateLocalAIState,
)
.payload(LocalAIPB {
enabled,
plugin_downloaded: true,
state: RunningStatePB::Stopped,
lack_of_resource: None,
plugin_version: None,
is_ready: self.is_ready().await,
})
.send();
}
@@ -630,10 +474,9 @@ impl LocalAIController {
}
#[instrument(level = "debug", skip_all, err)]
async fn initialize_ai_plugin(
plugin: &Arc<OllamaAIPlugin>,
async fn check_local_ai_resources(
llm_resource: &Arc<LocalAIResourceController>,
ret: Option<tokio::sync::oneshot::Sender<()>>,
llm_controller: &LLMChatController,
) -> FlowyResult<()> {
let lack_of_resource = llm_resource.get_lack_of_resource().await;
@@ -643,16 +486,14 @@ async fn initialize_ai_plugin(
)
.payload(LocalAIPB {
enabled: true,
plugin_downloaded: true,
state: RunningStatePB::ReadyToRun,
lack_of_resource: lack_of_resource.clone(),
plugin_version: None,
is_ready: llm_controller.is_ready().await,
})
.send();
if let Some(lack_of_resource) = lack_of_resource {
info!(
"[AI Plugin] lack of resource: {:?} to initialize plugin, thread: {:?}",
"[Local AI] lack of resource: {:?} to initialize plugin, thread: {:?}",
lack_of_resource,
std::thread::current().id()
);
@@ -666,41 +507,6 @@ async fn initialize_ai_plugin(
return Ok(());
}
if let Err(err) = plugin.destroy_plugin().await {
error!(
"[AI Plugin] failed to destroy plugin when lack of resource: {:?}",
err
);
}
let plugin = plugin.clone();
let cloned_llm_res = llm_resource.clone();
tokio::task::spawn_blocking(move || {
futures::executor::block_on(async move {
match cloned_llm_res.get_plugin_config(true).await {
Ok(config) => {
info!(
"[AI Plugin] initialize plugin with config: {:?}, thread: {:?}",
config,
std::thread::current().id()
);
match plugin.init_plugin(config).await {
Ok(_) => {},
Err(err) => error!("[AI Plugin] failed to setup plugin: {:?}", err),
}
if let Some(ret) = ret {
let _ = ret.send(());
}
},
Err(err) => {
error!("[AI Plugin] failed to get plugin config: {:?}", err);
},
};
})
});
Ok(())
}
@@ -0,0 +1,2 @@
pub mod summary;
pub mod translate;
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more