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((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 ask({ required String selection, String? localeHint, List conversation = const [], }) async* { final baseUrl = _settings.askAiBaseUrl.fetch().trim(); final apiKey = _settings.askAiApiKey.fetch().trim(); final model = _settings.askAiModel.fetch().trim(); final missing = []; 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 = { Headers.acceptHeader: 'text/event-stream', Headers.contentTypeHeader: Headers.jsonContentType, 'Authorization': authHeader, }; final requestBody = _buildRequestBody( model: model, selection: selection, localeHint: localeHint, conversation: conversation, ); Response response; try { response = await _dio.postUri( 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 = []; final toolBuilders = {}; final utf8Stream = body.stream.cast>().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 json; try { json = jsonDecode(payload) as Map; } 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) { continue; } final delta = choice['delta']; if (delta is Map) { 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) { 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) continue; final index = toolCall['index'] as int? ?? 0; final builder = toolBuilders.putIfAbsent(index, _ToolCallBuilder.new); final function = toolCall['function']; if (function is Map) { 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 _buildRequestBody({ required String model, required String selection, required List conversation, String? localeHint, }) { final promptBuffer = StringBuffer() ..writeln('你是一个 SSH 终端助手。') ..writeln('用户会提供一段终端输出或命令,请结合上下文给出解释。') ..writeln('当需要给出可执行命令时,调用 `recommend_shell` 工具,并提供简短描述。') ..writeln('仅在非常确定命令安全时才给出建议。'); if (localeHint != null && localeHint.isNotEmpty) { promptBuffer .writeln('请优先使用用户的语言输出:$localeHint。'); } final messages = >[ { '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; 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 missingFields; final String? invalidBaseUrl; bool get hasInvalidBaseUrl => (invalidBaseUrl ?? '').isNotEmpty; @override String toString() { final parts = []; 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)'; }