diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 4268797..0567ed6 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -64,6 +64,7 @@ "AI 그림 저장 폴더": "Folder for AI images", "AI 그림: {0}": "AI images: {0}", "AI 그림으로 (이 그림을 바탕으로)": "To AI images (based on this picture)", + "AI 모델 받는 중: {0}": "Downloading AI model: {0}", "AI 번역": "AI translation", "AI 설정 · 모델 받기 · 서비스 추가": "AI settings · download models · add services", "AI 엔진이 실패했습니다 ({0}): {1}": "The AI engine failed ({0}): {1}", @@ -559,6 +560,7 @@ "다운로딩 {0}{1}": "Downloading {0}{1}", "다음": "Next", "다음 (N)": "Next (N)", + "다음 {0}개 기다림": "{0} more waiting", "다음 페이지 (Alt+→)": "Next page (Alt+→)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "Don't ask again (can be changed in Settings > Run)", "단계": "Steps", @@ -766,6 +768,7 @@ "받을 수 없습니다 ({0}): {1}": "Can't download ({0}): {1}", "받음": "Downloaded", "받음 {0}": "Received {0}", + "받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "Couldn't download. Tap [Resume] in a moment", "받지 못했습니다: {0}": "Could not download: {0}", "밝게": "Brighter", "밝기": "Brightness", @@ -1064,6 +1067,8 @@ "연결 실패: {0}": "Connection failed: {0}", "연결 확인": "Test connection", "연결됨: 항목 {0}개{1}": "Connected: {0} items{1}", + "연결이 끊겨 다시 잇는 중 ({0}번째)": "Connection lost, reconnecting (attempt {0})", + "연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "The connection was lost. Tap [Resume] to continue from where it stopped", "연도": "Year", "열 수 없습니다: {0}": "Cannot open: {0}", "열기": "Open", @@ -1268,6 +1273,7 @@ "이미지 자막은 편집할 수 없습니다": "Image subtitles cannot be edited", "이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "Passwords entered this session will be lost when the app closes (they are not written to the settings file in plain text). Try pressing [Try again].", "이번 업데이트로 바뀐 기본 설정": "Default settings changed by this update", + "이어 받기": "Resume", "이전": "Previous", "이전 (P)": "Previous (P)", "이전 설정을 되살렸습니다": "Previous settings restored", @@ -1510,6 +1516,7 @@ "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "A job is in progress: {0}\nStop it and quit?", "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "If it uses a self-signed certificate (like a home NAS), turn on \"Don't verify certificate\" in [Edit server settings].", "짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "Shorter means faster syncing but more battery use.", + "차례 기다림": "Waiting in queue", "창": "Panes", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "Icon of the window, taskbar, tray and the top-left button in the app (the program file and desktop shortcut icons stay the same)", "창 배치": "Pane layout", @@ -1684,6 +1691,8 @@ "필수 프로그램 점검": "Check required programs", "필수 프로그램이 모두 설치되어 있습니다.": "All required programs are installed.", "필요한 것 모두 받기": "Download everything needed", + "필요한 공간 {0}": "Space needed {0}", + "필요한 공간 {0} · 남은 공간 {1}": "Space needed {0} · free {1}", "하위 폴더가 없습니다": "No subfolders", "한 번 더": "Once more", "한 번: 바로 실행": "Single: open right away", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 1ba0dd7..d82d136 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -64,6 +64,7 @@ "AI 그림 저장 폴더": "AI 画像の保存フォルダー", "AI 그림: {0}": "AI 画像: {0}", "AI 그림으로 (이 그림을 바탕으로)": "AI 画像へ (この画像を元に)", + "AI 모델 받는 중: {0}": "AI モデルをダウンロード中: {0}", "AI 번역": "AI 翻訳", "AI 설정 · 모델 받기 · 서비스 추가": "AI 設定・モデルのダウンロード・サービス追加", "AI 엔진이 실패했습니다 ({0}): {1}": "AI エンジンが失敗しました ({0}): {1}", @@ -559,6 +560,7 @@ "다운로딩 {0}{1}": "ダウンロード中 {0}{1}", "다음": "次へ", "다음 (N)": "次へ(N)", + "다음 {0}개 기다림": "次の {0} 件が待機中", "다음 페이지 (Alt+→)": "次のページ(Alt+→)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "次回から確認しない(環境設定 > 実行 で変更できます)", "단계": "ステップ", @@ -766,6 +768,7 @@ "받을 수 없습니다 ({0}): {1}": "ダウンロードできません ({0}): {1}", "받음": "ダウンロード済み", "받음 {0}": "受信 {0}", + "받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "ダウンロードできませんでした。少し待ってから [続きをダウンロード] を押してください", "받지 못했습니다: {0}": "ダウンロードできませんでした: {0}", "밝게": "明るく", "밝기": "明るさ", @@ -1064,6 +1067,8 @@ "연결 실패: {0}": "接続失敗: {0}", "연결 확인": "接続テスト", "연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}", + "연결이 끊겨 다시 잇는 중 ({0}번째)": "接続が切れたため再接続中 ({0} 回目)", + "연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "接続が切れました。[続きをダウンロード] を押すと続きからダウンロードします", "연도": "年", "열 수 없습니다: {0}": "開けません: {0}", "열기": "開く", @@ -1268,6 +1273,7 @@ "이미지 자막은 편집할 수 없습니다": "画像字幕は編集できません", "이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "今回入力したパスワードはアプリを終了すると消えます (設定ファイルに平文で書きません)。[再試行] を押してみてください。", "이번 업데이트로 바뀐 기본 설정": "今回のアップデートで変わった既定の設定", + "이어 받기": "続きをダウンロード", "이전": "前へ", "이전 (P)": "前へ(P)", "이전 설정을 되살렸습니다": "以前の設定を復元しました", @@ -1510,6 +1516,7 @@ "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "進行中の作業があります: {0}\n中止して終了しますか?", "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。", "짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "短いほど早く同期しますが、バッテリーをより使います。", + "차례 기다림": "順番待ち", "창": "ペイン", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "ウィンドウ・タスクバー・トレイとアプリ内左上のボタンのアイコンです(実行ファイル・デスクトップのショートカットのアイコンはそのまま)", "창 배치": "ペインの配置", @@ -1684,6 +1691,8 @@ "필수 프로그램 점검": "必須プログラムの点検", "필수 프로그램이 모두 설치되어 있습니다.": "必須プログラムはすべてインストールされています。", "필요한 것 모두 받기": "必要なものをすべてダウンロード", + "필요한 공간 {0}": "必要な容量 {0}", + "필요한 공간 {0} · 남은 공간 {1}": "必要な容量 {0}・空き容量 {1}", "하위 폴더가 없습니다": "サブフォルダーがありません", "한 번 더": "もう一度", "한 번: 바로 실행": "1 回: すぐに開く", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 6f8e317..0b753d4 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -64,6 +64,7 @@ "AI 그림 저장 폴더": "AI 图片保存文件夹", "AI 그림: {0}": "AI 图片: {0}", "AI 그림으로 (이 그림을 바탕으로)": "转到 AI 图片 (以此图片为基础)", + "AI 모델 받는 중: {0}": "正在下载 AI 模型: {0}", "AI 번역": "AI 翻译", "AI 설정 · 모델 받기 · 서비스 추가": "AI 设置 · 下载模型 · 添加服务", "AI 엔진이 실패했습니다 ({0}): {1}": "AI 引擎失败 ({0}): {1}", @@ -559,6 +560,7 @@ "다운로딩 {0}{1}": "正在下载 {0}{1}", "다음": "下一个", "다음 (N)": "下一个(N)", + "다음 {0}개 기다림": "另有 {0} 个等待中", "다음 페이지 (Alt+→)": "下一页(Alt+→)", "다음부터 묻지 않기 (환경 설정 > 실행 에서 바꿀 수 있음)": "下次不再询问(可在 设置 > 运行 中更改)", "단계": "步数", @@ -766,6 +768,7 @@ "받을 수 없습니다 ({0}): {1}": "无法下载 ({0}): {1}", "받음": "已下载", "받음 {0}": "已接收 {0}", + "받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요": "下载失败。请稍后点按 [继续下载]", "받지 못했습니다: {0}": "无法下载: {0}", "밝게": "调亮", "밝기": "亮度", @@ -1064,6 +1067,8 @@ "연결 실패: {0}": "连接失败: {0}", "연결 확인": "测试连接", "연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}", + "연결이 끊겨 다시 잇는 중 ({0}번째)": "连接中断,正在重新连接 (第 {0} 次)", + "연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "连接已中断。点按 [继续下载] 将从中断处继续", "연도": "年份", "열 수 없습니다: {0}": "无法打开: {0}", "열기": "打开", @@ -1268,6 +1273,7 @@ "이미지 자막은 편집할 수 없습니다": "图像字幕无法编辑", "이번 실행에서 넣은 비밀번호는 앱을 끄면 사라집니다 (설정 파일에 평문으로 쓰지 않습니다). [다시 시도] 를 눌러 보세요.": "本次输入的密码在关闭应用后会丢失 (不会以明文写入设置文件)。请尝试按 [重试]。", "이번 업데이트로 바뀐 기본 설정": "本次更新更改的默认设置", + "이어 받기": "继续下载", "이전": "上一个", "이전 (P)": "上一个(P)", "이전 설정을 되살렸습니다": "已恢复之前的设置", @@ -1510,6 +1516,7 @@ "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "有正在进行的任务: {0}\n要停止并退出吗?", "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。", "짧을수록 빨리 맞추지만 배터리를 더 씁니다.": "间隔越短同步越快,但更耗电。", + "차례 기다림": "排队等待", "창": "窗格", "창 · 작업 표시줄 · 트레이와 앱 안 왼쪽 위 버튼의 아이콘입니다 (실행 파일 · 바탕 화면 바로 가기 아이콘은 그대로)": "窗口、任务栏、托盘以及应用内左上角按钮的图标(程序文件和桌面快捷方式的图标不变)", "창 배치": "窗格布局", @@ -1684,6 +1691,8 @@ "필수 프로그램 점검": "检查必需程序", "필수 프로그램이 모두 설치되어 있습니다.": "所有必需程序均已安装。", "필요한 것 모두 받기": "下载全部所需文件", + "필요한 공간 {0}": "所需空间 {0}", + "필요한 공간 {0} · 남은 공간 {1}": "所需空间 {0} · 剩余空间 {1}", "하위 폴더가 없습니다": "没有子文件夹", "한 번 더": "再输入一次", "한 번: 바로 실행": "单击: 立即打开", diff --git a/lib/app/ai_local.dart b/lib/app/ai_local.dart index 5dd17ca..c11c679 100644 --- a/lib/app/ai_local.dart +++ b/lib/app/ai_local.dart @@ -39,36 +39,96 @@ class AiStore extends ChangeNotifier { bool isFileInstalled(AiFile f) => _mark(f).existsSync() && FileSystemEntity.typeSync(pathOf(f)) != FileSystemEntityType.notFound; + /// 받는 중인 것 (id → 0~1, 모르면 null) /// 받는 중인 것 (id → 0~1, 모르면 null) final progress = {}; + + /// 받은 바이트 · 전체 · 이번에 받기 시작한 때와 그때 이미 있던 바이트 (속도 · 남은 시간) + final received = {}; + final totals = {}; + final _started = {}; + + /// [installAll] 로 차례를 기다리는 것 (149-②: "차례 기다림") + final queued = {}; + + /// 연결이 끊겨 다시 잇는 중 (몇 번째) + final retrying = {}; + + /// 마지막 실패 (id → 쉬운 말). 다시 받으면 지운다 + final failures = {}; final _cancel = {}; - void cancel(String id) => _cancel.add(id); + void cancel(String id) { + _cancel.add(id); + queued.remove(id); + notifyListeners(); + } /// 받기 (SHA-256 확인). 저장 공간이 모자라면 먼저 알린다. zip (실행 파일) 은 푼다. Future install(String id) => installFile(aiFile(id)); - /// [install] 의 본체 (시험은 카탈로그 밖의 파일로) - Future installFile(AiFile f) async { + /// 여러 개를 차례로 (기다리는 것은 [queued] 로 보인다). 하나가 실패하면 멈추고 알린다 + Future installAll(List files) async { + final todo = [for (final f in files) if (!isFileInstalled(f) && !progress.containsKey(f.id)) f]; + queued.addAll(todo.map((f) => f.id)); + notifyListeners(); + try { + for (final f in todo) { + if (!queued.contains(f.id)) continue; // 기다리는 동안 취소 + queued.remove(f.id); + await installFile(f); + } + } finally { + queued.removeAll(todo.map((f) => f.id)); + notifyListeners(); + } + } + + /// 받을 때 필요한 공간 (zip 은 푸는 자리까지) - 이미 받아 둔 조각만큼은 빼고 + int neededSpace(AiFile f) { + final part = _partOf(f); + final have = part.existsSync() ? part.lengthSync() : 0; + return (f.kind == 'engine' ? f.size * 3 : f.size) - have; + } + + File _partOf(AiFile f) => File('${pathOf(f)}.part${f.kind == 'engine' ? '.zip' : ''}'); + + /// [install] 의 본체 (시험은 카탈로그 밖의 파일로). + /// 149: 끊기면 받은 곳부터 이어 받고 (조각 파일을 남겨 둠), 몇 번 저절로 다시 잇는다. 실패는 쉬운 말로 (주소는 보이지 않음). + Future installFile(AiFile f, {int retries = 5, Duration retryWait = const Duration(seconds: 3)}) async { final id = f.id; if (progress.containsKey(id)) return; _cancel.remove(id); + failures.remove(id); await Directory(root).create(recursive: true); final free = await diskSpace(root); - // 받는 파일 + 푸는 자리 (zip 은 두 배쯤) 와 여유 200MB - final need = (f.kind == 'engine' ? f.size * 3 : f.size) + (200 << 20); + final need = neededSpace(f) + (200 << 20); // 여유 200MB if (free != null && free.$1 < need) { throw ImageAiException(trf('저장 공간이 모자랍니다: {0} 이 필요한데 {1} 남았습니다', [_gb(need), _gb(free.$1)])); } + final part = _partOf(f); progress[id] = 0; notifyListeners(); - final part = File('${pathOf(f)}.part${f.kind == 'engine' ? '.zip' : ''}'); try { await part.parent.create(recursive: true); - await _download(f, part, (d) { - progress[id] = d; - notifyListeners(); - }); + for (var attempt = 0;; attempt++) { + try { + await _download(f, part); + break; + } on ImageAiException { + rethrow; // 취소 · 손상 (다시 받아도 같음) + } catch (e) { + if (_cancel.contains(id) || attempt >= retries) { + final msg = tr('연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다'); + failures[id] = msg; + throw ImageAiException(msg); + } + retrying[id] = attempt + 1; + notifyListeners(); + await Future.delayed(retryWait * (attempt + 1)); + } + } + retrying.remove(id); if (f.kind == 'engine') { final dir = Directory(pathOf(f)); if (await dir.exists()) await dir.delete(recursive: true); @@ -81,13 +141,12 @@ class AiStore extends ChangeNotifier { } await _mark(f).parent.create(recursive: true); await _mark(f).writeAsString(jsonEncode({'sha256': f.sha256, 'installed': DateTime.now().toIso8601String()})); - } catch (_) { - try { - if (await part.exists()) await part.delete(); - } catch (_) {} - rethrow; } finally { progress.remove(id); + received.remove(id); + totals.remove(id); + _started.remove(id); + retrying.remove(id); notifyListeners(); } } @@ -100,33 +159,108 @@ class AiStore extends ChangeNotifier { final t = FileSystemEntity.typeSync(pathOf(f)); if (t == FileSystemEntityType.directory) await Directory(pathOf(f)).delete(recursive: true); if (t == FileSystemEntityType.file) await File(pathOf(f)).delete(); + try { + final part = _partOf(f); + if (part.existsSync()) part.deleteSync(); + } catch (_) {} notifyListeners(); } static String _gb(int b) => b >= 1 << 30 ? '${(b / (1 << 30)).toStringAsFixed(1)}GB' : '${(b / (1 << 20)).round()}MB'; static String sizeText(int b) => _gb(b); - Future _download(AiFile f, File out, void Function(double? d) onProgress) async { + /// 진행 한 줄: "58% · 2.3GB / 4.0GB · 남은 약 5분" (149-①) + String? progressText(String id) { + if (queued.contains(id)) return tr('차례 기다림'); + if (!progress.containsKey(id)) return null; + final got = received[id] ?? 0, total = totals[id] ?? 0; + final retry = retrying[id]; + final left = eta(id); + return [ + if (total > 0) '${(got * 100 / total).floor()}%', + if (total > 0) '${_gb(got)} / ${_gb(total)}', + if (left != null) trf('남은 약 {0}', [AiJobs.durationText(left)]), + if (retry != null) trf('연결이 끊겨 다시 잇는 중 ({0}번째)', [retry]), + ].join(' · '); + } + + /// 남은 시간 (이번에 받은 속도로) + Duration? eta(String id) { + final s = _started[id]; + final got = received[id], total = totals[id]; + if (s == null || got == null || total == null || total <= 0) return null; + final secs = DateTime.now().difference(s.$1).inMilliseconds / 1000; + final rate = (got - s.$2) / (secs <= 0 ? 1 : secs); + if (secs < 3 || rate <= 0) return null; + return Duration(seconds: ((total - got) / rate).round()); + } + + /// 받는 중인 것을 한 줄로 (작업 알림 - 화면이 꺼져도 계속 받는 동안) + String? get statusLine { + if (progress.isEmpty) return null; + final id = progress.keys.first; + final name = aiCatalog.where((f) => f.id == id).firstOrNull?.name ?? id; + final more = queued.length; + return '${trf('AI 모델 받는 중: {0}', [tr(name)])} · ${progressText(id) ?? ''}' + '${more > 0 ? ' · ${trf('다음 {0}개 기다림', [more])}' : ''}'; + } + + /// 조각 파일 [out] 이 있으면 그 뒤부터 (Range) 받는다. 서버가 이어 주지 않으면 처음부터. + /// SHA-256 은 이미 받은 조각까지 합쳐 확인한다. + Future _download(AiFile f, File out) async { + var have = out.existsSync() ? out.lengthSync() : 0; + if (have > f.size) { + out.deleteSync(); + have = 0; + } final req = await _http.getUrl(Uri.parse(f.url)); + if (have > 0) req.headers.set(HttpHeaders.rangeHeader, 'bytes=$have-'); final res = await req.close(); - if (res.statusCode != 200) throw ImageAiException(trf('받을 수 없습니다 ({0}): {1}', [res.statusCode, f.url])); - final total = res.contentLength > 0 ? res.contentLength : f.size; - final sink = out.openWrite(); + final whole = res.statusCode == 416 && have == f.size; // 이미 다 받아 둠 - 확인만 + if (whole) { + await res.drain(); + } else if (res.statusCode == 200) { + have = 0; // 이어 주지 않음 - 처음부터 + } else if (res.statusCode != 206) { + await res.drain(); + throw HttpException('status ${res.statusCode}'); // 다시 시도 대상 (주소는 사용자에게 보이지 않음) + } + final total = f.size; final hash = AccumulatorSink(); final hasher = sha256.startChunkedConversion(hash); - var done = 0; + if (have > 0) { + await for (final c in out.openRead(0, have)) { + hasher.add(c); + } + } + received[f.id] = have; + totals[f.id] = total; + _started[f.id] = (DateTime.now(), have); + notifyListeners(); + final sink = out.openWrite(mode: have > 0 ? FileMode.append : FileMode.write); + var done = have; + var lastNotify = DateTime.now(); try { - await for (final chunk in res) { - if (_cancel.contains(f.id)) throw ImageAiException(tr('취소했습니다')); - sink.add(chunk); - hasher.add(chunk); - done += chunk.length; - onProgress(total > 0 ? done / total : null); + if (!whole) { + await for (final chunk in res) { + if (_cancel.contains(f.id)) throw ImageAiException(tr('취소했습니다')); + sink.add(chunk); + hasher.add(chunk); + done += chunk.length; + received[f.id] = done; + progress[f.id] = total > 0 ? done / total : null; + if (DateTime.now().difference(lastNotify).inMilliseconds > 250) { + lastNotify = DateTime.now(); + notifyListeners(); + } + } } } finally { + await sink.flush(); await sink.close(); hasher.close(); } + if (done < total) throw HttpException('short read $done/$total'); // 끊김 - 이어 받기 if (hash.events.single.toString() != f.sha256) { await out.delete(); throw ImageAiException(trf('받은 파일이 손상되었습니다 (SHA-256 다름): {0}', [f.name])); @@ -408,6 +542,8 @@ class AiJobs extends ChangeNotifier { return '$t${remaining > 1 ? ' · ${trf('남은 {0}장', [remaining])}' : ''}$left'; } + static String durationText(Duration d) => _dur(d); + static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]); } diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 3999d4f..81c4aec 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -471,6 +471,9 @@ class AppSettings { /// 사용자가 추가한 그림 서버 · 서비스 (API 키는 안전 저장소에) List aiServices = []; + /// 라이선스 조건에 동의한 받는 파일 (149-④: 한 번 동의하면 다시 묻지 않음) + List aiAgreed = []; + /// 실시간 동기화 확인 간격 (초). Windows 는 바뀌면 바로, 그 밖은 이 간격으로 살핀다. int liveSyncIntervalSec = 30; @@ -640,6 +643,7 @@ class AppSettings { 'aiStrength': aiStrength, 'aiNegative': aiNegative, 'aiSaveDir': aiSaveDir, + 'aiAgreed': aiAgreed, 'aiServices': [ for (final x in aiServices) {...x.toJson(), if (plainSecrets) 'apiKey': x.apiKey}, ], @@ -818,6 +822,7 @@ class AppSettings { ..aiStrength = ((j['aiStrength'] as num?)?.toDouble() ?? 0.6).clamp(0, 1) ..aiNegative = j['aiNegative'] as String? ?? '' ..aiSaveDir = j['aiSaveDir'] as String? ?? '' + ..aiAgreed = [for (final x in (j['aiAgreed'] as List?) ?? const []) '$x'] ..aiServices = [ for (final x in (j['aiServices'] as List?) ?? const []) if (x is Map) AiService.fromJson(x), diff --git a/lib/platform/android/android_keep_alive.dart b/lib/platform/android/android_keep_alive.dart index f0f80ea..07907ff 100644 --- a/lib/platform/android/android_keep_alive.dart +++ b/lib/platform/android/android_keep_alive.dart @@ -32,6 +32,7 @@ class AndroidKeepAlive { LiveSync? _live; CopyCenter? _copies; + AiStore? _ai; bool? _background; /// 진행률 알림은 자주 바뀌므로 1초에 한 번만 보낸다 @@ -64,14 +65,21 @@ class AndroidKeepAlive { _copies = copies?..addListener(_changed); } final copying = copies?.activeCount ?? 0; - final ai = AiJobs.instance.statusLine; + // 149: AI 모델 받기도 화면을 끄거나 다른 앱으로 가도 계속 (진행 알림) + final store = AiStore.instance; + if (store != _ai) { + _ai?.removeListener(_changed); + _ai = store?..addListener(_changed); + } + final ai = [AiJobs.instance.statusLine, store?.statusLine].nonNulls.join(' · ').trim(); var (text, progress) = status(c.currentJob, c.busy, running, downloads.overallProgress, syncs: syncs, syncing: live?.anyRunning ?? false, copies: copying, stopped: live?.problems.length ?? 0); - if (ai != null) { + if (ai.isNotEmpty) { // 진행 알림에 남은 장 수와 예상 시간 (발열 · 배터리 때문에 쉬어 가지는 않는다 - 사용자 결정) text = text == null ? ai : '$ai · $text'; - if (!c.busy && running == 0) progress = ((AiJobs.instance.progress ?? 0) * 100).round().clamp(0, 100); + final p = AiJobs.instance.progress ?? store?.progress.values.firstOrNull; + if (!c.busy && running == 0 && p != null) progress = (p * 100).round().clamp(0, 100); } try { if (text == null) { diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index 8d1f3a4..4f06d50 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../app/ai_local.dart'; import '../app/app_controller.dart'; +import '../app/copy_center.dart' show diskSpace; import '../core/ai_catalog.dart'; import '../core/secret_gate.dart'; import '../l10n/tr.dart'; @@ -76,7 +77,8 @@ class _AiSettingsState extends State { /// 받기 전에 라이선스 조건 (있으면) 을 보여 주고 동의를 받는다 Future _agree(AiFile f) async { - if (f.terms == null) return true; + // 149-④: 한 번 동의했으면 다시 묻지 않는다 + if (f.terms == null || c.settings.aiAgreed.contains(f.id)) return true; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( @@ -96,19 +98,26 @@ class _AiSettingsState extends State { ], ), ); + if (ok == true) await c.updateSettings((x) => x.aiAgreed = {...x.aiAgreed, f.id}.toList()); return ok == true; } + /// 받기: 조건 동의를 먼저 모두 받은 뒤 차례로 (기다리는 것은 "차례 기다림") Future _install(List files) async { + final agreed = []; for (final f in files) { - if (store.isInstalled(f.id) || !await _agree(f)) continue; - try { - setState(() => _error = null); - await store.install(f.id); - } catch (e) { - if (mounted) setState(() => _error = '$e'); - return; - } + if (store.isFileInstalled(f)) continue; + if (await _agree(f)) agreed.add(f); + } + if (agreed.isEmpty) return; + try { + setState(() => _error = null); + await store.installAll(agreed); + } on ImageAiException catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + // 쉬운 말로만 (원문 · 주소는 보이지 않게) + if (mounted) setState(() => _error = tr('받지 못했습니다. 잠시 뒤 [이어 받기] 를 눌러 주세요')); } await _refresh(); } @@ -132,6 +141,24 @@ class _AiSettingsState extends State { ? null : FilledButton.tonal(onPressed: () => _install(missing), child: Text(tr('필요한 것 모두 받기'))), ), + // 149-③: 받기 전에 필요한 공간과 지금 남은 공간을 늘 한 줄로 + if (missing.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + child: FutureBuilder<(int, int)?>( + future: diskSpace(store.root), + builder: (context, snap) { + final need = missing.fold(0, (a, f) => a + store.neededSpace(f)); + final free = snap.data?.$1; + return Text( + free == null + ? trf('필요한 공간 {0}', [AiStore.sizeText(need)]) + : trf('필요한 공간 {0} · 남은 공간 {1}', [AiStore.sizeText(need), AiStore.sizeText(free)]), + style: TextStyle(fontSize: 12, color: free != null && free < need ? Colors.redAccent : JjColors.textDim), + ); + }, + ), + ), if (_error != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -232,6 +259,9 @@ class _AiSettingsState extends State { final installed = store.isInstalled(f.id); final prog = store.progress[f.id]; final busy = store.progress.containsKey(f.id); + final waiting = store.queued.contains(f.id); + // 받다가 끊겨 조각이 남아 있으면 [이어 받기] + final partial = !installed && !busy && store.neededSpace(f) < (f.kind == 'engine' ? f.size * 3 : f.size); return Padding( padding: const EdgeInsets.only(left: 16), child: SettingTile( @@ -239,10 +269,21 @@ class _AiSettingsState extends State { leading: Icon(installed ? Icons.check_circle : Icons.download_outlined, color: installed ? JjColors.success : JjColors.textDim, size: 20), title: Text(tr(f.name)), + // 149-①②: 진행 막대 옆에 % · 받은 크기 / 전체 · 남은 시간, 기다리는 것은 "차례 기다림" subtitle: busy - ? LinearProgressIndicator(value: prog) - : Text('${AiStore.sizeText(f.size)} · ${f.license}${installed ? ' · ${tr('받음')}' : ''}'), - trailing: busy + ? Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + LinearProgressIndicator(value: prog), + const SizedBox(height: 2), + Text(store.progressText(f.id) ?? '', style: const TextStyle(fontSize: 12)), + ]) + : Text([ + AiStore.sizeText(f.size), + f.license, + if (installed) tr('받음'), + if (waiting) tr('차례 기다림'), + ?store.failures[f.id], + ].join(' · ')), + trailing: busy || waiting ? TextButton(onPressed: () => store.cancel(f.id), child: Text(tr('취소'))) : installed ? TextButton( @@ -255,7 +296,7 @@ class _AiSettingsState extends State { } }, child: Text(tr('지우기'))) - : OutlinedButton(onPressed: () => _install([f]), child: Text(tr('받기'))), + : OutlinedButton(onPressed: () => _install([f]), child: Text(partial ? tr('이어 받기') : tr('받기'))), ), ); } diff --git a/test/ai_image_test.dart b/test/ai_image_test.dart index 0e2718c..972a87a 100644 --- a/test/ai_image_test.dart +++ b/test/ai_image_test.dart @@ -150,6 +150,101 @@ void main() { expect(tmp.listSync(recursive: true).whereType().where((f) => f.path.contains('.part')), isEmpty); }); + test('149: 끊기면 받은 곳부터 이어 받고 (Range), 서버가 이어 주지 않으면 처음부터 · 진행 글', () async { + final store = AiStore(tmp.path); + final body = List.generate(300000, (i) => i * 7 % 251); + final f = file('big', '/big', body); + // 첫 요청은 120000 바이트만 보내고 연결을 끊는다, 그다음은 Range 로 나머지 + final port = server.port; + await server.close(force: true); + final ranges = []; + server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + server.listen((req) async { + final range = req.headers.value(HttpHeaders.rangeHeader); + ranges.add(range); + if (range == null) { + final sock = await req.response.detachSocket(writeHeaders: false); + sock.add(utf8.encode('HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n')); + sock.add(body.sublist(0, 120000)); + await sock.flush(); + sock.destroy(); + return; + } + final from = int.parse(RegExp(r'bytes=(\d+)-').firstMatch(range)!.group(1)!); + req.response + ..statusCode = 206 + ..headers.set(HttpHeaders.contentRangeHeader, 'bytes $from-${body.length - 1}/${body.length}') + ..add(body.sublist(from)); + await req.response.close(); + }); + final texts = []; + void listen() { + final t = store.progressText('big'); + if (t != null && t.isNotEmpty) texts.add(t); + } + store.addListener(listen); + await store.installFile(f, retryWait: const Duration(milliseconds: 10)); + store.removeListener(listen); + expect(store.isFileInstalled(f), isTrue); + expect(File(store.pathOf(f)).readAsBytesSync(), body); + expect(ranges.first, isNull); + expect(ranges.last, 'bytes=120000-', reason: '받은 곳부터'); + expect(texts.any((t) => t.contains('%') && t.contains(' / ')), isTrue); + }); + + test('149: 여러 번 끊기면 쉬운 말 (주소 없음) 로 알리고 조각은 남겨 [이어 받기] 로 이어 받음', () async { + final store = AiStore(tmp.path); + final body = List.generate(50000, (i) => i % 256); + final f = file('drop', '/drop', body); + var calls = 0; + final port = server.port; + await server.close(force: true); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + var allow = false; + server.listen((req) async { + calls++; + final range = req.headers.value(HttpHeaders.rangeHeader); + final from = range == null ? 0 : int.parse(RegExp(r'bytes=(\d+)-').firstMatch(range)!.group(1)!); + if (!allow) { + final sock = await req.response.detachSocket(writeHeaders: false); + final n = (from + 5000).clamp(0, body.length); + sock.add(utf8.encode('HTTP/1.1 ${range == null ? '200 OK' : '206 Partial Content'}\r\nContent-Length: ${body.length - from}\r\n\r\n')); + sock.add(body.sublist(from, n)); + await sock.flush(); + sock.destroy(); + return; + } + req.response + ..statusCode = range == null ? 200 : 206 + ..add(body.sublist(from)); + await req.response.close(); + }); + await expectLater(store.installFile(f, retries: 2, retryWait: const Duration(milliseconds: 10)), + throwsA(isA().having((e) => e.message, 'message', allOf(contains('이어 받기'), isNot(contains('http')))))); + expect(calls, 3); + expect(store.failures['drop'], contains('연결이 끊겼습니다')); + expect(store.neededSpace(f), lessThan(body.length), reason: '받은 조각을 남김'); + allow = true; + await store.installFile(f, retryWait: const Duration(milliseconds: 10)); + expect(File(store.pathOf(f)).readAsBytesSync(), body); + expect(store.failures, isEmpty); + }); + + test('149: 한꺼번에 받기 - 기다리는 것은 "차례 기다림"', () async { + final store = AiStore(tmp.path); + final a = file('qa', '/qa', List.filled(1000, 1)), b2 = file('qb', '/qb', List.filled(1000, 2)); + final seen = {}; + void listen() { + if (store.progress.containsKey('qa') && store.queued.contains('qb')) seen.add(store.progressText('qb')!); + } + store.addListener(listen); + await store.installAll([a, b2]); + store.removeListener(listen); + expect(seen, contains('차례 기다림')); + expect(store.isFileInstalled(a) && store.isFileInstalled(b2), isTrue); + expect(store.queued, isEmpty); + }); + test('실행 파일 zip 은 풀어 둔다', () async { final store = AiStore(tmp.path); final arc = Archive()..addFile(ArchiveFile.bytes('sd-cli.exe', utf8.encode('fake')));