Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions doc/PLAN-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,19 @@

## Next up (priority order)

Plans: [`docs/superpowers/plans/2026-07-18-README.md`](../docs/superpowers/plans/2026-07-18-README.md)

### P0 — Hygiene

1. **Soak-test cleanup** — Remove `AGENT_DBG` / ingest helpers after device verification.
1. **Soak-test cleanup** — Remove `AGENT_DBG` / ingest helpers after device verification (PR #6 / F1). Then bump **0.4.1**.

### P1 — UX / intelligence

2. Conversation branching from a message
3. Share message / chat sheet
4. Continuous dictation / audio attach
2. **Conversation branching from a message** — [plan](../docs/superpowers/plans/2026-07-18-conversation-branching.md) (ready; implement after greenlight)
3. Share message / chat sheet — [stub](../docs/superpowers/plans/2026-07-18-share-sheet-polish.md)
4. Continuous dictation / audio attach — [stub](../docs/superpowers/plans/2026-07-18-dictation-audio.md)
5. ~~Free-RAM hard gate before Gemma 4 load~~
6. Heavy models / image gen — X8 Pro Max soak or [LAN remote](../docs/remote-inference.md); not F1 (see [models.md](../docs/models.md))

### P2 — Platform

Expand Down
18 changes: 17 additions & 1 deletion lib/models/model_info.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,21 @@ class ModelHuggingFaceURLs {
}
}

/// Gemma repos are gated on HuggingFace and return 401 without a token.
static bool requiresHuggingFaceAuth(NovaModel model) {
return model == NovaModel.gemma3_1b || model == NovaModel.gemma4E2b;
}

/// True when [url] points at a known gated Gemma asset.
static bool urlRequiresHuggingFaceAuth(String url) {
final lower = url.toLowerCase();

return lower.contains('gemma3-1b') ||
lower.contains('gemma-4') ||
lower.contains('/gemma3') ||
lower.contains('/gemma-4');
}

static String fileNameFor(NovaModel model) {
switch (model) {
case NovaModel.smollm:
Expand Down Expand Up @@ -188,7 +203,8 @@ extension NovaModelExtensions on NovaModel {

String get sizeLabel => '$sizeMB MB';

bool get supportsFunctionCalling => true;
/// Catalog capability. Runtime may still refuse tools (e.g. MediaPipe SmolLM).
bool get supportsFunctionCalling => this != NovaModel.smollm;

List<String> get capabilityList {
final list = <String>['Function Calling'];
Expand Down
141 changes: 103 additions & 38 deletions lib/screens/assistant_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import 'package:nova_assistant/screens/custom_model_import_sheet.dart';
import 'package:nova_assistant/services/follow_up_suggestion_service.dart';
import 'package:nova_assistant/services/memory_diagnostics_service.dart';
import 'package:nova_assistant/services/platform_adaptation_service.dart';
import 'package:nova_assistant/utils/agent_debug_log.dart';
import 'package:nova_assistant/utils/message_limits.dart';
import 'package:nova_assistant/widgets/suggestion_chip.dart';
import 'package:shared_preferences/shared_preferences.dart';
Expand Down Expand Up @@ -430,7 +431,26 @@ class _AssistantScreenState extends State<AssistantScreen>
}

_inputController.text = text;
_sendMessage();
unawaited(_sendMessage());
}

/// Whether the query asks about the device screen / a screenshot.
bool _wantsDeviceScreen(String query, {required bool hasImage}) {
if (hasImage) return false;
final q = query.toLowerCase();

return q.contains('screenshot') ||
q.contains('capture the screen') ||
q.contains('capture my screen') ||
q.contains("what's on my screen") ||
q.contains('whats on my screen') ||
q.contains('what is on my screen') ||
q.contains('on my screen') ||
(q.contains('screen') &&
(q.contains('look') ||
q.contains('see') ||
q.contains('show') ||
q.contains('what')));
}

Future<void> _checkModelAvailability() async {
Expand Down Expand Up @@ -614,6 +634,9 @@ class _AssistantScreenState extends State<AssistantScreen>
setState(() {
_messages.removeRange(errorIndex, _messages.length);
});
ModelOrchestrator.instance.invalidateSessionForReplay(
List<ChatMessage>.from(_messages.where((m) => !m.isStreaming)),
);

// Re-send
_inputController.text = userText;
Expand All @@ -635,6 +658,9 @@ class _AssistantScreenState extends State<AssistantScreen>
setState(() {
_messages.removeRange(assistantIndex, _messages.length);
});
ModelOrchestrator.instance.invalidateSessionForReplay(
List<ChatMessage>.from(_messages.where((m) => !m.isStreaming)),
);

// Re-send the user message
_inputController.text = userText;
Expand Down Expand Up @@ -679,15 +705,32 @@ class _AssistantScreenState extends State<AssistantScreen>
],
),
);
controller.dispose();
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.dispose();
});
if (result == null || result.isEmpty || !mounted) return;

await TtsService.instance.stop();
if (!mounted) return;
setState(() {
_messages.removeRange(userIndex, _messages.length);
});
Comment on lines 713 to 717

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is an asynchronous gap (await TtsService.instance.stop()) before _messages.removeRange is called. During this await, the user could have cleared the chat history or switched sessions, meaning _messages could be empty or smaller than userIndex. Calling removeRange without validating userIndex against the current _messages.length will throw a RangeError and crash the UI.

Validate that userIndex is still within bounds of _messages after the asynchronous gap.

Suggested change
await TtsService.instance.stop();
if (!mounted) return;
setState(() {
_messages.removeRange(userIndex, _messages.length);
});
await TtsService.instance.stop();
if (!mounted) return;
if (userIndex < 0 || userIndex >= _messages.length) return;
setState(() {
_messages.removeRange(userIndex, _messages.length);
});

ModelOrchestrator.instance.invalidateSessionForReplay(
List<ChatMessage>.from(_messages.where((m) => !m.isStreaming)),
);
// #region agent log
unawaited(
AgentDebugLog.log(
hypothesisId: 'A',
location: 'assistant_screen.dart:_editUserMessage',
message: 'Edit truncated UI history; session invalidated',
data: {'remaining': _messages.length, 'newQueryLen': result.length},
runId: 'post-fix',
),
);
// #endregion
_inputController.text = result;
_sendMessage();
unawaited(_sendMessage());
}

Future<void> _speakMessage(String text) async {
Expand All @@ -709,20 +752,7 @@ class _AssistantScreenState extends State<AssistantScreen>

// Only offer screen capture when the user asks for the *device screen*
// and no image is already attached (gallery / prior capture).
final wantsDeviceScreen =
!hasImage &&
(q.contains('screenshot') ||
q.contains('capture the screen') ||
q.contains('capture my screen') ||
q.contains("what's on my screen") ||
q.contains('whats on my screen') ||
q.contains('what is on my screen') ||
q.contains('on my screen') ||
(q.contains('screen') &&
(q.contains('look') ||
q.contains('see') ||
q.contains('show') ||
q.contains('what'))));
final wantsDeviceScreen = _wantsDeviceScreen(query, hasImage: hasImage);
if (wantsDeviceScreen) {
tools.add(NovaTools.takeScreenshot);
}
Expand Down Expand Up @@ -792,6 +822,31 @@ class _AssistantScreenState extends State<AssistantScreen>

await _maybeAutoCompact();

// Screen questions need a real image; SmolLM cannot see the device.
final hasImageAlready =
_currentScreenshot != null ||
_attachmentManager.attachments.any(
(a) => a.filePath != null && DocumentExtractor.isImageFile(a.name),
);
if (_wantsDeviceScreen(text, hasImage: hasImageAlready) &&
_currentScreenshot == null) {
await _captureAndAttachScreenshot();
if (!mounted) return;
if (_currentScreenshot == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Screen capture is required to answer "what\'s on my screen?". '
'Allow capture or attach a photo.',
),
duration: Duration(seconds: 3),
),
);

return;
}
}

// Wait for initial screenshot to load if still loading (from assistant mode)
// This ensures screenshot is captured before model selection happens
if (_isLoadingInitialScreenshot && _currentScreenshot == null) {
Expand Down Expand Up @@ -913,13 +968,18 @@ class _AssistantScreenState extends State<AssistantScreen>
});
}
} finally {
setState(() {
if (mounted) {
setState(() {
_isGenerating = false;
if (!_shouldPreserveScreenshot) {
_currentScreenshot = null;
}
_shouldPreserveScreenshot = false;
});
} else {
_isGenerating = false;
if (!_shouldPreserveScreenshot) {
_currentScreenshot = null;
}
_shouldPreserveScreenshot = false;
});
}
await _saveMessages();
_attachmentManager.clear();
}
Expand Down Expand Up @@ -955,7 +1015,7 @@ class _AssistantScreenState extends State<AssistantScreen>
Future<void> _showModelSelectorSheet(BuildContext context) async {
final isAutoMode = _selectedModel == null && _selectedCustomModel == null;

await showModalBottomSheet<void>(
final result = await showModalBottomSheet<String>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
Expand Down Expand Up @@ -989,27 +1049,31 @@ class _AssistantScreenState extends State<AssistantScreen>
}
setState(() {});
},
onImportModel: () {
Navigator.pop(context);
_showCustomModelImportSheet();
},
onImportModel: () => Navigator.pop(context, 'import'),
),
);

if (result == 'import' && mounted) {
await _showCustomModelImportSheet();
}
}

Future<void> _showCustomModelImportSheet() async {
await showModalBottomSheet<void>(
final imported = await showModalBottomSheet<CustomModel>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => CustomModelImportSheet(
onInstalled: (model) {
setState(() {
_selectedCustomModel = model;
});
},
),
builder: (context) => const CustomModelImportSheet(),
);

if (!mounted || imported == null) return;

setState(() {
_selectedCustomModel = imported;
_selectedModel = null;
});
ModelOrchestrator.instance.preferredCustomModel = imported;
ModelOrchestrator.instance.preferredModelType = null;
}

Future<void> _pickImageFromGallery() async {
Expand Down Expand Up @@ -1375,7 +1439,7 @@ class _AssistantScreenState extends State<AssistantScreen>
),
const SizedBox(height: 8),
const Text(
'First GPU compile can take 1–2 minutes. Do not send yet.',
'First compile can take 1–2 minutes. Do not send yet.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white54, fontSize: 12),
),
Expand Down Expand Up @@ -2126,19 +2190,20 @@ class _AssistantScreenState extends State<AssistantScreen>
const SizedBox(width: 8),
VoiceInputButton(
onPartial: (partial) {
if (!mounted) return;
_inputController.text = partial;
_inputController.selection = TextSelection.collapsed(
offset: partial.length,
);
},
onTranscription: (transcript) {
if (transcript.isEmpty) return;
if (!mounted || transcript.isEmpty) return;
_inputController.text = transcript;
if (_isGenerating || ModelOrchestrator.instance.isBusy) {
// Leave full text in the field for the user to send.
return;
}
_sendMessage();
unawaited(_sendMessage());
},
),
const SizedBox(width: 8),
Expand Down
3 changes: 3 additions & 0 deletions lib/screens/assistant_screen_beginner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ class _AssistantScreenBeginnerState extends State<AssistantScreenBeginner> {
setState(() {
_messages.removeRange(assistantIndex, _messages.length);
});
ModelOrchestrator.instance.invalidateSessionForReplay(
List<ChatMessage>.from(_messages.where((m) => !m.isStreaming)),
);

// Re-send the user message
_inputController.text = userText;
Expand Down
6 changes: 3 additions & 3 deletions lib/screens/custom_model_import_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ class _CustomModelImportSheetState extends State<CustomModelImportSheet> {
if (!mounted) return;

if (customModel != null) {
widget.onInstalled?.call(customModel);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
final messenger = ScaffoldMessenger.of(context);
Navigator.pop(context, customModel);
messenger.showSnackBar(
SnackBar(
content: Text('Imported: ${customModel.displayName}'),
backgroundColor: Colors.green,
Expand Down
20 changes: 10 additions & 10 deletions lib/screens/model_browser_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -664,19 +664,19 @@ class _ModelBrowserScreenState extends State<ModelBrowserScreen> {
}

Future<void> _showImportSheet() async {
await showModalBottomSheet<void>(
final imported = await showModalBottomSheet<CustomModel>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => CustomModelImportSheet(
onInstalled: (model) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Imported: ${model.displayName}'),
backgroundColor: Colors.green,
),
);
},
builder: (context) => const CustomModelImportSheet(),
);

if (!mounted || imported == null) return;

ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Imported: ${imported.displayName}'),
backgroundColor: Colors.green,
),
);
}
Expand Down
Loading
Loading