diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 1ecd780c..b571fc33 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -62,7 +62,7 @@ jobs: matrix: include: - test_service: "appflowy_cloud" - test_cmd: "--workspace --exclude appflowy-history --exclude appflowy-ai-client" + test_cmd: "--workspace --exclude appflowy-history --exclude appflowy-ai-client --features ai-test-enabled" - test_service: "appflowy_history" test_cmd: "-p appflowy-history" steps: @@ -79,18 +79,19 @@ jobs: run: | # log level sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env - sed -i 's/GOTRUE_SMTP_USER=.*/GOTRUE_SMTP_USER=${{ secrets.CI_GOTRUE_SMTP_USER }}/' .env - sed -i 's/GOTRUE_SMTP_PASS=.*/GOTRUE_SMTP_PASS=${{ secrets.CI_GOTRUE_SMTP_PASS }}/' .env - sed -i 's/GOTRUE_SMTP_ADMIN_EMAIL=.*/GOTRUE_SMTP_ADMIN_EMAIL=${{ secrets.CI_GOTRUE_SMTP_ADMIN_EMAIL }}/' .env - sed -i 's/GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*/GOTRUE_EXTERNAL_GOOGLE_ENABLED=true/' .env - sed -i 's/GOTRUE_MAILER_AUTOCONFIRM=.*/GOTRUE_MAILER_AUTOCONFIRM=false/' .env - sed -i 's/API_EXTERNAL_URL=http:\/\/your-host/API_EXTERNAL_URL=http:\/\/localhost/' .env - sed -i 's/GOTRUE_RATE_LIMIT_EMAIL_SENT=100/GOTRUE_RATE_LIMIT_EMAIL_SENT=1000/' .env - sed -i 's/APPFLOWY_MAILER_SMTP_USERNAME=.*/APPFLOWY_MAILER_SMTP_USERNAME=${{ secrets.CI_GOTRUE_SMTP_USER }}/' .env - sed -i 's/APPFLOWY_MAILER_SMTP_PASSWORD=.*/APPFLOWY_MAILER_SMTP_PASSWORD=${{ secrets.CI_GOTRUE_SMTP_PASS }}/' .env - sed -i 's/APPFLOWY_AI_OPENAI_API_KEY=.*/APPFLOWY_AI_OPENAI_API_KEY=${{ secrets.CI_OPENAI_API_KEY }}/' .env - sed -i 's/APPFLOWY_INDEXER_REDIS_URL=.*/APPFLOWY_INDEXER_REDIS_URL=redis:\/\/localhost:6379/' .env - sed -i 's/APPFLOWY_INDEXER_DATABASE_URL=.*/APPFLOWY_INDEXER_DATABASE_URL=postgres:\/\/postgres:password@localhost:5432\/postgres/' .env + sed -i 's|GOTRUE_SMTP_USER=.*|GOTRUE_SMTP_USER=${{ secrets.CI_GOTRUE_SMTP_USER }}|' .env + sed -i 's|GOTRUE_SMTP_PASS=.*|GOTRUE_SMTP_PASS=${{ secrets.CI_GOTRUE_SMTP_PASS }}|' .env + sed -i 's|GOTRUE_SMTP_ADMIN_EMAIL=.*|GOTRUE_SMTP_ADMIN_EMAIL=${{ secrets.CI_GOTRUE_SMTP_ADMIN_EMAIL }}|' .env + sed -i 's|GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*|GOTRUE_EXTERNAL_GOOGLE_ENABLED=true|' .env + sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=false|' .env + sed -i 's|API_EXTERNAL_URL=http://your-host|API_EXTERNAL_URL=http://localhost|' .env + sed -i 's|GOTRUE_RATE_LIMIT_EMAIL_SENT=100|GOTRUE_RATE_LIMIT_EMAIL_SENT=1000|' .env + sed -i 's|APPFLOWY_MAILER_SMTP_USERNAME=.*|APPFLOWY_MAILER_SMTP_USERNAME=${{ secrets.CI_GOTRUE_SMTP_USER }}|' .env + sed -i 's|APPFLOWY_MAILER_SMTP_PASSWORD=.*|APPFLOWY_MAILER_SMTP_PASSWORD=${{ secrets.CI_GOTRUE_SMTP_PASS }}|' .env + sed -i 's|APPFLOWY_AI_OPENAI_API_KEY=.*|APPFLOWY_AI_OPENAI_API_KEY=${{ secrets.CI_OPENAI_API_KEY }}|' .env + sed -i 's|APPFLOWY_INDEXER_REDIS_URL=.*|APPFLOWY_INDEXER_REDIS_URL=redis://localhost:6379|' .env + sed -i 's|APPFLOWY_INDEXER_DATABASE_URL=.*|APPFLOWY_INDEXER_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres|' .env + shell: bash - name: Update Nginx Configuration # the wasm-pack headless tests will run on random ports, so we need to allow all origins diff --git a/Cargo.toml b/Cargo.toml index bd8cbe67..bf450340 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -240,3 +240,4 @@ collab-document = { git = "https://github.com/AppFlowy-IO/AppFlowy-Collab", rev [features] history = [] +ai-test-enabled = [] \ No newline at end of file diff --git a/docker-compose-ci.yml b/docker-compose-ci.yml index 26099c57..4a684b7b 100644 --- a/docker-compose-ci.yml +++ b/docker-compose-ci.yml @@ -143,6 +143,8 @@ services: - "5001:5001" environment: - OPENAI_API_KEY=${APPFLOWY_AI_OPENAI_API_KEY} + - LOCAL_AI_AWS_ACCESS_KEY_ID=${LOCAL_AI_AWS_ACCESS_KEY_ID} + - LOCAL_AI_AWS_SECRET_ACCESS_KEY=${LOCAL_AI_AWS_SECRET_ACCESS_KEY} - APPFLOWY_AI_SERVER_PORT=${APPFLOWY_AI_SERVER_PORT} - APPFLOWY_AI_DATABASE_URL=${APPFLOWY_AI_DATABASE_URL} diff --git a/libs/appflowy-ai-client/src/client.rs b/libs/appflowy-ai-client/src/client.rs index 1a39c6fa..d6c403f6 100644 --- a/libs/appflowy-ai-client/src/client.rs +++ b/libs/appflowy-ai-client/src/client.rs @@ -1,7 +1,8 @@ use crate::dto::{ AIModel, ChatAnswer, ChatQuestion, CompleteTextResponse, CompletionType, Document, - EmbeddingRequest, EmbeddingResponse, MessageData, RepeatedRelatedQuestion, - SearchDocumentsRequest, SummarizeRowResponse, TranslateRowData, TranslateRowResponse, + EmbeddingRequest, EmbeddingResponse, LocalAIConfig, MessageData, RepeatedLocalAIPackage, + RepeatedRelatedQuestion, SearchDocumentsRequest, SummarizeRowResponse, TranslateRowData, + TranslateRowResponse, }; use crate::error::AIError; @@ -236,6 +237,25 @@ impl AppFlowyAIClient { .into_data() } + pub async fn get_local_ai_package( + &self, + platform: &str, + ) -> Result { + let url = format!("{}/local_ai/version?platform={platform}", self.url); + let resp = self.http_client(Method::GET, &url)?.send().await?; + AIResponse::::from_response(resp) + .await? + .into_data() + } + + pub async fn get_local_ai_config(&self, platform: &str) -> Result { + let url = format!("{}/local_ai/config?platform={platform}", self.url); + let resp = self.http_client(Method::GET, &url)?.send().await?; + AIResponse::::from_response(resp) + .await? + .into_data() + } + fn http_client(&self, method: Method, url: &str) -> Result { let request_builder = self.client.request(method, url); Ok(request_builder) diff --git a/libs/appflowy-ai-client/src/dto.rs b/libs/appflowy-ai-client/src/dto.rs index 28a0521a..840c969f 100644 --- a/libs/appflowy-ai-client/src/dto.rs +++ b/libs/appflowy-ai-client/src/dto.rs @@ -233,3 +233,38 @@ impl FromStr for AIModel { } } } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RepeatedLocalAIPackage(pub Vec); + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct AppFlowyAIPlugin { + pub name: String, + pub version: String, + pub url: String, + pub etag: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct LLMModel { + pub llm_id: i64, + pub provider: String, + pub embedding_model: ModelInfo, + pub chat_model: ModelInfo, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub struct ModelInfo { + pub name: String, + pub file_name: String, + pub file_size: i64, + pub requirements: String, + pub download_url: String, + pub desc: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LocalAIConfig { + pub models: Vec, + pub plugin: AppFlowyAIPlugin, +} diff --git a/libs/appflowy-ai-client/tests/chat_test/qa_test.rs b/libs/appflowy-ai-client/tests/chat_test/qa_test.rs index 78c23c00..32d8df8b 100644 --- a/libs/appflowy-ai-client/tests/chat_test/qa_test.rs +++ b/libs/appflowy-ai-client/tests/chat_test/qa_test.rs @@ -65,3 +65,25 @@ async fn stream_test() { let messages: Vec = stream.map(|message| message.unwrap()).collect().await; println!("final answer: {}", messages.join("")); } + +#[tokio::test] +async fn download_package_test() { + let client = appflowy_ai_client(); + let packages = client.get_local_ai_package("macos").await.unwrap(); + assert!(!packages.0.is_empty()); + println!("packages: {:?}", packages); +} + +#[tokio::test] +async fn get_local_ai_config_test() { + let client = appflowy_ai_client(); + let config = client.get_local_ai_config("macos").await.unwrap(); + assert!(!config.models.is_empty()); + + assert!(!config.models[0].embedding_model.download_url.is_empty()); + assert!(!config.models[0].chat_model.download_url.is_empty()); + + assert!(!config.plugin.version.is_empty()); + assert!(!config.plugin.url.is_empty()); + println!("packages: {:?}", config); +} diff --git a/libs/client-api/src/http.rs b/libs/client-api/src/http.rs index 81ab9ea8..61b74d21 100644 --- a/libs/client-api/src/http.rs +++ b/libs/client-api/src/http.rs @@ -124,7 +124,20 @@ impl Client { client_id: &str, ) -> Self { let reqwest_client = reqwest::Client::new(); - let client_version = Version::parse(client_id).unwrap_or_else(|_| Version::new(0, 5, 0)); + let client_version = Version::parse(client_id).unwrap_or_else(|_| { + warn!("Failed to parse client version, defaulting to 0.6.3"); + Version::new(0, 6, 3) + }); + + // The latest version of appflowy frontend application is 0.6.3. + // Ensure the client version is at least 0.6.3. Just in case client passes a lower version. + let min_version = Version::new(0, 6, 3); + let client_version = if client_version < min_version { + warn!("Client version is less than 0.6.3, setting it to 0.6.3"); + min_version + } else { + client_version + }; #[cfg(debug_assertions)] { diff --git a/libs/client-api/src/http_ai.rs b/libs/client-api/src/http_ai.rs index 908c465c..1bdd16c0 100644 --- a/libs/client-api/src/http_ai.rs +++ b/libs/client-api/src/http_ai.rs @@ -2,8 +2,8 @@ use crate::http::log_request_id; use crate::Client; use reqwest::Method; use shared_entity::dto::ai_dto::{ - CompleteTextParams, CompleteTextResponse, SummarizeRowParams, SummarizeRowResponse, - TranslateRowParams, TranslateRowResponse, + CompleteTextParams, CompleteTextResponse, LocalAIConfig, SummarizeRowParams, + SummarizeRowResponse, TranslateRowParams, TranslateRowResponse, }; use shared_entity::response::{AppResponse, AppResponseError}; use tracing::instrument; @@ -73,4 +73,25 @@ impl Client { .await? .into_data() } + + #[instrument(level = "info", skip_all, err)] + pub async fn get_local_ai_config( + &self, + workspace_id: &str, + platform: &str, + ) -> Result { + let url = format!( + "{}/api/ai/{}/local/config?platform={platform}", + self.base_url, workspace_id + ); + let resp = self + .http_client_with_auth(Method::GET, &url) + .await? + .send() + .await?; + log_request_id(&resp); + AppResponse::::from_response(resp) + .await? + .into_data() + } } diff --git a/src/api/ai.rs b/src/api/ai.rs index e6ad6057..3ea0e90b 100644 --- a/src/api/ai.rs +++ b/src/api/ai.rs @@ -4,9 +4,12 @@ use crate::state::AppState; use actix_web::web::{Data, Json}; use actix_web::{web, HttpRequest, HttpResponse, Scope}; use app_error::AppError; -use appflowy_ai_client::dto::{CompleteTextResponse, TranslateRowParams, TranslateRowResponse}; +use appflowy_ai_client::dto::{ + CompleteTextResponse, LocalAIConfig, TranslateRowParams, TranslateRowResponse, +}; use futures_util::{stream, TryStreamExt}; +use serde::Deserialize; use shared_entity::dto::ai_dto::{ CompleteTextParams, SummarizeRowData, SummarizeRowParams, SummarizeRowResponse, }; @@ -20,6 +23,7 @@ pub fn ai_completion_scope() -> Scope { .service(web::resource("/complete/stream").route(web::post().to(stream_complete_text_handler))) .service(web::resource("/summarize_row").route(web::post().to(summarize_row_handler))) .service(web::resource("/translate_row").route(web::post().to(translate_row_handler))) + .service(web::resource("/local/config").route(web::get().to(local_ai_config_handler))) } async fn complete_text_handler( @@ -123,3 +127,45 @@ async fn translate_row_handler( }, } } + +#[derive(Deserialize)] +struct ConfigQuery { + platform: String, +} +#[instrument(level = "debug", skip_all, err)] +async fn local_ai_config_handler( + state: web::Data, + query: web::Query, + // req: HttpRequest, +) -> actix_web::Result>> { + let platform = match query.into_inner().platform.as_str() { + "macos" => "macos", + "linux" => "ubuntu", + "ubuntu" => "ubuntu", + "windows" => "windows", + _ => { + return Err(AppError::InvalidRequest("Invalid platform".to_string()).into()); + }, + }; + + let config = state + .ai_client + .get_local_ai_config(platform) + .await + .map_err(|err| AppError::AIServiceUnavailable(err.to_string()))?; + Ok(AppResponse::Ok().with_data(config).into()) +} + +// fn device_info_from_headers(headers: &HeaderMap) -> std::result::Result { +// headers +// .get("device_id") +// .ok_or(AppError::InvalidRequest( +// "Missing device_id header".to_string(), +// )) +// .and_then(|header| { +// header +// .to_str() +// .map_err(|err| AppError::InvalidRequest(format!("Failed to parse device_id: {}", err))) +// }) +// .map(|s| s.to_string()) +// } diff --git a/tests/ai_test/local_ai_test.rs b/tests/ai_test/local_ai_test.rs new file mode 100644 index 00000000..c95c782f --- /dev/null +++ b/tests/ai_test/local_ai_test.rs @@ -0,0 +1,23 @@ +use client_api_test::TestClient; + +#[tokio::test] +async fn get_local_ai_config_test() { + let test_client = TestClient::new_user().await; + let workspace_id = test_client.workspace_id().await; + let config = test_client + .api_client + .get_local_ai_config(&workspace_id, "macos") + .await + .unwrap(); + { + assert!(!config.models.is_empty()); + assert!(!config.models[0].embedding_model.download_url.is_empty()); + assert!(config.models[0].embedding_model.file_size > 10); + assert!(!config.models[0].chat_model.download_url.is_empty()); + assert!(config.models[0].chat_model.file_size > 10); + + assert!(!config.plugin.version.is_empty()); + assert!(!config.plugin.url.is_empty()); + println!("config: {:?}", config); + } +} diff --git a/tests/ai_test/mod.rs b/tests/ai_test/mod.rs index 70ff75b8..4859fdbe 100644 --- a/tests/ai_test/mod.rs +++ b/tests/ai_test/mod.rs @@ -1,3 +1,4 @@ mod chat_test; mod complete_text; +// mod local_ai_test; mod summarize_row; diff --git a/tests/main.rs b/tests/main.rs index 67f9c8b7..302b8a5c 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -1,12 +1,12 @@ +#[cfg(feature = "ai-test-enabled")] +mod ai_test; mod collab; +mod collab_history; +mod file_test; mod gotrue; +mod search; mod sql_test; mod user; mod websocket; mod workspace; - -mod ai_test; -mod collab_history; -mod file_test; -mod search; mod yrs_version;