Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: integrate local ai #5204

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ enum OpenAIRequestType {
case OpenAIRequestType.textCompletion:
return Uri.parse('https://api.openai.com/v1/completions');
case OpenAIRequestType.textEdit:
return Uri.parse('https://api.openai.com/v1/v1/chat/completions');
return Uri.parse('https://api.openai.com/v1/completions');
case OpenAIRequestType.imageGenerations:
return Uri.parse('https://api.openai.com/v1/images/generations');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/build_context_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/text_robot.dart';
Expand Down Expand Up @@ -188,7 +190,7 @@ class _AutoCompletionBlockComponentState

Future<void> _onGenerate() async {
final loading = Loading(context);
await loading.start();
unawaited(loading.start());

await _updateEditingText();

Expand Down
6 changes: 6 additions & 0 deletions frontend/appflowy_flutter/lib/shared/feature_flags.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ enum FeatureFlag {
// used for the search feature
search,

// ai
ai,

// used for ignore the conflicted feature flag
unknown;

Expand Down Expand Up @@ -103,6 +106,7 @@ enum FeatureFlag {
case FeatureFlag.collaborativeWorkspace:
case FeatureFlag.membersSettings:
case FeatureFlag.search:
case FeatureFlag.ai:
case FeatureFlag.unknown:
return false;
case FeatureFlag.syncDocument:
Expand All @@ -123,6 +127,8 @@ enum FeatureFlag {
return 'if it\'s on, the collaborators will show in the database';
case FeatureFlag.search:
return 'if it\'s on, the command palette and search button will be available';
case FeatureFlag.ai:
return 'if it\'s on, the AI feature will be available';
case FeatureFlag.unknown:
return '';
}
Expand Down
139 changes: 139 additions & 0 deletions frontend/appflowy_flutter/lib/shared/local_ai_server.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import 'dart:async';
import 'dart:io';

import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

const String kLocalAIServerPrefix = 'appflowy_ai_';

class LocalAIServer {
LocalAIServer._internal();

factory LocalAIServer() => _instance;

static final LocalAIServer _instance = LocalAIServer._internal();

Future<(bool, int?)> launch({
required String localLLMPath,
String host = '127.0.0.1',
int? port,
}) async {
final executablePath = await getExecutablePath();

if (port == null && host == '127.0.0.1') {
// use a free port
final socket = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
port = socket.port;
await socket.close();
}

await terminate();

try {
final server = await Process.start(
executablePath,
[host, port.toString()],
);
// No need to wait for the exitCode because it won't return an exit code until the server is terminated
int? exitCode;
unawaited(
server.exitCode.then(
(value) => exitCode = value,
),
);

// the server will return error immediately if it launch failed.
await Future.delayed(const Duration(milliseconds: 500));

if (exitCode != null && exitCode != 0) {
debugPrint('Failed to launch server. Exit code: $exitCode');
return (false, null);
}
debugPrint('Server launched at $host:$port');
return (true, port);
} catch (e) {
debugPrint('unable to launch server: $e');
}

return (false, null);
}

Future<void> terminate() async {
final executablePath = await getExecutablePath();

if (Platform.isMacOS || Platform.isLinux) {
await Process.run('pkill', [executablePath]);
} else if (Platform.isWindows) {
await Process.run('taskkill', ['/F', '/IM', executablePath]);
}
}

Future<FlowyResult<void, FlowyError>> pingServer(
String host,
int port,
) async {
try {
final response = await http.get(Uri.parse('http://$host:$port/'));
if (response.statusCode != 200) {
return FlowyFailure(
FlowyError(
code: ErrorCode.Internal,
msg: 'Failed to ping server ${response.statusCode}',
),
);
}
return FlowySuccess(null);
} catch (e) {
return FlowyFailure(
FlowyError(
code: ErrorCode.Internal,
msg: 'Failed to ping server: $e',
),
);
}
}

Future<String> getExecutablePath() async {
try {
final dir = await getApplicationDocumentsDirectory();
// final dir = await getApplicationSupportDirectory();
final executablePath = p.join(dir.path, getExecutableName());
final executable = File(executablePath);
if (!executable.existsSync()) {
final bytes = await rootBundle.load('assets/ai/${getExecutableName()}');
await executable.writeAsBytes(bytes.buffer.asUint8List());
debugPrint('Executable written to $executablePath');
}

// make it executable because the permission is not preserved when writing the file
if (Platform.isMacOS || Platform.isLinux) {
await Process.run('chmod', [
'u+x',
executablePath,
]);
}

return executablePath;
} catch (e) {
throw Exception('Failed to get executable path: $e');
}
}

String getExecutableName() {
var name = kLocalAIServerPrefix;
if (Platform.isMacOS) {
name += 'osx';
} else if (Platform.isWindows) {
name += 'win';
} else if (Platform.isLinux) {
name += 'lnx';
}
return name;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum SettingsPage {
cloud,
shortcuts,
member,
ai,
featureFlags,
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/ai/settings_ai_page.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_page.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart';
Expand Down Expand Up @@ -117,6 +118,8 @@ class SettingsDialog extends StatelessWidget {
return const SettingsCustomizeShortcutsWrapper();
case SettingsPage.member:
return WorkspaceMembersPage(userProfile: user);
case SettingsPage.ai:
return const SettingsAIPage();
case SettingsPage.featureFlags:
return const FeatureFlagsPage();
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import 'dart:async';

import 'package:appflowy/shared/local_ai_server.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'settings_ai_bloc.freezed.dart';

class SettingsAIBloc extends Bloc<SettingsAIEvent, SettingsAIState> {
SettingsAIBloc() : super(SettingsAIState.initial()) {
on<SettingsAIEvent>((event, emit) async {
await event.when(
initial: () async {},
setLocalLLMPath: (path) async {
await setLocalLLMPath(emit, path);
},
updateLocalServerHealth: (result) async {
emit(
state.copyWith(
actionResult: result,
),
);
},
);
});
}

final aiServer = LocalAIServer();
final host = '127.0.0.1';
int? port;
Timer? timer;

@override
Future<void> close() async {
timer?.cancel();
await super.close();
}

Future<void> setLocalLLMPath(Emitter emit, String path) async {
// load the local model
emit(
state.copyWith(
actionResult: const SettingsAIRequestResult(
actionType: SettingsAILLMMode.local,
isLoading: true,
result: null,
),
),
);
final launchResult = await LocalAIServer().launch(
localLLMPath: path,
host: host,
);
final isLoadSuccess = launchResult.$1;
port = launchResult.$2;
debugPrint('Local LLM launched at localhost:$port');
if (!isLoadSuccess) {
// only update the state if the load failed
emit(
state.copyWith(
actionResult: SettingsAIRequestResult(
actionType: SettingsAILLMMode.local,
isLoading: false,
result: FlowyResult.failure(
FlowyError(msg: 'Load Local LLM Failed'),
),
),
),
);
} else {
observeLocalServeHealth();
}
}

void observeLocalServeHealth() {
if (port == null) {
return;
}
timer?.cancel();
timer = Timer.periodic(const Duration(seconds: 2), (timer) async {
final result = await aiServer.pingServer(host, port!);
debugPrint('Local server health: $result');
final requestResult = SettingsAIRequestResult(
actionType: SettingsAILLMMode.local,
isLoading: false,
result: result,
);
add(SettingsAIEvent.updateLocalServerHealth(requestResult));
});
}
}

@freezed
class SettingsAIEvent with _$SettingsAIEvent {
const factory SettingsAIEvent.initial() = Initial;
const factory SettingsAIEvent.setLocalLLMPath(String localLLMPath) =
SetLocalLLMPath;
const factory SettingsAIEvent.updateLocalServerHealth(
SettingsAIRequestResult result,
) = UpdateLocalServerHealth;
}

enum SettingsAILLMMode {
none,
local,
remote,
}

class SettingsAIRequestResult {
const SettingsAIRequestResult({
required this.actionType,
required this.isLoading,
required this.result,
});

final SettingsAILLMMode actionType;
final bool isLoading;
final FlowyResult<void, FlowyError>? result;
}

@freezed
class SettingsAIState with _$SettingsAIState {
const SettingsAIState._();

const factory SettingsAIState({
@Default(SettingsAILLMMode.local) SettingsAILLMMode mode,
@Default(null) SettingsAIRequestResult? actionResult,
String? localLLMPath,
}) = _SettingsAIState;

factory SettingsAIState.initial() => const SettingsAIState();

@override
int get hashCode => runtimeType.hashCode;

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;

return other is SettingsAIState &&
other.mode == mode &&
identical(other.actionResult, actionResult);
}
}
Loading
Loading