diff --git a/integration_test/pdf_provider_test.dart b/integration_test/pdf_provider_test.dart index 17949a0..b5c9baf 100644 --- a/integration_test/pdf_provider_test.dart +++ b/integration_test/pdf_provider_test.dart @@ -21,7 +21,7 @@ class DummyOcrService implements OCRProvider { @override Future process(String pdfPath) async { return const OCRResult( - metadata: Metadata(title: 'test', author: 'test', pages: 0), + metadata: Metadata(title: 'test', pages: 0), pages: [], figures: [], ); diff --git a/lib/features/pdf_viewer/screens/pdf_viewer.dart b/lib/features/pdf_viewer/screens/pdf_viewer.dart index eb33eaa..0f96139 100644 --- a/lib/features/pdf_viewer/screens/pdf_viewer.dart +++ b/lib/features/pdf_viewer/screens/pdf_viewer.dart @@ -60,7 +60,7 @@ class _PDFViewerState extends State { if (layout.type != LayoutType.figure) return; final pageNumber = _viewModel!.getPageNumberForFigure(layout); if (pageNumber == null) return; - _pdfController.goToPage(pageNumber: pageNumber); + _pdfController.goToPage(pageNumber: pageNumber + 1); } void _showFigurePopover(BaseLayout reference, Offset tapPosition) { @@ -167,18 +167,7 @@ class _PDFViewerState extends State { listenable: _viewModel!, builder: (context, child) { return Scaffold( - appBar: AppBar( - title: Text(_viewModel!.pdfTitle), - actions: [ - IconButton( - onPressed: () { - Navigator.of(context).pushNamed('/ai-settings'); - }, - icon: const Icon(Icons.smart_toy), - tooltip: 'AI 설정', - ), - ], - ), + appBar: AppBar(title: Text(_viewModel!.pdfTitle)), body: Row( children: [ Expanded( diff --git a/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart b/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart index d42eac4..86d77ed 100644 --- a/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart +++ b/lib/features/pdf_viewer/widgets/sidebar/figure_sidebar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:snapfig/shared/services/pdf_core/pdf_core.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; class FigureSidebar extends StatelessWidget { final List figures; @@ -18,7 +19,7 @@ class FigureSidebar extends StatelessWidget { itemBuilder: (context, index) { return ListTile( leading: _buildThumbnail(figures[index], context: context), - title: Text(figures[index].figureId ?? ''), + title: GptMarkdown(figures[index].text ?? '', maxLines: 2), trailing: const Icon(Icons.chevron_right), onTap: () => onFigureSelected(figures[index]), ); diff --git a/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart b/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart index e79aae7..cbb9de0 100644 --- a/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart +++ b/lib/features/pdf_viewer/widgets/sidebar/pdf_sidebar.dart @@ -41,9 +41,13 @@ class _PDFSideBarState extends State { children: [ SegmentedButton<_PDFSideBarTab>( showSelectedIcon: true, - segments: const [ - ButtonSegment(value: _PDFSideBarTab.page, label: Text('Outline')), - ButtonSegment( + segments: [ + if (widget.viewModel.outlines.isNotEmpty) + const ButtonSegment( + value: _PDFSideBarTab.page, + label: Text('Outline'), + ), + const ButtonSegment( value: _PDFSideBarTab.figure, label: Text('Figure'), ), diff --git a/lib/features/pdf_viewer/widgets/simple_draggable_ai_chat.dart b/lib/features/pdf_viewer/widgets/simple_draggable_ai_chat.dart index 49cbd01..f4c910e 100644 --- a/lib/features/pdf_viewer/widgets/simple_draggable_ai_chat.dart +++ b/lib/features/pdf_viewer/widgets/simple_draggable_ai_chat.dart @@ -3,6 +3,7 @@ import 'package:snapfig/shared/services/ai_service/ai_service.dart'; import 'package:snapfig/shared/services/ai_service/models/ai_provider.dart'; import 'package:snapfig/shared/services/pdf_core/models/models.dart'; import 'package:snapfig/features/pdf_viewer/models/pdf_data_viewmodel.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; class Message { final String content; @@ -49,9 +50,17 @@ class _SimpleDraggableAIChatState extends State late AnimationController _animationController; late Animation _scaleAnimation; bool _isDragging = false; + bool _isResizing = false; - static const double _chatWidth = 320.0; - static const double _chatHeight = 400.0; + // Size variables + double _chatWidth = 400.0; + double _chatHeight = 500.0; + + // Size constraints + static const double _minWidth = 300.0; + static const double _maxWidth = 600.0; + static const double _minHeight = 400.0; + static const double _maxHeight = 800.0; @override void initState() { @@ -203,36 +212,109 @@ class _SimpleDraggableAIChatState extends State _position = _snapToEdges(_position, screenSize); }); }, - child: Container( - width: _chatWidth, - height: _chatHeight, - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: - _isDragging - ? theme.colorScheme.primary.withValues(alpha: 0.5) - : theme.colorScheme.outline.withValues(alpha: 0.2), - width: _isDragging ? 2 : 1, + child: Stack( + children: [ + Container( + width: _chatWidth, + height: _chatHeight, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + _isDragging || _isResizing + ? theme.colorScheme.primary.withValues(alpha: 0.5) + : theme.colorScheme.outline.withValues( + alpha: 0.2, + ), + width: _isDragging || _isResizing ? 2 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues( + alpha: _isDragging || _isResizing ? 0.3 : 0.15, + ), + blurRadius: _isDragging || _isResizing ? 20 : 10, + offset: Offset(0, _isDragging || _isResizing ? 8 : 4), + ), + ], + ), + child: Column( + children: [ + _buildDraggableHeader(context), + Expanded(child: _buildMessageList(context)), + _buildInputSection(context), + ], + ), ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues( - alpha: _isDragging ? 0.3 : 0.15, + // Resize handle + Positioned( + right: 0, + bottom: 0, + child: GestureDetector( + onPanStart: (details) { + setState(() { + _isResizing = true; + }); + }, + onPanUpdate: (details) { + setState(() { + // Update size based on drag delta + _chatWidth = (_chatWidth + details.delta.dx).clamp( + _minWidth, + _maxWidth, + ); + _chatHeight = (_chatHeight + details.delta.dy).clamp( + _minHeight, + _maxHeight, + ); + + // Ensure window stays within screen bounds after resizing + final screenSize = MediaQuery.of(context).size; + if (_position.dx + _chatWidth > screenSize.width) { + _position = Offset( + screenSize.width - _chatWidth, + _position.dy, + ); + } + if (_position.dy + _chatHeight > screenSize.height) { + _position = Offset( + _position.dx, + screenSize.height - _chatHeight, + ); + } + }); + }, + onPanEnd: (details) { + setState(() { + _isResizing = false; + }); + }, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: + _isResizing + ? theme.colorScheme.primary + : theme.colorScheme.surfaceVariant, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4), + bottomRight: Radius.circular(16), + ), + ), + child: Icon( + Icons.drag_handle, + size: 12, + color: + _isResizing + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurfaceVariant, + ), ), - blurRadius: _isDragging ? 20 : 10, - offset: Offset(0, _isDragging ? 8 : 4), ), - ], - ), - child: Column( - children: [ - _buildDraggableHeader(context), - Expanded(child: _buildMessageList(context)), - _buildInputSection(context), - ], - ), + ), + ], ), ), ), @@ -245,7 +327,7 @@ class _SimpleDraggableAIChatState extends State final configurations = AIService.instance.configurations; return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: _isDragging @@ -270,17 +352,8 @@ class _SimpleDraggableAIChatState extends State ), Row( children: [ - Icon( - Icons.smart_toy, - color: - _isDragging - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.primary, - size: 20, - ), - const SizedBox(width: 8), Text( - 'AI Assistant', + 'Assistant', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: @@ -405,18 +478,18 @@ class _SimpleDraggableAIChatState extends State Text( 'Thinking...', style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + color: theme.colorScheme.onSecondaryContainer, ), ), ], ) - : Text( + : GptMarkdown( message.content, style: theme.textTheme.bodyMedium?.copyWith( color: message.isUser ? theme.colorScheme.onPrimary - : theme.colorScheme.onSurface, + : theme.colorScheme.onSecondaryContainer, ), ), ), @@ -559,18 +632,7 @@ class _SimpleDraggableAIChatState extends State ), Row( children: [ - Icon( - Icons.smart_toy, - color: theme.colorScheme.primary, - size: 20, - ), - const SizedBox(width: 8), - Text( - 'AI Assistant', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), + Text('Assistant', style: theme.textTheme.titleMedium), const Spacer(), IconButton( onPressed: _closeWithAnimation, diff --git a/lib/main.dart b/lib/main.dart index 6143671..c8d0fbe 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,8 +2,10 @@ // lib/main.dart +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; import 'package:snapfig/features/home/screens/home_widget.dart'; import 'package:snapfig/features/settings/ai_settings_screen.dart'; @@ -14,6 +16,14 @@ import 'core/theme/theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + if (kDebugMode) { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + debugPrint( + '(${record.time}) ${record.level.name} - ${record.loggerName}: ${record.message}', + ); + }); + } await dotenv.load(fileName: '.env'); final dir = await getApplicationCacheDirectory(); final pdfProvider = await PDFProviderImpl.load( diff --git a/lib/shared/services/ocr_core/models/bounding_box.dart b/lib/shared/services/ocr_core/models/bounding_box.dart index 7d81aec..86440a4 100644 --- a/lib/shared/services/ocr_core/models/bounding_box.dart +++ b/lib/shared/services/ocr_core/models/bounding_box.dart @@ -15,13 +15,12 @@ class BBox { return {'x': x, 'y': y, 'width': width, 'height': height}; } - factory BBox.fromJson(Map json) { - return BBox( - x: (json['x'] as num).toDouble(), - y: (json['y'] as num).toDouble(), - width: (json['width'] as num).toDouble(), - height: (json['height'] as num).toDouble(), - ); + factory BBox.fromJson(List json) { + final x1 = (json[0] as num).toDouble(); + final y1 = (json[1] as num).toDouble(); + final x2 = (json[2] as num).toDouble(); + final y2 = (json[3] as num).toDouble(); + return BBox(x: x1, y: y1, width: x2 - x1, height: y2 - y1); } @override diff --git a/lib/shared/services/ocr_core/models/metadata.dart b/lib/shared/services/ocr_core/models/metadata.dart index 10474d0..beee608 100644 --- a/lib/shared/services/ocr_core/models/metadata.dart +++ b/lib/shared/services/ocr_core/models/metadata.dart @@ -1,23 +1,17 @@ class Metadata { final String title; - final String author; final int pages; - const Metadata({ - required this.title, - required this.author, - required this.pages, - }); + const Metadata({required this.title, required this.pages}); factory Metadata.fromJson(Map json) { return Metadata( - title: json['title'] as String, - author: json['author'] as String, - pages: (json['pages'] as num).toInt(), + title: json['filename'] as String, + pages: (json['total_pages'] as num).toInt(), ); } Map toJson() { - return {'title': title, 'author': author, 'pages': pages}; + return {'title': title, 'pages': pages}; } } diff --git a/lib/shared/services/ocr_core/models/ocr_result.dart b/lib/shared/services/ocr_core/models/ocr_result.dart index ae0cb8c..f9b9a33 100644 --- a/lib/shared/services/ocr_core/models/ocr_result.dart +++ b/lib/shared/services/ocr_core/models/ocr_result.dart @@ -85,7 +85,7 @@ class TextResult { factory TextResult.fromJson(Map json) { return TextResult( text: json['text'] as String, - bbox: BBox.fromJson(json['bbox'] as Map), + bbox: BBox.fromJson(json['bbox'] as List), ); } @@ -109,7 +109,7 @@ class Reference { factory Reference.fromJson(Map json) { return Reference( - bbox: BBox.fromJson(json['bbox'] as Map), + bbox: BBox.fromJson(json['bbox'] as List), text: json['text'] as String, figureId: json['figure_id'] as String? ?? '', notMatched: json['not_matched'] as bool? ?? false, @@ -143,10 +143,10 @@ class Figure { factory Figure.fromJson(Map json) { return Figure( - bbox: BBox.fromJson(json['bbox'] as Map), + bbox: BBox.fromJson(json['bbox'] as List), pageIdx: (json['page_idx'] as num).toInt(), - figureId: json['figure_id'] as String? ?? '', - type: json['type'] as String, + figureId: json['id'] as String? ?? '', + type: json['block_type'] as String, text: json['text'] as String, ); } @@ -155,8 +155,8 @@ class Figure { return { 'bbox': bbox.toJson(), 'page_idx': pageIdx, - 'figure_id': figureId, - 'type': type, + 'id': figureId, + 'block_type': type, 'text': text, }; } diff --git a/lib/shared/services/ocr_core/ocr_provider_impl.dart b/lib/shared/services/ocr_core/ocr_provider_impl.dart index bae2e02..90644e5 100644 --- a/lib/shared/services/ocr_core/ocr_provider_impl.dart +++ b/lib/shared/services/ocr_core/ocr_provider_impl.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:http/io_client.dart'; +import 'package:snapfig/shared/services/ocr_core/ocr_core.dart'; import 'package:snapfig/shared/services/ocr_core/ocr_provider.dart'; import 'package:snapfig/shared/services/ocr_core/models/ocr_result.dart'; @@ -21,28 +23,139 @@ class OCRProviderImpl extends OCRProvider { static http.Client _createHttpClient() { final httpClient = HttpClient(); httpClient.badCertificateCallback = (cert, host, port) => true; + + // 연결 안정성을 위한 설정 + httpClient.connectionTimeout = const Duration(minutes: 5); + httpClient.idleTimeout = const Duration(minutes: 35); + + // 긴 스트림 처리를 위한 추가 설정 + httpClient.maxConnectionsPerHost = 10; + httpClient.autoUncompress = false; // 압축 해제 비활성화로 스트림 안정성 향상 + + // User-Agent 설정 + httpClient.userAgent = 'Snapfig/1.0'; + return IOClient(httpClient); } @override Future process(String pdfPath) async { - // 1) 분석 요청 - final analyzeUri = Uri.parse('$baseUrl/api/v1/analyze'); - final pdfBytes = await File(pdfPath).readAsBytes(); - final request = http.MultipartRequest('POST', analyzeUri) - ..files.add( - http.MultipartFile.fromBytes( - 'file', - pdfBytes, - filename: path_lib.basename(pdfPath), - ), + debugPrint('OCR 처리 시작: $pdfPath'); + debugPrint('OCR API URL: $baseUrl/analyze'); + + try { + // 1) 분석 요청 + final analyzeUri = Uri.parse('$baseUrl/analyze'); + final pdfBytes = await File(pdfPath).readAsBytes(); + debugPrint('PDF 파일 크기: ${pdfBytes.length} bytes'); + + final request = + http.MultipartRequest('POST', analyzeUri) + ..headers['Accept'] = 'text/event-stream' + ..headers['Cache-Control'] = 'no-cache' + ..headers['Connection'] = 'keep-alive' + ..headers['Keep-Alive'] = + 'timeout=2100, max=1000' // 35분 타임아웃 + ..files.add( + http.MultipartFile.fromBytes( + 'file', + pdfBytes, + filename: path_lib.basename(pdfPath), + ), + ); + + debugPrint('OCR API 요청 전송 중...'); + final response = await httpClient.send(request); + + debugPrint('OCR API 응답 상태: ${response.statusCode}'); + debugPrint('OCR API 응답 헤더: ${response.headers}'); + + if (response.statusCode != 200) { + debugPrint('OCR API 요청 실패: ${response.statusCode}'); + throw Exception('OCR API 요청 실패: HTTP ${response.statusCode}'); + } + + debugPrint('OCR Stream 수신 시작'); + + String buffer = ''; + int chunkCount = 0; + DateTime lastChunkTime = DateTime.now(); + + await for (final chunk in response.stream.transform(utf8.decoder)) { + chunkCount++; + lastChunkTime = DateTime.now(); + debugPrint( + '청크 #$chunkCount 수신: ${chunk.length} characters (${lastChunkTime.toIso8601String()})', + ); + debugPrint('청크 내용: "$chunk"'); + + buffer += chunk; + + // 줄바꿈으로 분리하여 각 메시지 처리 + final lines = buffer.split('\n'); + buffer = lines.removeLast(); // 마지막 불완전한 라인은 버퍼에 보관 + + for (final line in lines) { + final trimmed = line.trim(); + if (trimmed.isEmpty) { + debugPrint('빈 라인 건너뛰기'); + continue; + } + + debugPrint('라인 처리: "$trimmed"'); + + if (!trimmed.startsWith('data: ')) { + debugPrint('올바르지 않은 메시지 형식: $trimmed'); + continue; + } + + final jsonStr = trimmed.substring(6); + if (jsonStr.isEmpty) { + debugPrint('빈 JSON 문자열 건너뛰기'); + continue; + } + + debugPrint('JSON 파싱 시도: $jsonStr'); + + try { + final json = jsonDecode(jsonStr) as Map; + final status = json['status'] as String; + + debugPrint('JSON 파싱 성공, 상태: $status'); + + if (status == 'completed') { + final data = json['data'] as Map; + debugPrint('OCR 처리 완료'); + final result = OCRResult.fromJson(data); + return result; + } else if (status == 'error') { + final message = json['message'] as String? ?? '알 수 없는 오류'; + debugPrint('OCR 처리 실패: $message'); + throw Exception('OCR 처리 실패: $message'); + } else { + debugPrint('OCR 진행 중: $status'); + } + } catch (e) { + debugPrint('JSON 파싱 오류: $e'); + debugPrint('문제가 된 데이터: "$jsonStr"'); + // JSON 파싱 오류가 있어도 계속 진행 + continue; + } + } + } + + debugPrint('스트림 처리 완료, 총 $chunkCount 개 청크 처리'); + return const OCRResult( + metadata: Metadata(title: '', pages: 0), + pages: [], + figures: [], ); - final response = await httpClient.send(request); - final analyzeRes = await http.Response.fromStream(response); - if (analyzeRes.statusCode != 200) { - throw Exception('OCR API 요청 실패: HTTP ${analyzeRes.statusCode}'); + } catch (e) { + debugPrint('OCR 처리 중 오류 발생: $e'); + if (e.toString().contains('Connection closed')) { + throw Exception('서버 연결이 끊어졌습니다. 서버 상태를 확인해주세요.'); + } + rethrow; } - final bodyJson = json.decode(analyzeRes.body) as Map; - return OCRResult.fromJson(bodyJson); } } diff --git a/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart b/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart index 6644db1..20b7676 100644 --- a/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart +++ b/lib/shared/services/pdf_core/provider/pdf_provider_impl.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'dart:ui'; +import 'package:flutter/material.dart'; import 'package:isar/isar.dart'; import 'package:snapfig/shared/services/ocr_core/ocr_core.dart'; import 'package:snapfig/shared/services/pdf_core/models/models.dart'; @@ -110,6 +111,7 @@ class PDFProviderImpl extends PDFProvider { final ocrResult = await ocrProvider.process(pdfPath); sendPort.send(ocrResult); } catch (e) { + debugPrint('Fail: $e'); sendPort.send(null); } } diff --git a/pubspec.yaml b/pubspec.yaml index afb66b3..9666555 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: http: ^1.4.0 flutter_client_sse: ^2.0.3 flutter_dotenv: ^5.1.0 + gpt_markdown: ^1.0.20 dev_dependencies: flutter_test: @@ -68,6 +69,9 @@ dev_dependencies: build_runner: ^2.4.13 mockito: ^5.4.4 +dependency_overrides: + flutter_math_fork: 0.7.3 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -86,6 +90,7 @@ flutter: - assets/sample.pdf - assets/sample_ocr_test.pdf - assets/ocr_result.json + - .env # An image asset can refer to one or more resolution-specific "variants", see