Skip to content

Commit f67ae5a

Browse files
committed
feat: harden first-run model setup and imports
1 parent 6a30e60 commit f67ae5a

18 files changed

Lines changed: 693 additions & 19 deletions

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@
33
All notable changes to Aura are documented here.
44
Format follows [Keep a Changelog](https://keepachangelog.com/).
55

6+
## [0.2.6] — 2026-05-27
7+
8+
### Added
9+
- Release validation script for Android arm64 builds, checksum output, and core regression checks
10+
- Tavern compatibility fixture matrix for JSON V2/V3 cards, PNG text variants, embedded worldbooks, and standalone worldbooks
11+
12+
### Changed
13+
- Made first-run model setup clearer about local storage needs and retrying interrupted downloads
14+
- Split the import entry point into clearer paths for photo PNG cards, file cards, standalone worldbooks, and manual story-card creation
15+
16+
### Fixed
17+
- Prevented resumed downloads from corrupting files when a server ignores HTTP range requests and restarts from byte zero
18+
619
## [0.2.5] — 2026-05-07
720

821
### Added
@@ -108,6 +121,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/).
108121
- Hardware delegate auto-selection (CoreML / GPU / CPU)
109122
- Context window management with heuristic summarization
110123

124+
[0.2.6]: https://github.com/wimi321/aura/releases/tag/v0.2.6
111125
[0.2.5]: https://github.com/wimi321/aura/releases/tag/v0.2.5
112126
[0.2.4]: https://github.com/wimi321/aura/releases/tag/v0.2.4
113127
[0.2.3]: https://github.com/wimi321/aura/releases/tag/v0.2.3

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ flutter pub get
112112
flutter run
113113
```
114114

115+
Release validation for the Android arm64 package:
116+
117+
```bash
118+
./scripts/validate_release_android_arm64.sh
119+
```
120+
115121
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed build instructions including iOS.
116122

117123
---

README.zh-CN.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ flutter pub get
112112
flutter run
113113
```
114114

115+
Android arm64 发布包验证:
116+
117+
```bash
118+
./scripts/validate_release_android_arm64.sh
119+
```
120+
115121
详细构建指南(含 iOS)请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)
116122

117123
---

lib/application/providers/app_state_provider.dart

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class AppStateProvider extends ChangeNotifier {
5656
String? _errorMessage;
5757
String? _downloadingModelId;
5858
String? _cancelledDownloadId;
59+
String? _failedDownloadModelId;
5960
double _downloadProgress = 0;
6061
int _downloadReceivedBytes = 0;
6162
int _downloadTotalBytes = 0;
@@ -79,6 +80,7 @@ class AppStateProvider extends ChangeNotifier {
7980
List<ModelManifest> get availableModels => _availableModels;
8081
ModelManifest? get activeModel => engine.modelManager.activeModel;
8182
String? get downloadingModelId => _downloadingModelId;
83+
String? get failedDownloadModelId => _failedDownloadModelId;
8284
double get downloadProgress => _downloadProgress;
8385
int get downloadReceivedBytes => _downloadReceivedBytes;
8486
int get downloadTotalBytes => _downloadTotalBytes;
@@ -759,6 +761,7 @@ class AppStateProvider extends ChangeNotifier {
759761

760762
_errorMessage = null;
761763
_cancelledDownloadId = null;
764+
_failedDownloadModelId = null;
762765
_downloadingModelId = manifest.id;
763766
_downloadProgress = 0;
764767
_downloadReceivedBytes = 0;
@@ -775,20 +778,26 @@ class AppStateProvider extends ChangeNotifier {
775778
snapshot.errorMessage != null &&
776779
_cancelledDownloadId != manifest.id) {
777780
_errorMessage = _presentableError(snapshot.errorMessage!);
781+
_failedDownloadModelId = manifest.id;
778782
}
779783
_notifyListenersSafely();
780784
}
781785

782786
await refreshModels();
783-
await _attemptModelSwitch(manifest);
787+
final bool switched = await _attemptModelSwitch(manifest);
788+
if (switched) {
789+
_failedDownloadModelId = null;
790+
}
784791
} catch (error) {
785792
if (_cancelledDownloadId != manifest.id) {
786793
_errorMessage = _presentableError(error);
794+
_failedDownloadModelId = manifest.id;
787795
}
788796
_notifyListenersSafely();
789797
} finally {
790798
if (_cancelledDownloadId == manifest.id) {
791799
_errorMessage = null;
800+
_failedDownloadModelId = null;
792801
}
793802
_cancelledDownloadId = null;
794803
_downloadingModelId = null;

lib/presentation/pages/chat/chat_page.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,19 @@ class _ChatPageState extends State<ChatPage> with TickerProviderStateMixin {
12901290
],
12911291
),
12921292
),
1293+
IconButton(
1294+
key: const ValueKey<String>('chat-history-button'),
1295+
tooltip: l10n?.sessionHistoryShortLabel ?? 'History',
1296+
onPressed: disableOverflow
1297+
? null
1298+
: () => unawaited(_openSessionHistory()),
1299+
icon: Icon(
1300+
Icons.history_rounded,
1301+
color: disableOverflow
1302+
? AppTheme.textMuted
1303+
: AppTheme.textSecondary,
1304+
),
1305+
),
12931306
PopupMenuButton<_ChatMenuAction>(
12941307
key: const ValueKey<String>('chat-overflow-button'),
12951308
enabled: !disableOverflow,

lib/presentation/pages/model_setup/model_setup_page.dart

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,8 @@ class _SetupModelCardState extends State<_SetupModelCard> {
260260
final bool isInstalled = appState.isInstalled(widget.manifest);
261261
final bool isActive = appState.isActive(widget.manifest);
262262
final bool isDownloading = appState.isDownloading(widget.manifest);
263+
final bool downloadFailed =
264+
appState.failedDownloadModelId == widget.manifest.id && !isInstalled;
263265
final bool anotherDownloadInProgress =
264266
appState.downloadingModelId != null &&
265267
appState.downloadingModelId != widget.manifest.id;
@@ -382,6 +384,12 @@ class _SetupModelCardState extends State<_SetupModelCard> {
382384
),
383385
],
384386
),
387+
if (!isInstalled) ...<Widget>[
388+
const SizedBox(height: 14),
389+
_DownloadPrepNote(
390+
text: _downloadPrepCopy(context, widget.manifest),
391+
),
392+
],
385393
if (isDownloading) ...<Widget>[
386394
const SizedBox(height: 24),
387395
ClipRRect(
@@ -444,8 +452,10 @@ class _SetupModelCardState extends State<_SetupModelCard> {
444452
: isInstalled
445453
? (l10n?.activateEngineButton ??
446454
'Activate Core')
447-
: (l10n?.downloadInstallButton ??
448-
'Download & Install'),
455+
: downloadFailed
456+
? _retryDownloadLabel(context)
457+
: (l10n?.downloadInstallButton ??
458+
'Download & Install'),
449459
style: const TextStyle(fontWeight: FontWeight.w700),
450460
),
451461
),
@@ -490,6 +500,45 @@ class _SetupModelCardState extends State<_SetupModelCard> {
490500
}
491501
}
492502

503+
class _DownloadPrepNote extends StatelessWidget {
504+
const _DownloadPrepNote({required this.text});
505+
506+
final String text;
507+
508+
@override
509+
Widget build(BuildContext context) {
510+
return Container(
511+
width: double.infinity,
512+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
513+
decoration: BoxDecoration(
514+
color: AppTheme.bgElevated.withValues(alpha: 0.72),
515+
borderRadius: BorderRadius.circular(16),
516+
border: Border.all(color: AppTheme.borderSubtle),
517+
),
518+
child: Row(
519+
crossAxisAlignment: CrossAxisAlignment.start,
520+
children: <Widget>[
521+
const Icon(
522+
Icons.storage_rounded,
523+
size: 18,
524+
color: AppTheme.textSecondary,
525+
),
526+
const SizedBox(width: 10),
527+
Expanded(
528+
child: Text(
529+
text,
530+
style: Theme.of(context).textTheme.bodySmall?.copyWith(
531+
color: AppTheme.textSecondary,
532+
height: 1.4,
533+
),
534+
),
535+
),
536+
],
537+
),
538+
);
539+
}
540+
}
541+
493542
class _Badge extends StatelessWidget {
494543
const _Badge({required this.label, required this.color});
495544

@@ -592,6 +641,25 @@ String _localizedRamHint(BuildContext context, ModelManifest manifest) {
592641
return l10n?.modelSetupRamHint(memory) ?? 'Recommended RAM $memory';
593642
}
594643

644+
String _downloadPrepCopy(BuildContext context, ModelManifest manifest) {
645+
final String locale =
646+
Localizations.localeOf(context).toLanguageTag().toLowerCase();
647+
final String size = formatBytes(manifest.sizeBytes);
648+
if (locale.startsWith('zh')) {
649+
return '下载前请预留至少 $size 的可用空间。下载中断后可直接重试,Aura 会尽量接着未完成的部分继续。';
650+
}
651+
return 'Keep at least $size free before downloading. If the download is interrupted, tap retry and Aura will continue whenever possible.';
652+
}
653+
654+
String _retryDownloadLabel(BuildContext context) {
655+
final String locale =
656+
Localizations.localeOf(context).toLanguageTag().toLowerCase();
657+
if (locale.startsWith('zh')) {
658+
return '重试下载';
659+
}
660+
return 'Retry Download';
661+
}
662+
595663
String _localizedModelError(BuildContext context, String message) {
596664
final AppLocalizations? l10n = AppLocalizations.of(context);
597665
switch (message) {

lib/presentation/widgets/import_preview_dialog.dart

Lines changed: 85 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import '../../core/platform/file_selector_type_groups.dart';
1414
import '../../core/theme/app_theme.dart';
1515
import 'character_editor_dialog.dart';
1616

17-
enum _ImportSource { photos, files }
17+
enum _ImportSource { photos, files, worldbook, create }
1818

1919
class ImportPreviewDialog extends StatefulWidget {
2020
const ImportPreviewDialog({
@@ -102,6 +102,10 @@ class _ImportPreviewDialogState extends State<ImportPreviewDialog> {
102102
}
103103

104104
Future<void> _pickAndParseFromSource(_ImportSource source) async {
105+
if (source == _ImportSource.create) {
106+
await _createCharacter();
107+
return;
108+
}
105109
if (_busy || _pickingFile) {
106110
return;
107111
}
@@ -151,6 +155,37 @@ class _ImportPreviewDialogState extends State<ImportPreviewDialog> {
151155
_pickingFile = false;
152156
_busy = true;
153157
});
158+
if (source == _ImportSource.worldbook) {
159+
try {
160+
final LorebookImportPreview lorebookPreview =
161+
await lorebookPreviewLoader(sourceFile);
162+
if (!mounted) {
163+
return;
164+
}
165+
setState(() {
166+
_preview = null;
167+
_lorebookPreview = lorebookPreview;
168+
_selectedLorebookTargetCharacterId ??=
169+
appState.availableCharacters.isEmpty
170+
? null
171+
: appState.availableCharacters.first.id;
172+
_busy = false;
173+
_error = null;
174+
});
175+
return;
176+
} catch (error) {
177+
if (!mounted) {
178+
return;
179+
}
180+
setState(() {
181+
_busy = false;
182+
_preview = null;
183+
_lorebookPreview = null;
184+
_error = _friendlyErrorMessage(error);
185+
});
186+
return;
187+
}
188+
}
154189
try {
155190
final CharacterImportPreview preview = await previewLoader(sourceFile);
156191
if (!mounted) {
@@ -212,10 +247,13 @@ class _ImportPreviewDialogState extends State<ImportPreviewDialog> {
212247
}
213248

214249
Future<_ImportSource?> _chooseImportSource() async {
215-
if (!_supportsPhotoImport) {
216-
return _ImportSource.files;
217-
}
218250
final AppLocalizations? l10n = AppLocalizations.of(context);
251+
final bool isZh = Localizations.localeOf(context)
252+
.toLanguageTag()
253+
.toLowerCase()
254+
.startsWith(
255+
'zh',
256+
);
219257
return showModalBottomSheet<_ImportSource>(
220258
context: context,
221259
backgroundColor: AppTheme.cardElevated,
@@ -249,16 +287,19 @@ class _ImportPreviewDialogState extends State<ImportPreviewDialog> {
249287
),
250288
),
251289
const SizedBox(height: 16),
252-
_buildImportSourceTile(
253-
context: sheetContext,
254-
key: const ValueKey<String>('import-source-photos'),
255-
icon: Icons.photo_library_rounded,
256-
title: l10n?.importFromPhotosTitle ?? 'Import From Photos',
257-
subtitle: l10n?.importFromPhotosSubtitle ??
258-
'Pick a Tavern PNG card that is already in your photo library.',
259-
source: _ImportSource.photos,
260-
),
261-
const SizedBox(height: 10),
290+
if (_supportsPhotoImport) ...<Widget>[
291+
_buildImportSourceTile(
292+
context: sheetContext,
293+
key: const ValueKey<String>('import-source-photos'),
294+
icon: Icons.photo_library_rounded,
295+
title:
296+
l10n?.importFromPhotosTitle ?? 'Import From Photos',
297+
subtitle: l10n?.importFromPhotosSubtitle ??
298+
'Pick a Tavern PNG card that is already in your photo library.',
299+
source: _ImportSource.photos,
300+
),
301+
const SizedBox(height: 10),
302+
],
262303
_buildImportSourceTile(
263304
context: sheetContext,
264305
key: const ValueKey<String>('import-source-files'),
@@ -268,6 +309,28 @@ class _ImportPreviewDialogState extends State<ImportPreviewDialog> {
268309
'Pick a Tavern PNG card, JSON card, or standalone worldbook from Files.',
269310
source: _ImportSource.files,
270311
),
312+
const SizedBox(height: 10),
313+
_buildImportSourceTile(
314+
context: sheetContext,
315+
key: const ValueKey<String>('import-source-worldbook'),
316+
icon: Icons.menu_book_rounded,
317+
title: isZh ? '导入世界书' : 'Import Worldbook',
318+
subtitle: isZh
319+
? '选择独立世界书 JSON,并把它挂到已有剧情卡下面。'
320+
: 'Pick a standalone worldbook JSON and attach it to an existing story card.',
321+
source: _ImportSource.worldbook,
322+
),
323+
const SizedBox(height: 10),
324+
_buildImportSourceTile(
325+
context: sheetContext,
326+
key: const ValueKey<String>('import-source-create'),
327+
icon: Icons.draw_rounded,
328+
title: l10n?.createCharacterButton ?? 'Create Character',
329+
subtitle: isZh
330+
? '不导入文件,直接自己写一张新的剧情卡。'
331+
: 'Start from a blank story card instead of importing a file.',
332+
source: _ImportSource.create,
333+
),
271334
],
272335
),
273336
),
@@ -346,6 +409,14 @@ class _ImportPreviewDialogState extends State<ImportPreviewDialog> {
346409
acceptedTypeGroups: characterCardImportTypeGroups,
347410
));
348411
return picked == null ? null : File(picked.path);
412+
case _ImportSource.worldbook:
413+
final XFile? picked = await (widget.pickFile?.call() ??
414+
openFile(
415+
acceptedTypeGroups: lorebookImportTypeGroups,
416+
));
417+
return picked == null ? null : File(picked.path);
418+
case _ImportSource.create:
419+
return null;
349420
}
350421
}
351422

packages/aura_core/lib/src/infrastructure/http_resumable_model_downloader.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ class HttpResumableModelDownloader implements ModelDownloader {
114114
uri: uri,
115115
);
116116
}
117+
if (existingBytes > 0 && response.statusCode == HttpStatus.ok) {
118+
// Some hosts ignore Range and restart from byte 0. In that case, appending
119+
// would corrupt the model by duplicating the already downloaded prefix.
120+
existingBytes = 0;
121+
if (await partialFile.exists()) {
122+
await partialFile.delete();
123+
}
124+
}
117125

118126
final IOSink sink = partialFile.openWrite(
119127
mode: existingBytes > 0 ? FileMode.append : FileMode.writeOnly,

0 commit comments

Comments
 (0)