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
40 changes: 31 additions & 9 deletions lib/screens/assistant_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:nova_assistant/models/model_info.dart';
import 'package:nova_assistant/services/document_extractor.dart';
import 'package:nova_assistant/services/chat_history_service.dart';
import 'package:nova_assistant/services/conversation_summary_service.dart';
import 'package:nova_assistant/services/export_service.dart';
import 'package:nova_assistant/services/model_orchestrator.dart';
import 'package:nova_assistant/services/model_release_policy.dart';
import 'package:nova_assistant/services/tts_service.dart';
Expand Down Expand Up @@ -1544,15 +1545,27 @@ class _AssistantScreenState extends State<AssistantScreen>
PopupMenuButton<String>(
tooltip: 'Export conversation',
onSelected: (format) async {
String? path;
path = format == 'text'
final content = format == 'text'
? await ChatHistoryService.exportAsText()
: await ChatHistoryService.exportAsJson();
if (path != null && mounted) {
if (!mounted) return;
if (content == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Exported to:\n$path'),
duration: const Duration(seconds: 4),
const SnackBar(content: Text('Nothing to export')),
);

return;
}

final fileName = format == 'text'
? 'nova_export.txt'
: 'nova_export.json';
await ExportService.instance.shareText(content, fileName);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Share sheet opened'),
duration: Duration(seconds: 2),
),
);
}
Expand Down Expand Up @@ -2109,11 +2122,20 @@ class _AssistantScreenState extends State<AssistantScreen>
),
const SizedBox(width: 8),
VoiceInputButton(
onPartial: (partial) {
_inputController.text = partial;
_inputController.selection = TextSelection.collapsed(
offset: partial.length,
);
},
onTranscription: (transcript) {
if (transcript.isNotEmpty) {
_inputController.text = transcript;
_sendMessage();
if (transcript.isEmpty) return;
_inputController.text = transcript;
if (_isGenerating || ModelOrchestrator.instance.isBusy) {
// Leave full text in the field for the user to send.
return;
}
_sendMessage();
},
),
const SizedBox(width: 8),
Expand Down
14 changes: 11 additions & 3 deletions lib/screens/assistant_screen_beginner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -489,11 +489,19 @@ class _AssistantScreenBeginnerState extends State<AssistantScreenBeginner> {
),
const SizedBox(width: 12),
VoiceInputButton(
onPartial: (partial) {
_inputController.text = partial;
_inputController.selection = TextSelection.collapsed(
offset: partial.length,
);
},
onTranscription: (text) {
if (text.isNotEmpty) {
_inputController.text = text;
_sendMessage();
if (text.isEmpty) return;
_inputController.text = text;
if (_isGenerating || ModelOrchestrator.instance.isBusy) {
return;
}
_sendMessage();
},
),
const SizedBox(width: 8),
Expand Down
24 changes: 16 additions & 8 deletions lib/screens/chat_history_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:nova_assistant/models/conversation.dart';
import 'package:nova_assistant/screens/assistant_screen.dart';
import 'package:nova_assistant/services/chat_history_service.dart';
import 'package:nova_assistant/services/export_service.dart';

class ChatHistoryScreen extends StatefulWidget {
const ChatHistoryScreen({super.key});
Expand Down Expand Up @@ -196,17 +197,24 @@ class _ChatHistoryScreenState extends State<ChatHistoryScreen> {
}

Future<void> _exportConversation(Conversation conversation) async {
final path = await ChatHistoryService.exportConversationAsText(
final content = await ChatHistoryService.exportConversationAsText(
conversation.id,
);
if (!mounted) return;
if (content == null) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Export failed')));

return;
}

await ExportService.instance.shareText(
content,
'nova_chat_${conversation.id}.txt',
);
if (mounted) {
if (path != null) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Exported to:\n$path')));
} else {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Export failed')));
}
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Share sheet opened')));
}
}

Expand Down
74 changes: 26 additions & 48 deletions lib/services/chat_history_service.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import 'dart:convert';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:nova_assistant/models/chat_message.dart';
import 'package:nova_assistant/models/conversation.dart';
Expand Down Expand Up @@ -187,6 +185,7 @@ class ChatHistoryService {
await p.remove(_key);
}

/// Builds a plain-text export of all conversations (no file write).
static Future<String?> exportAsText() async {
try {
final conversations = await loadConversations();
Expand All @@ -206,35 +205,18 @@ class ChatHistoryService {
buffer.writeln('-' * 30);

for (final msg in convo.messages) {
final time = msg.timestamp.toLocal();
final timeStr =
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
final sender = msg.isUser ? 'User' : 'Assistant';
buffer.writeln('[$timeStr] $sender:');
buffer.writeln(msg.text);
if (msg.imageData != null) buffer.writeln('[Image attached]');
if (msg.toolCalls != null && msg.toolCalls!.isNotEmpty) {
buffer.writeln('[Tool calls: ${msg.toolCalls}]');
}
if (msg.thinking != null && msg.thinking!.isNotEmpty) {
buffer.writeln('[Thinking: ${msg.thinking}]');
}
buffer.writeln();
_appendMessage(buffer, msg);
}
buffer.writeln('=' * 50);
}

final dir = await getApplicationDocumentsDirectory();
final file = File(
'${dir.path}/nova_export_${DateTime.now().millisecondsSinceEpoch}.txt',
);
await file.writeAsString(buffer.toString());
return file.path;
return buffer.toString();
} on Exception {
return null;
}
}

/// Builds a plain-text export of one conversation (no file write).
static Future<String?> exportConversationAsText(String conversationId) async {
try {
final convo = await getConversation(conversationId);
Expand All @@ -248,33 +230,16 @@ class ChatHistoryService {
buffer.writeln('---');

for (final msg in convo.messages) {
final time = msg.timestamp.toLocal();
final timeStr =
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
final sender = msg.isUser ? 'User' : 'Assistant';
buffer.writeln('[$timeStr] $sender:');
buffer.writeln(msg.text);
if (msg.imageData != null) buffer.writeln('[Image attached]');
if (msg.toolCalls != null && msg.toolCalls!.isNotEmpty) {
buffer.writeln('[Tool calls: ${msg.toolCalls}]');
}
if (msg.thinking != null && msg.thinking!.isNotEmpty) {
buffer.writeln('[Thinking: ${msg.thinking}]');
}
buffer.writeln();
_appendMessage(buffer, msg);
}

final dir = await getApplicationDocumentsDirectory();
final file = File(
'${dir.path}/nova_export_${DateTime.now().millisecondsSinceEpoch}.txt',
);
await file.writeAsString(buffer.toString());
return file.path;
return buffer.toString();
} on Exception {
return null;
}
}

/// Builds a JSON export of all conversations (no file write).
static Future<String?> exportAsJson() async {
try {
final conversations = await loadConversations();
Expand All @@ -286,14 +251,27 @@ class ChatHistoryService {
'conversations': conversations.map((c) => c.toJson()).toList(),
};

final dir = await getApplicationDocumentsDirectory();
final file = File(
'${dir.path}/nova_export_${DateTime.now().millisecondsSinceEpoch}.json',
);
await file.writeAsString(jsonEncode(export));
return file.path;
return const JsonEncoder.withIndent(' ').convert(export);
} on Exception {
return null;
}
}

static void _appendMessage(StringBuffer buffer, ChatMessage msg) {
final time = msg.timestamp.toLocal();
final timeStr =
'${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}';
final sender = msg.isUser ? 'User' : 'Assistant';
buffer.writeln('[$timeStr] $sender:');
buffer.writeln(msg.text);
if (msg.imageData != null) buffer.writeln('[Image attached]');
if (msg.toolCalls != null && msg.toolCalls!.isNotEmpty) {
buffer.writeln('[Tool calls: ${msg.toolCalls}]');
}
if (msg.thinking != null && msg.thinking!.isNotEmpty) {
buffer.writeln('[Thinking: ${msg.thinking}]');
}
buffer.writeln();
}
}
57 changes: 57 additions & 0 deletions lib/services/model_orchestrator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import 'package:nova_assistant/services/platform_adaptation_service.dart';
import 'package:nova_assistant/utils/alarm_time_parser.dart';
import 'package:nova_assistant/utils/agent_debug_log.dart';
import 'package:nova_assistant/utils/message_limits.dart';
import 'package:nova_assistant/utils/open_app_intent_parser.dart';
import 'package:nova_assistant/services/memory_diagnostics_service.dart';
import 'package:nova_assistant/services/session_history_reinjection.dart';

Expand Down Expand Up @@ -1578,6 +1579,29 @@ class ModelOrchestrator {
return;
}

// Deterministic open-app shortcut (YouTube, Settings, etc.).
final openPackage = OpenAppIntentParser.tryParsePackage(query);
if (openPackage != null) {
_statusController.add('Opening app...');
final result = await ToolExecutorService.instance.openApp(openPackage);
final ok = result['success'] == true;
yield InferenceResult(
text: ok
? 'Opened $openPackage.'
: 'Could not open the app: ${result['error'] ?? 'unknown error'}',
model: selector.primaryHeavy,
isStreaming: false,
toolCalls: [
{
'name': 'open_app',
'args': {'package': openPackage},
'result': result,
},
],
);
return;
}

// Check if there are image attachments that need vision processing
final hasImageAttachments = attachments.any((att) {
if (att.type != AttachedDataType.file) return false;
Expand Down Expand Up @@ -1702,6 +1726,7 @@ class ModelOrchestrator {
}

final chatTools = _toolsForCreateChat(model, tools);
final textToolPrompt = chatTools.isEmpty && tools.isNotEmpty;
final wasNull = _activeChat == null;
if (wasNull) {
_isLoadingModel = true;
Expand All @@ -1713,6 +1738,7 @@ class ModelOrchestrator {
model,
ragContext,
_capContextInjection(attachmentContext),
textToolPrompt ? tools : null,
),
tools: chatTools,
supportImage: model.hasVision && _activeModelSupportsImage,
Expand Down Expand Up @@ -2283,6 +2309,7 @@ class ModelOrchestrator {
NovaModel model, [
String? ragContext,
String? attachmentContext,
List<Tool>? textTools,
]) async {
final compact =
!kIsWeb &&
Expand Down Expand Up @@ -2312,6 +2339,15 @@ class ModelOrchestrator {
buffer.write('\n\n${await _languageInstruction()}');
}

// Compact Android prompts drop the role's tool list — restore capability
// so the model does not claim it cannot open apps / set alarms.
// Gate to Android only: web FC-off must not get "Android device" wording.
final isAndroid =
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
if (isAndroid && (compact || (textTools != null && textTools.isNotEmpty))) {
buffer.write(_deviceToolsCapabilitySuffix(textTools));
}

if (_adultModeEnabled) {
buffer.write(AdultModePolicy.systemPromptSuffix(compact: compact));
}
Expand Down Expand Up @@ -2342,6 +2378,27 @@ class ModelOrchestrator {
return buffer.toString();
}

/// Short capability block for on-device tools (and text-JSON FC when native FC
/// is unavailable on Android Gemma 4).
String _deviceToolsCapabilitySuffix(List<Tool>? textTools) {
final names = (textTools ?? const <Tool>[])
.map((t) => t.name)
.where((n) => n.isNotEmpty)
.toList();
final listed = names.isEmpty
? 'get_time, set_alarm, cancel_alarm, open_app, open_settings, search_web'
: names.join(', ');

return ' You control this Android device via tools: $listed. '
'When the user asks to open an app, set/cancel an alarm, open settings, '
'or search the web, call the tool immediately — do not say you lack '
'device access. '
'open_app packages: com.google.android.youtube, com.android.settings, '
'com.android.chrome. '
'If native function calling is unavailable, reply with only JSON like '
'{"name":"open_app","arguments":{"package":"com.google.android.youtube"}}.';
}

static AgentIdentity? _cachedIdentity;

static AgentIdentity? _getCachedIdentity() => _cachedIdentity;
Expand Down
10 changes: 7 additions & 3 deletions lib/tools/tool_definitions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,18 @@ class NovaTools {
name: 'open_app',
description:
'Open an application on the device. '
'Use this ONLY when the user explicitly asks to open '
'or launch a specific application.',
'Use when the user asks to open/launch an app (English or German: '
'open, launch, öffne, starte). '
'Common packages: YouTube=com.google.android.youtube, '
'Settings=com.android.settings, Chrome=com.android.chrome, '
'Spotify=com.spotify.music, Maps=com.google.android.apps.maps.',
parameters: {
'type': 'object',
'properties': {
'package': {
'type': 'string',
'description': 'App package name (e.g. com.twitter.android)',
'description':
'Android package name, e.g. com.google.android.youtube',
},
},
'required': ['package'],
Expand Down
Loading
Loading