Skip to content

Commit c87d9ab

Browse files
authored
Merge pull request #7652 from AppFlowy-IO/show_not_ready
chore: display message when using ai writer with local ai, but local ai is disabled
2 parents 3c74208 + e3a0806 commit c87d9ab

File tree

15 files changed

+214
-105
lines changed

15 files changed

+214
-105
lines changed

frontend/appflowy_flutter/lib/ai/service/ai_entities.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,20 @@ import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
44
import 'package:easy_localization/easy_localization.dart';
55
import 'package:equatable/equatable.dart';
66

7+
class AIStreamEventPrefix {
8+
static const data = 'data:';
9+
static const error = 'error:';
10+
static const metadata = 'metadata:';
11+
static const start = 'start:';
12+
static const finish = 'finish:';
13+
static const comment = 'comment:';
14+
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
15+
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
16+
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
17+
static const localAINotReady = 'LOCAL_AI_NOT_READY';
18+
static const localAIDisabled = 'LOCAL_AI_DISABLED';
19+
}
20+
721
enum AiType {
822
cloud,
923
local;

frontend/appflowy_flutter/lib/ai/service/appflowy_ai_service.dart

Lines changed: 77 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ import 'package:fixnum/fixnum.dart' as fixnum;
1515
import 'ai_entities.dart';
1616
import 'error.dart';
1717

18+
enum LocalAIStreamingState {
19+
notReady,
20+
disabled,
21+
}
22+
1823
abstract class AIRepository {
1924
Future<void> streamCompletion({
2025
String? objectId,
@@ -28,6 +33,8 @@ abstract class AIRepository {
2833
required Future<void> Function(String text) processAssistMessage,
2934
required Future<void> Function() onEnd,
3035
required void Function(AIError error) onError,
36+
required void Function(LocalAIStreamingState state)
37+
onLocalAIStreamingStateChange,
3138
});
3239
}
3340

@@ -45,12 +52,15 @@ class AppFlowyAIService implements AIRepository {
4552
required Future<void> Function(String text) processAssistMessage,
4653
required Future<void> Function() onEnd,
4754
required void Function(AIError error) onError,
55+
required void Function(LocalAIStreamingState state)
56+
onLocalAIStreamingStateChange,
4857
}) async {
4958
final stream = AppFlowyCompletionStream(
5059
onStart: onStart,
5160
processMessage: processMessage,
5261
processAssistMessage: processAssistMessage,
5362
processError: onError,
63+
onLocalAIStreamingStateChange: onLocalAIStreamingStateChange,
5464
onEnd: onEnd,
5565
);
5666

@@ -85,13 +95,16 @@ abstract class CompletionStream {
8595
required this.processMessage,
8696
required this.processAssistMessage,
8797
required this.processError,
98+
required this.onLocalAIStreamingStateChange,
8899
required this.onEnd,
89100
});
90101

91102
final Future<void> Function() onStart;
92103
final Future<void> Function(String text) processMessage;
93104
final Future<void> Function(String text) processAssistMessage;
94105
final void Function(AIError error) processError;
106+
final void Function(LocalAIStreamingState state)
107+
onLocalAIStreamingStateChange;
95108
final Future<void> Function() onEnd;
96109
}
97110

@@ -102,6 +115,7 @@ class AppFlowyCompletionStream extends CompletionStream {
102115
required super.processAssistMessage,
103116
required super.processError,
104117
required super.onEnd,
118+
required super.onLocalAIStreamingStateChange,
105119
}) {
106120
_startListening();
107121
}
@@ -115,55 +129,7 @@ class AppFlowyCompletionStream extends CompletionStream {
115129
_port.handler = _controller.add;
116130
_subscription = _controller.stream.listen(
117131
(event) async {
118-
if (event == "AI_RESPONSE_LIMIT") {
119-
processError(
120-
AIError(
121-
message: LocaleKeys.ai_textLimitReachedDescription.tr(),
122-
code: AIErrorCode.aiResponseLimitExceeded,
123-
),
124-
);
125-
}
126-
127-
if (event == "AI_IMAGE_RESPONSE_LIMIT") {
128-
processError(
129-
AIError(
130-
message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
131-
code: AIErrorCode.aiImageResponseLimitExceeded,
132-
),
133-
);
134-
}
135-
136-
if (event.startsWith("AI_MAX_REQUIRED:")) {
137-
final msg = event.substring(16);
138-
processError(
139-
AIError(
140-
message: msg,
141-
code: AIErrorCode.other,
142-
),
143-
);
144-
}
145-
146-
if (event.startsWith("start:")) {
147-
await onStart();
148-
}
149-
150-
if (event.startsWith("data:")) {
151-
await processMessage(event.substring(5));
152-
}
153-
154-
if (event.startsWith("comment:")) {
155-
await processAssistMessage(event.substring(8));
156-
}
157-
158-
if (event.startsWith("finish:")) {
159-
await onEnd();
160-
}
161-
162-
if (event.startsWith("error:")) {
163-
processError(
164-
AIError(message: event.substring(6), code: AIErrorCode.other),
165-
);
166-
}
132+
await _handleEvent(event);
167133
},
168134
);
169135
}
@@ -173,4 +139,66 @@ class AppFlowyCompletionStream extends CompletionStream {
173139
await _subscription.cancel();
174140
_port.close();
175141
}
142+
143+
Future<void> _handleEvent(String event) async {
144+
// Check simple matches first
145+
if (event == AIStreamEventPrefix.aiResponseLimit) {
146+
processError(
147+
AIError(
148+
message: LocaleKeys.ai_textLimitReachedDescription.tr(),
149+
code: AIErrorCode.aiResponseLimitExceeded,
150+
),
151+
);
152+
return;
153+
}
154+
155+
if (event == AIStreamEventPrefix.aiImageResponseLimit) {
156+
processError(
157+
AIError(
158+
message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
159+
code: AIErrorCode.aiImageResponseLimitExceeded,
160+
),
161+
);
162+
return;
163+
}
164+
165+
// Otherwise, parse out prefix:content
166+
if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
167+
processError(
168+
AIError(
169+
message: event.substring(AIStreamEventPrefix.aiMaxRequired.length),
170+
code: AIErrorCode.other,
171+
),
172+
);
173+
} else if (event.startsWith(AIStreamEventPrefix.start)) {
174+
await onStart();
175+
} else if (event.startsWith(AIStreamEventPrefix.data)) {
176+
await processMessage(
177+
event.substring(AIStreamEventPrefix.data.length),
178+
);
179+
} else if (event.startsWith(AIStreamEventPrefix.comment)) {
180+
await processAssistMessage(
181+
event.substring(AIStreamEventPrefix.comment.length),
182+
);
183+
} else if (event.startsWith(AIStreamEventPrefix.finish)) {
184+
await onEnd();
185+
} else if (event.startsWith(AIStreamEventPrefix.localAIDisabled)) {
186+
onLocalAIStreamingStateChange(
187+
LocalAIStreamingState.disabled,
188+
);
189+
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
190+
onLocalAIStreamingStateChange(
191+
LocalAIStreamingState.notReady,
192+
);
193+
} else if (event.startsWith(AIStreamEventPrefix.error)) {
194+
processError(
195+
AIError(
196+
message: event.substring(AIStreamEventPrefix.error.length),
197+
code: AIErrorCode.other,
198+
),
199+
);
200+
} else {
201+
Log.debug('Unknown AI event: $event');
202+
}
203+
}
176204
}

frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_message_stream.dart

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,9 @@ import 'dart:async';
22
import 'dart:ffi';
33
import 'dart:isolate';
44

5+
import 'package:appflowy/ai/service/ai_entities.dart';
56
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
67

7-
/// Constants for event prefixes.
8-
class AnswerEventPrefix {
9-
static const data = 'data:';
10-
static const error = 'error:';
11-
static const metadata = 'metadata:';
12-
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
13-
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
14-
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
15-
static const localAINotReady = 'LOCAL_AI_NOT_READY';
16-
}
17-
188
/// A stream that receives answer events from an isolate or external process.
199
/// It caches events that might occur before a listener is attached.
2010
class AnswerStream {
@@ -68,31 +58,31 @@ class AnswerStream {
6858

6959
/// Handles incoming events from the underlying stream.
7060
void _handleEvent(String event) {
71-
if (event.startsWith(AnswerEventPrefix.data)) {
61+
if (event.startsWith(AIStreamEventPrefix.data)) {
7262
_hasStarted = true;
73-
final newText = event.substring(AnswerEventPrefix.data.length);
63+
final newText = event.substring(AIStreamEventPrefix.data.length);
7464
_text += newText;
7565
_onData?.call(_text);
76-
} else if (event.startsWith(AnswerEventPrefix.error)) {
77-
_error = event.substring(AnswerEventPrefix.error.length);
66+
} else if (event.startsWith(AIStreamEventPrefix.error)) {
67+
_error = event.substring(AIStreamEventPrefix.error.length);
7868
_onError?.call(_error!);
79-
} else if (event.startsWith(AnswerEventPrefix.metadata)) {
80-
final s = event.substring(AnswerEventPrefix.metadata.length);
69+
} else if (event.startsWith(AIStreamEventPrefix.metadata)) {
70+
final s = event.substring(AIStreamEventPrefix.metadata.length);
8171
_onMetadata?.call(parseMetadata(s));
82-
} else if (event == AnswerEventPrefix.aiResponseLimit) {
72+
} else if (event == AIStreamEventPrefix.aiResponseLimit) {
8373
_aiLimitReached = true;
8474
_onAIResponseLimit?.call();
85-
} else if (event == AnswerEventPrefix.aiImageResponseLimit) {
75+
} else if (event == AIStreamEventPrefix.aiImageResponseLimit) {
8676
_aiImageLimitReached = true;
8777
_onAIImageResponseLimit?.call();
88-
} else if (event.startsWith(AnswerEventPrefix.aiMaxRequired)) {
89-
final msg = event.substring(AnswerEventPrefix.aiMaxRequired.length);
78+
} else if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
79+
final msg = event.substring(AIStreamEventPrefix.aiMaxRequired.length);
9080
if (_onAIMaxRequired != null) {
9181
_onAIMaxRequired!(msg);
9282
} else {
9383
_pendingAIMaxRequiredEvents.add(msg);
9484
}
95-
} else if (event.startsWith(AnswerEventPrefix.localAINotReady)) {
85+
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
9686
if (_onLocalAIInitializing != null) {
9787
_onLocalAIInitializing!();
9888
} else {

frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,26 @@ class MainContentArea extends StatelessWidget {
568568
),
569569
);
570570
}
571+
if (state is LocalAIStreamingAiWriterState) {
572+
final text = switch (state.state) {
573+
LocalAIStreamingState.notReady =>
574+
LocaleKeys.settings_aiPage_keys_localAINotReadyRetryLater.tr(),
575+
LocalAIStreamingState.disabled =>
576+
LocaleKeys.settings_aiPage_keys_localAIDisabled.tr(),
577+
};
578+
return Padding(
579+
padding: EdgeInsets.all(8.0),
580+
child: Row(
581+
children: [
582+
const HSpace(8.0),
583+
Opacity(
584+
opacity: 0.5,
585+
child: FlowyText(text),
586+
),
587+
],
588+
),
589+
);
590+
}
571591
return const SizedBox.shrink();
572592
},
573593
);

frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_cubit.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,9 @@ class AiWriterCubit extends Cubit<AiWriterState> {
390390
AiWriterRecord.ai(content: _textRobot.markdownText),
391391
);
392392
},
393+
onLocalAIStreamingStateChange: (state) {
394+
emit(LocalAIStreamingAiWriterState(command, state: state));
395+
},
393396
);
394397

395398
if (stream != null) {
@@ -481,6 +484,9 @@ class AiWriterCubit extends Cubit<AiWriterState> {
481484
AiWriterRecord.ai(content: _textRobot.markdownText),
482485
);
483486
},
487+
onLocalAIStreamingStateChange: (state) {
488+
emit(LocalAIStreamingAiWriterState(command, state: state));
489+
},
484490
);
485491
if (stream != null) {
486492
emit(
@@ -569,6 +575,9 @@ class AiWriterCubit extends Cubit<AiWriterState> {
569575
AiWriterRecord.ai(content: _textRobot.markdownText),
570576
);
571577
},
578+
onLocalAIStreamingStateChange: (state) {
579+
emit(LocalAIStreamingAiWriterState(command, state: state));
580+
},
572581
);
573582
if (stream != null) {
574583
emit(
@@ -639,6 +648,9 @@ class AiWriterCubit extends Cubit<AiWriterState> {
639648
}
640649
emit(ErrorAiWriterState(command, error: error));
641650
},
651+
onLocalAIStreamingStateChange: (state) {
652+
emit(LocalAIStreamingAiWriterState(command, state: state));
653+
},
642654
);
643655
if (stream != null) {
644656
emit(
@@ -714,3 +726,16 @@ class DocumentContentEmptyAiWriterState extends AiWriterState
714726

715727
final void Function() onConfirm;
716728
}
729+
730+
class LocalAIStreamingAiWriterState extends AiWriterState
731+
with RegisteredAiWriter {
732+
const LocalAIStreamingAiWriterState(
733+
this.command, {
734+
required this.state,
735+
});
736+
737+
@override
738+
final AiWriterCommand command;
739+
740+
final LocalAIStreamingState state;
741+
}

frontend/appflowy_flutter/test/bloc_test/ai_writer_test/ai_writer_bloc_test.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ class _MockAIRepository extends Mock implements AppFlowyAIService {
3030
required Future<void> Function(String text) processAssistMessage,
3131
required Future<void> Function() onEnd,
3232
required void Function(AIError error) onError,
33+
required void Function(LocalAIStreamingState state)
34+
onLocalAIStreamingStateChange,
3335
}) async {
3436
final stream = _MockCompletionStream();
3537
unawaited(
@@ -62,6 +64,8 @@ class _MockAIRepositoryLess extends Mock implements AppFlowyAIService {
6264
required Future<void> Function(String text) processAssistMessage,
6365
required Future<void> Function() onEnd,
6466
required void Function(AIError error) onError,
67+
required void Function(LocalAIStreamingState state)
68+
onLocalAIStreamingStateChange,
6569
}) async {
6670
final stream = _MockCompletionStream();
6771
unawaited(
@@ -90,6 +94,8 @@ class _MockAIRepositoryMore extends Mock implements AppFlowyAIService {
9094
required Future<void> Function(String text) processAssistMessage,
9195
required Future<void> Function() onEnd,
9296
required void Function(AIError error) onError,
97+
required void Function(LocalAIStreamingState state)
98+
onLocalAIStreamingStateChange,
9399
}) async {
94100
final stream = _MockCompletionStream();
95101
unawaited(
@@ -120,6 +126,8 @@ class _MockErrorRepository extends Mock implements AppFlowyAIService {
120126
required Future<void> Function(String text) processAssistMessage,
121127
required Future<void> Function() onEnd,
122128
required void Function(AIError error) onError,
129+
required void Function(LocalAIStreamingState state)
130+
onLocalAIStreamingStateChange,
123131
}) async {
124132
final stream = _MockCompletionStream();
125133
unawaited(

0 commit comments

Comments
 (0)