mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 23:34:24 +01:00
344 lines
11 KiB
Dart
344 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:meta/meta.dart';
|
|
import 'package:riverpod/riverpod.dart';
|
|
import 'package:server_box/data/model/ai/ask_ai_models.dart';
|
|
import 'package:server_box/data/res/store.dart';
|
|
import 'package:server_box/data/store/setting.dart';
|
|
|
|
final askAiRepositoryProvider = Provider<AskAiRepository>((ref) {
|
|
return AskAiRepository();
|
|
});
|
|
|
|
class AskAiRepository {
|
|
AskAiRepository({Dio? dio}) : _dio = dio ?? Dio();
|
|
|
|
final Dio _dio;
|
|
|
|
SettingStore get _settings => Stores.setting;
|
|
|
|
/// Streams the AI response using the configured endpoint.
|
|
Stream<AskAiEvent> ask({
|
|
required String selection,
|
|
String? localeHint,
|
|
List<AskAiMessage> conversation = const [],
|
|
}) async* {
|
|
final baseUrl = _settings.askAiBaseUrl.fetch().trim();
|
|
final apiKey = _settings.askAiApiKey.fetch().trim();
|
|
final model = _settings.askAiModel.fetch().trim();
|
|
|
|
final missing = <AskAiConfigField>[];
|
|
if (baseUrl.isEmpty) missing.add(AskAiConfigField.baseUrl);
|
|
if (apiKey.isEmpty) missing.add(AskAiConfigField.apiKey);
|
|
if (model.isEmpty) missing.add(AskAiConfigField.model);
|
|
if (missing.isNotEmpty) {
|
|
throw AskAiConfigException(missingFields: missing);
|
|
}
|
|
|
|
final parsedBaseUri = Uri.tryParse(baseUrl);
|
|
final hasScheme = parsedBaseUri?.hasScheme ?? false;
|
|
final hasHost = (parsedBaseUri?.host ?? '').isNotEmpty;
|
|
if (!hasScheme || !hasHost) {
|
|
throw AskAiConfigException(invalidBaseUrl: baseUrl);
|
|
}
|
|
|
|
final uri = _composeUri(baseUrl, '/v1/chat/completions');
|
|
final authHeader = apiKey.startsWith('Bearer ') ? apiKey : 'Bearer $apiKey';
|
|
final headers = <String, String>{
|
|
Headers.acceptHeader: 'text/event-stream',
|
|
Headers.contentTypeHeader: Headers.jsonContentType,
|
|
'Authorization': authHeader,
|
|
};
|
|
|
|
final requestBody = _buildRequestBody(
|
|
model: model,
|
|
selection: selection,
|
|
localeHint: localeHint,
|
|
conversation: conversation,
|
|
);
|
|
|
|
Response<ResponseBody> response;
|
|
try {
|
|
response = await _dio.postUri<ResponseBody>(
|
|
uri,
|
|
data: jsonEncode(requestBody),
|
|
options: Options(
|
|
responseType: ResponseType.stream,
|
|
headers: headers,
|
|
sendTimeout: const Duration(seconds: 20),
|
|
receiveTimeout: const Duration(minutes: 2),
|
|
),
|
|
);
|
|
} on DioException catch (e) {
|
|
throw AskAiNetworkException(message: e.message ?? 'Request failed', cause: e);
|
|
}
|
|
|
|
final body = response.data;
|
|
if (body == null) {
|
|
throw AskAiNetworkException(message: 'Empty response body');
|
|
}
|
|
|
|
final contentBuffer = StringBuffer();
|
|
final commands = <AskAiCommand>[];
|
|
final toolBuilders = <int, _ToolCallBuilder>{};
|
|
final utf8Stream = body.stream.cast<List<int>>().transform(utf8.decoder);
|
|
final carry = StringBuffer();
|
|
|
|
try {
|
|
await for (final chunk in utf8Stream) {
|
|
carry.write(chunk);
|
|
final segments = carry.toString().split('\n\n');
|
|
carry
|
|
..clear()
|
|
..write(segments.removeLast());
|
|
|
|
for (final segment in segments) {
|
|
final lines = segment.split('\n');
|
|
for (final rawLine in lines) {
|
|
final line = rawLine.trim();
|
|
if (line.isEmpty || !line.startsWith('data:')) {
|
|
continue;
|
|
}
|
|
final payload = line.substring(5).trim();
|
|
if (payload.isEmpty) {
|
|
continue;
|
|
}
|
|
if (payload == '[DONE]') {
|
|
yield AskAiCompleted(
|
|
fullText: contentBuffer.toString(),
|
|
commands: List.unmodifiable(commands),
|
|
);
|
|
return;
|
|
}
|
|
|
|
Map<String, dynamic> json;
|
|
try {
|
|
json = jsonDecode(payload) as Map<String, dynamic>;
|
|
} catch (e, s) {
|
|
yield AskAiStreamError(e, s);
|
|
continue;
|
|
}
|
|
|
|
final choices = json['choices'];
|
|
if (choices is! List || choices.isEmpty) {
|
|
continue;
|
|
}
|
|
|
|
for (final choice in choices) {
|
|
if (choice is! Map<String, dynamic>) {
|
|
continue;
|
|
}
|
|
final delta = choice['delta'];
|
|
if (delta is Map<String, dynamic>) {
|
|
final content = delta['content'];
|
|
if (content is String && content.isNotEmpty) {
|
|
contentBuffer.write(content);
|
|
yield AskAiContentDelta(content);
|
|
} else if (content is List) {
|
|
for (final item in content) {
|
|
if (item is Map<String, dynamic>) {
|
|
final text = item['text'] as String?;
|
|
if (text != null && text.isNotEmpty) {
|
|
contentBuffer.write(text);
|
|
yield AskAiContentDelta(text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final toolCalls = delta['tool_calls'];
|
|
if (toolCalls is List) {
|
|
for (final toolCall in toolCalls) {
|
|
if (toolCall is! Map<String, dynamic>) continue;
|
|
final index = toolCall['index'] as int? ?? 0;
|
|
final builder = toolBuilders.putIfAbsent(index, _ToolCallBuilder.new);
|
|
final function = toolCall['function'];
|
|
if (function is Map<String, dynamic>) {
|
|
builder.name ??= function['name'] as String?;
|
|
final args = function['arguments'] as String?;
|
|
if (args != null && args.isNotEmpty) {
|
|
builder.arguments.write(args);
|
|
final command = builder.tryBuild();
|
|
if (command != null) {
|
|
commands.add(command);
|
|
yield AskAiToolSuggestion(command);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final finishReason = choice['finish_reason'];
|
|
if (finishReason == 'tool_calls') {
|
|
for (final builder in toolBuilders.values) {
|
|
final command = builder.tryBuild(force: true);
|
|
if (command != null) {
|
|
commands.add(command);
|
|
yield AskAiToolSuggestion(command);
|
|
}
|
|
}
|
|
toolBuilders.clear();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flush remaining buffer if [DONE] not received.
|
|
if (contentBuffer.isNotEmpty || commands.isNotEmpty) {
|
|
yield AskAiCompleted(
|
|
fullText: contentBuffer.toString(),
|
|
commands: List.unmodifiable(commands),
|
|
);
|
|
}
|
|
} catch (e, s) {
|
|
yield AskAiStreamError(e, s);
|
|
return;
|
|
}
|
|
}
|
|
|
|
Map<String, dynamic> _buildRequestBody({
|
|
required String model,
|
|
required String selection,
|
|
required List<AskAiMessage> conversation,
|
|
String? localeHint,
|
|
}) {
|
|
final promptBuffer = StringBuffer()
|
|
..writeln('你是一个 SSH 终端助手。')
|
|
..writeln('用户会提供一段终端输出或命令,请结合上下文给出解释。')
|
|
..writeln('当需要给出可执行命令时,调用 `recommend_shell` 工具,并提供简短描述。')
|
|
..writeln('仅在非常确定命令安全时才给出建议。');
|
|
|
|
if (localeHint != null && localeHint.isNotEmpty) {
|
|
promptBuffer
|
|
.writeln('请优先使用用户的语言输出:$localeHint。');
|
|
}
|
|
|
|
final messages = <Map<String, String>>[
|
|
{
|
|
'role': 'system',
|
|
'content': promptBuffer.toString(),
|
|
},
|
|
...conversation.map((message) => {
|
|
'role': message.apiRole,
|
|
'content': message.content,
|
|
}),
|
|
{
|
|
'role': 'user',
|
|
'content': '以下是终端选中的内容:\n$selection',
|
|
},
|
|
];
|
|
|
|
return {
|
|
'model': model,
|
|
'stream': true,
|
|
'messages': messages,
|
|
'tools': [
|
|
{
|
|
'type': 'function',
|
|
'function': {
|
|
'name': 'recommend_shell',
|
|
'description': '返回一个用户可以直接复制执行的终端命令。',
|
|
'parameters': {
|
|
'type': 'object',
|
|
'required': ['command'],
|
|
'properties': {
|
|
'command': {
|
|
'type': 'string',
|
|
'description': '完整的终端命令,确保可以被粘贴后直接执行。',
|
|
},
|
|
'description': {
|
|
'type': 'string',
|
|
'description': '简述该命令的作用或注意事项。',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
Uri _composeUri(String base, String path) {
|
|
final sanitizedBase = base.replaceAll(RegExp(r'/+$'), '');
|
|
final sanitizedPath = path.replaceFirst(RegExp(r'^/+'), '');
|
|
return Uri.parse('$sanitizedBase/$sanitizedPath');
|
|
}
|
|
}
|
|
|
|
class _ToolCallBuilder {
|
|
_ToolCallBuilder();
|
|
|
|
final StringBuffer arguments = StringBuffer();
|
|
String? name;
|
|
bool _emitted = false;
|
|
|
|
AskAiCommand? tryBuild({bool force = false}) {
|
|
if (_emitted && !force) return null;
|
|
final raw = arguments.toString();
|
|
try {
|
|
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
|
final command = decoded['command'] as String?;
|
|
if (command == null || command.trim().isEmpty) {
|
|
if (force) {
|
|
_emitted = true;
|
|
}
|
|
return null;
|
|
}
|
|
final description = decoded['description'] as String? ?? decoded['explanation'] as String? ?? '';
|
|
_emitted = true;
|
|
return AskAiCommand(
|
|
command: command.trim(),
|
|
description: description.trim(),
|
|
toolName: name,
|
|
);
|
|
} on FormatException {
|
|
if (force) {
|
|
_emitted = true;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
@immutable
|
|
enum AskAiConfigField { baseUrl, apiKey, model }
|
|
|
|
class AskAiConfigException implements Exception {
|
|
const AskAiConfigException({this.missingFields = const [], this.invalidBaseUrl});
|
|
|
|
final List<AskAiConfigField> missingFields;
|
|
final String? invalidBaseUrl;
|
|
|
|
bool get hasInvalidBaseUrl => (invalidBaseUrl ?? '').isNotEmpty;
|
|
|
|
@override
|
|
String toString() {
|
|
final parts = <String>[];
|
|
if (missingFields.isNotEmpty) {
|
|
parts.add('missing: ${missingFields.map((e) => e.name).join(', ')}');
|
|
}
|
|
if (hasInvalidBaseUrl) {
|
|
parts.add('invalidBaseUrl: $invalidBaseUrl');
|
|
}
|
|
if (parts.isEmpty) {
|
|
return 'AskAiConfigException()';
|
|
}
|
|
return 'AskAiConfigException(${parts.join('; ')})';
|
|
}
|
|
}
|
|
|
|
@immutable
|
|
class AskAiNetworkException implements Exception {
|
|
const AskAiNetworkException({required this.message, this.cause});
|
|
|
|
final String message;
|
|
final Object? cause;
|
|
|
|
@override
|
|
String toString() => 'AskAiNetworkException(message: $message)';
|
|
}
|